Skip to content

Release 0.5.0: jitter, refresh opt-out, perf improvements#12

Merged
Wil-Collier merged 12 commits into
mainfrom
dev
May 18, 2026
Merged

Release 0.5.0: jitter, refresh opt-out, perf improvements#12
Wil-Collier merged 12 commits into
mainfrom
dev

Conversation

@Wil-Collier

@Wil-Collier Wil-Collier commented May 18, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • feat(retry): Full-jitter on exponential backoff (thundering-herd mitigation)
  • feat(assets): refresh=False option on checkout/checkin/audit/restore (halves round trips in bulk workflows)
  • perf(resources): _fast_json_copy replaces copy.deepcopy in snapshot tracking; list_all caps per-page requests to remaining limit
  • fix(tests): Work around mutmut 3.5.0 set_start_method crash on Python 3.14
  • chore(tests): No-op retry sleep in fixtures, property tests, lint hardening

Validation

  • 304 unit tests passing
  • Mutation testing: 1296 mutants, 0 survived
  • Each commit validated independently before creation
  • No breaking changes; default behaviour preserved on all public APIs

- ruff: enable B, I, UP, RUF rule sets; set line-length=120; ignore E501 in tests
- pyright: upgrade basic → standard; disable override/private rules; enable reportUnnecessaryTypeIgnoreComment
- isort: add missing blank lines between import groups, alphabetize members
- pyupgrade: typing.Callable/Iterable → collections.abc, Dict → dict, timezone.utc → UTC, remove quoted annotations
- remove stale noqa: D401 directive (caught by RUF100)
- B904: add 'from None' to 3 intentional exception replacements
- B028: add stacklevel=2 to warnings.warn call
- B009: replace getattr with constant key with direct attribute access
- remove stale '# type: ignore[assignment]' (caught by reportUnnecessaryTypeIgnoreComment)
Refactor 8 resource __repr__ methods to use local variables instead of
inline ternaries and getattr calls, bringing all lines under 120 chars.
Update the mutmut configuration in pyproject.toml to specify a list of
directories for tests and enable `mutate_only_covered_lines`. This will
improve the accuracy and efficiency of mutation testing by focusing on
relevant code paths and only mutating lines that are already covered by
tests.

Also, update the `mut-reset` target in the Makefile to directly remove
the `.mutmut-cache` directory for a cleaner reset process.
This commit introduces several new property tests to enhance the
robustness of the API client library. The tests cover:

- **ApiObject property handling**: Ensuring that changes are correctly
  tracked and that setting attributes to their existing values doesn't
  mark them as dirty.
- **Asset custom field staging**: Verifying the correct behavior of
  `set_custom_field`, including staging, cancellation, and handling of
  unknown fields.
- **BaseResourceManager list_all pagination**: Testing that `list_all`
  correctly handles pagination and respects the provided limit.
- **Pure utility functions**: Confirming the reliability of
  `_parse_retry_after` and `_stringify_messages` under various inputs.
- what: replace retry transport's sleep with a no-op lambda in the
  shared snipeit_client fixture and test_logging's client_with_token
- why: tests that exhaust retries (5xx mapping, transport-error paths)
  no longer incur ~2.1s real backoff delay per test
- risk: none — tests needing real timing construct their own transport
- what: add _full_jitter helper and jitter= parameter to RetryTransport;
  backoff delays are passed through jitter(base) before sleeping
- why: desynchronise retries across concurrent clients hitting the same
  Snipe-IT instance (thundering-herd mitigation)
- risk: default behaviour changes from deterministic to randomised delays;
  Retry-After headers are still honoured verbatim (no jitter applied)
- what: replace copy.deepcopy with a JSON-specialised recursive copy in
  _safe_snapshot; cap list_all per-page request to min(page_size,
  remaining) and raise default page_size from 50 to 100
- why: deepcopy overhead dominated ApiObject construction on large
  list_all results; requesting 500 rows when only 5 remain wastes
  bandwidth and server resources
- risk: _fast_json_copy falls back to deepcopy for non-JSON types;
  default page_size change is backward-compatible (server-side max
  still applies)
- what: add keyword-only refresh parameter (default True) to the four
  asset action methods; when False, skip the follow-up GET and return
  the same instance
- why: halves round trips per action in bulk workflows where the caller
  does not need the updated local state
- risk: none — default behaviour is unchanged; callers opting in accept
  stale local state until they call refresh() themselves
@coderabbitai

coderabbitai Bot commented May 18, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

Release 0.5.0 introduces retry backoff jitter, optional refresh parameter for asset operations, pagination efficiency gains, JSON snapshot optimization, Pyright type-checking hardening, comprehensive property-based test coverage, and systematic import/formatting cleanup.

Changes

v0.5.0 Release: Features, Optimizations, and Testing

Layer / File(s) Summary
Release metadata and configuration
CHANGELOG.md, Makefile, pyproject.toml, pyrightconfig.json
Version bumped to 0.5.0. Mutation testing targets simplified for mutmut 3.x. Pyright type-checking mode upgraded from basic to standard with stricter override/ignore reporting.
Retry transport jitter support
snipeit/_retry.py, tests/unit/test_retries.py
RetryTransport accepts optional jitter callable for exponential-backoff randomization. New _full_jitter() default implementation; Retry-After headers bypass jitter. HTTP-date parsing uses datetime.UTC. Unit tests validate custom jitter, bounds, and header precedence.
Asset operation refresh control
snipeit/resources/assets/model.py, tests/unit/resources/test_assets.py
checkout(), checkin(), audit(), restore() accept keyword-only refresh: bool = True to optionally skip follow-up GET requests. Tests verify both refresh and no-refresh behaviors with request assertion chains.
List_all pagination optimization and snapshot performance
snipeit/resources/base.py, tests/unit/test_property_list_all.py, tests/unit/resources/test_pagination.py
list_all() refactored to cap per-request limits when limit is set, reducing over-fetching; default page_size increased 50→100. _fast_json_copy() optimizes dirty-tracking snapshots via recursive dict/list copying. Property-based and deterministic tests validate output shape, deduplication, boundary handling, and request sequences.
Type annotation cleanup and resource repr refactoring
snipeit/client.py, snipeit/resources/*.py, tests/integration/conftest.py
Forward-reference strings removed (e.g., "SnipeIT"SnipeIT). typing.Dict replaced with built-in dict. Resource __repr__ methods refactored to intermediate variables for readability. Callable moved to collections.abc.
Exception handling and warning improvements
snipeit/resources/assets/manager.py, snipeit/resources/assets/files.py
get_by_tag() and get_by_serial() suppress chained exception context with from None. File-close warning enhanced with stacklevel=2 and dynamic file-name inclusion.
Test infrastructure and property-based coverage
tests/conftest.py, tests/unit/test_property_apiobject.py, tests/unit/test_property_asset_custom_fields.py, tests/unit/test_property_list_all.py, tests/unit/test_property_pure_functions.py
Fixtures disable retry backoff via no-op _sleep override. New property-based test suites validate ApiObject dirty-tracking invariants, custom-field staging, pagination correctness, and robustness of internal parsing/stringification functions.
Import organization and whitespace normalization
snipeit/resources/accessories.py, snipeit/resources/departments.py, snipeit/resources/fieldsets.py, snipeit/resources/locations.py, snipeit/resources/manufacturers.py, snipeit/resources/suppliers.py, tests/integration/resources/*, tests/unit/resources/*, tests/unit/test_*.py
Exception imports reordered alphabetically across test files. Blank lines added for PEP 8 compliance between import groups. Direct attribute access preferred over getattr() in assertions. No functional changes.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • lfctech/snipeit-python-api#8: Introduced Asset.audit() and Asset.restore() APIs, which are now enhanced in this PR with the new refresh keyword-only parameter for skipping follow-up GET requests.

Poem

A rabbit hops through version bumps and retry jitters,
Fresh refresh=False lets assets skip the getters,
Pagination pages faster, lists don't over-fetch,
Property tests catch every edge—a testing stretch! 🐰✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 68.42% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The PR title clearly and concisely summarizes the three main changes: retry jitter support, optional refresh parameter for asset actions, and performance improvements. It matches the substantive changes across the codebase.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch dev

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@Makefile`:
- Around line 29-31: The recipe comment for target mut-quick is misleading
because the mut-quick Makefile target currently runs the full mutmut command
just like mut; either update the comment or change the recipe for the mut-quick
target to run a scoped mutmut invocation (e.g., use mutmut run with path/arg to
limit to highest-value files or a predefined file list/glob), and keep the mut
target unchanged; locate the Makefile target named mut-quick and either adjust
its description text to match the current full run or modify the command to a
scoped mutmut run (using the mutmut --paths-to-mutate / specific-paths approach
or similar) so behavior and comment are aligned.

In `@pyrightconfig.json`:
- Around line 8-9: Re-enable the Pyright diagnostics by setting
reportIncompatibleMethodOverride and reportIncompatibleVariableOverride back to
true in pyrightconfig.json, then fix the incompatible override in the Asset
class: change Asset.save() signature/return type so it preserves the generic
type variable T from the base class (or make the base method covariant/adjust
generics) so the override matches the base class contract; ensure any calls
using Asset.save() still type-check after adjusting the signature to satisfy
Pyright's reportIncompatibleMethodOverride check.

In `@snipeit/resources/base.py`:
- Around line 346-362: The list_all method should validate pagination inputs up
front: reject page_size <= 0 and limit < 0 with clear ValueError messages so
invalid query params are not sent to self._get; add checks at the start of
list_all to raise for these cases (reference the list_all(...) signature and the
use of page_size/limit before calling self._get) and keep existing behavior for
None/positive values.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 645cf161-b507-4949-afed-605830dbcc2c

📥 Commits

Reviewing files that changed from the base of the PR and between 0d33af7 and ac51509.

⛔ Files ignored due to path filters (1)
  • uv.lock is excluded by !**/*.lock
📒 Files selected for processing (55)
  • CHANGELOG.md
  • Makefile
  • pyproject.toml
  • pyrightconfig.json
  • snipeit/_retry.py
  • snipeit/client.py
  • snipeit/resources/accessories.py
  • snipeit/resources/assets/files.py
  • snipeit/resources/assets/manager.py
  • snipeit/resources/assets/model.py
  • snipeit/resources/base.py
  • snipeit/resources/categories.py
  • snipeit/resources/companies.py
  • snipeit/resources/components.py
  • snipeit/resources/consumables.py
  • snipeit/resources/departments.py
  • snipeit/resources/fields.py
  • snipeit/resources/fieldsets.py
  • snipeit/resources/licenses.py
  • snipeit/resources/locations.py
  • snipeit/resources/manufacturers.py
  • snipeit/resources/models.py
  • snipeit/resources/status_labels.py
  • snipeit/resources/suppliers.py
  • snipeit/resources/users.py
  • tests/conftest.py
  • tests/integration/conftest.py
  • tests/integration/resources/test_accessories.py
  • tests/integration/resources/test_assets.py
  • tests/integration/resources/test_categories.py
  • tests/integration/resources/test_companies.py
  • tests/integration/resources/test_departments.py
  • tests/integration/resources/test_fields.py
  • tests/integration/resources/test_locations.py
  • tests/integration/resources/test_manufacturers.py
  • tests/integration/resources/test_models.py
  • tests/integration/resources/test_status_labels.py
  • tests/integration/resources/test_suppliers.py
  • tests/integration/resources/test_users.py
  • tests/unit/resources/test_assets.py
  • tests/unit/resources/test_assets_labels.py
  • tests/unit/resources/test_base.py
  • tests/unit/resources/test_pagination.py
  • tests/unit/resources/test_shape_validation.py
  • tests/unit/test_assets_endpoints.py
  • tests/unit/test_client_properties.py
  • tests/unit/test_exceptions.py
  • tests/unit/test_logging.py
  • tests/unit/test_property_apiobject.py
  • tests/unit/test_property_asset_custom_fields.py
  • tests/unit/test_property_list_all.py
  • tests/unit/test_property_pure_functions.py
  • tests/unit/test_repr.py
  • tests/unit/test_retries.py
  • tests/unit/test_streaming_download.py

Comment thread Makefile
Comment on lines 29 to +31
# Quick mutation run scoped to the highest-value source files (used in CI)
mut-quick:
$(PY) -m mutmut run \
--paths-to-mutate snipeit/client.py,snipeit/_retry.py,snipeit/resources/base.py \
--tests-dir tests/unit tests/contract || true
$(PY) -m mutmut run || true

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Update mut-quick description (or restore scoped behavior).

Line 29 says “scoped to highest-value source files”, but Line 31 runs the same full command as mut. Please align comment and behavior to avoid misleading usage.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Makefile` around lines 29 - 31, The recipe comment for target mut-quick is
misleading because the mut-quick Makefile target currently runs the full mutmut
command just like mut; either update the comment or change the recipe for the
mut-quick target to run a scoped mutmut invocation (e.g., use mutmut run with
path/arg to limit to highest-value files or a predefined file list/glob), and
keep the mut target unchanged; locate the Makefile target named mut-quick and
either adjust its description text to match the current full run or modify the
command to a scoped mutmut run (using the mutmut --paths-to-mutate /
specific-paths approach or similar) so behavior and comment are aligned.

Comment thread pyrightconfig.json
Comment on lines +8 to +9
"reportIncompatibleMethodOverride": false,
"reportIncompatibleVariableOverride": false,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Compare current pyright output vs. output with override checks enabled.
python - <<'PY'
import json, tempfile, subprocess, pathlib

cfg_path = pathlib.Path("pyrightconfig.json")
cfg = json.loads(cfg_path.read_text())

strict = dict(cfg)
strict["reportIncompatibleMethodOverride"] = True
strict["reportIncompatibleVariableOverride"] = True

tmp = tempfile.NamedTemporaryFile("w", suffix=".json", delete=False)
json.dump(strict, tmp, indent=2)
tmp.close()

print("=== Current config ===")
subprocess.run([".venv/bin/pyright", "-p", str(cfg_path)], check=False)
print("\n=== With override diagnostics enabled ===")
subprocess.run([".venv/bin/pyright", "-p", tmp.name], check=False)
PY

Repository: lfctech/snipeit-python-api

Length of output: 678


🏁 Script executed:

cat -n pyrightconfig.json

Repository: lfctech/snipeit-python-api

Length of output: 700


🏁 Script executed:

# Find all Python files in the snipeit directory
find snipeit -name "*.py" -type f | head -20

Repository: lfctech/snipeit-python-api

Length of output: 649


🏁 Script executed:

wc -l snipeit/resources/base.py && cat -n snipeit/resources/base.py

Repository: lfctech/snipeit-python-api

Length of output: 19642


🏁 Script executed:

# Search for classes that inherit from ApiObject or Manager
rg "class\s+\w+\((ApiObject|Manager|BaseResourceManager)" snipeit/

Repository: lfctech/snipeit-python-api

Length of output: 2378


🏁 Script executed:

# Search for __init__ method overrides in subclasses
rg "__init__" snipeit/resources/ | grep -v "base.py" | grep "def __init__"

Repository: lfctech/snipeit-python-api

Length of output: 52


🏁 Script executed:

# Check if any resource subclasses override common base class methods
rg "def (save|refresh|delete|_dirty_set|mark_dirty|_apply_server_data|_get|_create|_patch)" snipeit/resources/ | grep -v "base.py"

Repository: lfctech/snipeit-python-api

Length of output: 329


🏁 Script executed:

cat -n snipeit/resources/assets/model.py | head -100

Repository: lfctech/snipeit-python-api

Length of output: 4587


🏁 Script executed:

wc -l snipeit/resources/assets/model.py && tail -250 snipeit/resources/assets/model.py

Repository: lfctech/snipeit-python-api

Length of output: 11388


🏁 Script executed:

# Check Asset.save() signature more carefully and compare with base
sed -n '335,340p' snipeit/resources/assets/model.py
sed -n '244,246p' snipeit/resources/base.py

Repository: lfctech/snipeit-python-api

Length of output: 407


🏁 Script executed:

# Get exact line numbers for Asset.save() signature
sed -n '335,340p' snipeit/resources/assets/model.py

Repository: lfctech/snipeit-python-api

Length of output: 321


🏁 Script executed:

# Find the exact line where save() is defined in Asset
grep -n "def save" snipeit/resources/assets/model.py

Repository: lfctech/snipeit-python-api

Length of output: 103


🏁 Script executed:

# Compare Asset.save() signature with ApiObject.save() signature
sed -n '139,145p' snipeit/resources/assets/model.py
echo "---BASE CLASS---"
sed -n '244,250p' snipeit/resources/base.py

Repository: lfctech/snipeit-python-api

Length of output: 783


🏁 Script executed:

# Search for other method overrides that might have signature differences
find snipeit/resources -name "*.py" -exec grep -l "def save\|def _apply_server_data\|def _dirty_set\|def refresh" {} \;

Repository: lfctech/snipeit-python-api

Length of output: 130


🏁 Script executed:

# Check if there are any subclasses of Asset that might have their own overrides
grep -r "class.*Asset" snipeit/ | grep -v "test" | grep -v ".pyc"

Repository: lfctech/snipeit-python-api

Length of output: 364


🏁 Script executed:

# Check AssetsManager for any method overrides
cat -n snipeit/resources/assets/manager.py

Repository: lfctech/snipeit-python-api

Length of output: 5014


Re-enable override compatibility diagnostics.

Disabling reportIncompatibleMethodOverride and reportIncompatibleVariableOverride hides real type safety issues. The codebase contains at least one actual incompatible method override: Asset.save() returns Asset instead of preserving the type variable T from the base class signature, which violates Liskov Substitution Principle and would be caught by these diagnostics.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pyrightconfig.json` around lines 8 - 9, Re-enable the Pyright diagnostics by
setting reportIncompatibleMethodOverride and reportIncompatibleVariableOverride
back to true in pyrightconfig.json, then fix the incompatible override in the
Asset class: change Asset.save() signature/return type so it preserves the
generic type variable T from the base class (or make the base method
covariant/adjust generics) so the override matches the base class contract;
ensure any calls using Asset.save() still type-check after adjusting the
signature to satisfy Pyright's reportIncompatibleMethodOverride check.

Comment thread snipeit/resources/base.py
Comment on lines +346 to 362
def list_all(self, *, limit: int | None = None, page_size: int = 100, **params: Any) -> Iterable[T]:
if "offset" in params:
raise ValueError(
"Do not pass 'offset' as a filter param to list_all() — it controls "
"internal pagination and would break page iteration. "
"Use 'limit' to cap total results."
)
page = 1
yielded = 0
while True:
# When the caller passes a small ``limit``, never request more rows
# from the server than we still need. ``list_all(limit=5,
# page_size=500)`` would otherwise pull 500 rows just to use 5.
remaining = None if limit is None else max(0, limit - yielded)
if remaining == 0:
return
per_page = page_size if remaining is None else min(page_size, remaining)
resp = self._get(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Validate pagination inputs before issuing requests.

page_size <= 0 (and limit < 0) currently falls through and can emit invalid query params, making behavior server-dependent. Add explicit guards so invalid inputs fail fast.

Suggested fix
 def list_all(self, *, limit: int | None = None, page_size: int = 100, **params: Any) -> Iterable[T]:
+    if page_size <= 0:
+        raise ValueError("page_size must be > 0")
+    if limit is not None and limit < 0:
+        raise ValueError("limit must be >= 0")
     if "offset" in params:
         raise ValueError(
             "Do not pass 'offset' as a filter param to list_all() — it controls "
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def list_all(self, *, limit: int | None = None, page_size: int = 100, **params: Any) -> Iterable[T]:
if "offset" in params:
raise ValueError(
"Do not pass 'offset' as a filter param to list_all() — it controls "
"internal pagination and would break page iteration. "
"Use 'limit' to cap total results."
)
page = 1
yielded = 0
while True:
# When the caller passes a small ``limit``, never request more rows
# from the server than we still need. ``list_all(limit=5,
# page_size=500)`` would otherwise pull 500 rows just to use 5.
remaining = None if limit is None else max(0, limit - yielded)
if remaining == 0:
return
per_page = page_size if remaining is None else min(page_size, remaining)
resp = self._get(
def list_all(self, *, limit: int | None = None, page_size: int = 100, **params: Any) -> Iterable[T]:
if page_size <= 0:
raise ValueError("page_size must be > 0")
if limit is not None and limit < 0:
raise ValueError("limit must be >= 0")
if "offset" in params:
raise ValueError(
"Do not pass 'offset' as a filter param to list_all() — it controls "
"internal pagination and would break page iteration. "
"Use 'limit' to cap total results."
)
yielded = 0
while True:
# When the caller passes a small ``limit``, never request more rows
# from the server than we still need. ``list_all(limit=5,
# page_size=500)`` would otherwise pull 500 rows just to use 5.
remaining = None if limit is None else max(0, limit - yielded)
if remaining == 0:
return
per_page = page_size if remaining is None else min(page_size, remaining)
resp = self._get(
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@snipeit/resources/base.py` around lines 346 - 362, The list_all method should
validate pagination inputs up front: reject page_size <= 0 and limit < 0 with
clear ValueError messages so invalid query params are not sent to self._get; add
checks at the start of list_all to raise for these cases (reference the
list_all(...) signature and the use of page_size/limit before calling self._get)
and keep existing behavior for None/positive values.

- what: patch multiprocessing.set_start_method to tolerate redundant
  calls in tests/conftest.py
- why: mutmut 3.5.0 calls set_start_method('fork') at module level in
  __main__.py; on Python 3.14 the context is already set when the
  trampoline triggers the import, causing RuntimeError
- risk: none — the patch only suppresses the 'already set' error
@Wil-Collier Wil-Collier merged commit 6ecbe05 into main May 18, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant