From 1c137a6741851214e95569cc37588cd3a56273d7 Mon Sep 17 00:00:00 2001 From: petruki <31597636+petruki@users.noreply.github.com> Date: Sun, 23 Aug 2026 15:31:19 -0700 Subject: [PATCH 1/2] chore(tests): bump pylint@4.0.7, python-dotenv@1.2.3 --- .github/workflows/master.yml | 6 +- .github/workflows/release-test.yml | 2 +- .github/workflows/release.yml | 4 +- .github/workflows/sonar.yml | 4 +- .github/workflows/staging.yml | 2 +- ARCHITECTURE.md | 323 +++++++++++++++++++++++++++ Pipfile | 4 +- Pipfile.lock | 291 +++++++++++++----------- tests/test_client_check_switchers.py | 6 +- tests/test_client_context.py | 4 +- tests/test_switcher_integration.py | 2 +- tests/test_switcher_remote.py | 2 +- 12 files changed, 503 insertions(+), 147 deletions(-) create mode 100644 ARCHITECTURE.md diff --git a/.github/workflows/master.yml b/.github/workflows/master.yml index 151115f..ea3afc1 100644 --- a/.github/workflows/master.yml +++ b/.github/workflows/master.yml @@ -18,7 +18,7 @@ jobs: fetch-depth: 0 - name: Set up Python 3.14 - uses: actions/setup-python@v6 + uses: actions/setup-python@v7 with: python-version: "3.14" @@ -34,7 +34,7 @@ jobs: run: pipenv run pytest -v --cov=./switcher_client --cov-report xml --cov-config=.coveragerc - name: SonarCloud Scan - uses: sonarsource/sonarqube-scan-action@v8.2.0 + uses: sonarsource/sonarqube-scan-action@v8.2.1 env: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} if: env.SONAR_TOKEN != '' @@ -53,7 +53,7 @@ jobs: uses: actions/checkout@v7 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v6 + uses: actions/setup-python@v7 with: python-version: ${{ matrix.python-version }} diff --git a/.github/workflows/release-test.yml b/.github/workflows/release-test.yml index 569027a..5a5cf51 100644 --- a/.github/workflows/release-test.yml +++ b/.github/workflows/release-test.yml @@ -21,7 +21,7 @@ jobs: steps: - uses: actions/checkout@v7 - name: Set up Python - uses: actions/setup-python@v6 + uses: actions/setup-python@v7 with: python-version: "3.14" - name: Install pypa/build diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f9fa76f..f536501 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,7 +19,7 @@ jobs: uses: actions/checkout@v7 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v6 + uses: actions/setup-python@v7 with: python-version: ${{ matrix.python-version }} @@ -39,7 +39,7 @@ jobs: steps: - uses: actions/checkout@v7 - name: Set up Python - uses: actions/setup-python@v6 + uses: actions/setup-python@v7 with: python-version: "3.14" - name: Install pypa/build diff --git a/.github/workflows/sonar.yml b/.github/workflows/sonar.yml index 17b9c31..d7ad7fe 100644 --- a/.github/workflows/sonar.yml +++ b/.github/workflows/sonar.yml @@ -34,7 +34,7 @@ jobs: fetch-depth: 0 - name: Set up Python 3.14 - uses: actions/setup-python@v6 + uses: actions/setup-python@v7 with: python-version: "3.14" @@ -47,7 +47,7 @@ jobs: run: pipenv run pytest -v --cov=./switcher_client --cov-report xml - name: SonarCloud Scan - uses: sonarsource/sonarqube-scan-action@v8.2.0 + uses: sonarsource/sonarqube-scan-action@v8.2.1 env: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} if: env.SONAR_TOKEN != '' diff --git a/.github/workflows/staging.yml b/.github/workflows/staging.yml index 5f2da7f..37fecec 100644 --- a/.github/workflows/staging.yml +++ b/.github/workflows/staging.yml @@ -29,7 +29,7 @@ jobs: fetch-depth: 0 - name: Set up Python - uses: actions/setup-python@v6 + uses: actions/setup-python@v7 with: python-version: ${{ github.event.inputs.python }} diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..925533f --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,323 @@ +# Architecture + +This document describes the design of the **Switcher Client SDK for Python** (`switcher_client`), from the public +API surface down to internal modules and execution flows. It complements [README.md](./README.md), which focuses on +usage, with the *why* and *how* behind the implementation. + +## Table of Contents + +- [Design Goals](#design-goals) +- [API-First Overview](#api-first-overview) +- [High-Level Architecture](#high-level-architecture) +- [Package Layout](#package-layout) +- [Core Components](#core-components) + - [Client (Facade)](#client-facade) + - [Context & Options](#context--options) + - [Switcher / SwitcherData (Builder)](#switcher--switcherdata-builder) + - [Resolver (Local Strategy Engine)](#resolver-local-strategy-engine) + - [Remote & RemoteAuth (Remote Strategy Engine)](#remote--remoteauth-remote-strategy-engine) + - [Bypasser (Mocking)](#bypasser-mocking) + - [Snapshot Management](#snapshot-management) + - [Execution Logger (Throttle Cache)](#execution-logger-throttle-cache) + - [TimedMatch (ReDoS Protection)](#timedmatch-redos-protection) + - [Errors](#errors) + - [Testing Utilities](#testing-utilities) +- [Execution Flow](#execution-flow) +- [Software Patterns Used](#software-patterns-used) +- [Concurrency Model](#concurrency-model) +- [Extensibility Points](#extensibility-points) + +--- + +## Design Goals + +- **API-first**: a small, fluent, discoverable surface (`Client`, `Switcher`) hides internal complexity. +- **Local-first performance**: snapshot-based evaluation for zero-latency, offline-capable feature flags. +- **Resilience**: silent mode / circuit breaker automatically falls back to local evaluation when the remote + API is unreachable. +- **Security by default**: regex (ReDoS) protection isolated in worker processes with timeouts and blacklisting. +- **Testability**: built-in mocking (`Bypasser`) that is safe under concurrent test execution via `ContextVar`. +- **Stateless module boundaries with static/class-level state**: most collaborators are static-method classes + (namespaces) holding process-wide state, avoiding the need to thread dependencies through call sites. + +## API-First Overview + +The SDK is designed around the workflow described in the README: + +1. `Client.build_context(...)` — configure domain, credentials, environment and `ContextOptions`. +2. `Client.get_switcher([key])` — obtain a `Switcher`, optionally cached/persisted by key. +3. `switcher.check_value(...).check_network(...)....is_on(key)` / `is_on_with_details(key)` — fluently attach + strategy inputs and evaluate a criteria (locally or remotely). +4. Supporting capabilities are layered on top of this core loop: snapshot management, throttling, silent mode, + hybrid (`remote()`) execution, mocking (`Client.assume/forget`, `switcher_test`/`assume_test`), and + configuration validation (`Client.check_switchers`). + +This mirrors a **Facade** (`Client`) in front of a **Fluent Builder** (`Switcher`/`SwitcherData`), keeping the +public API small while the internal library (`switcher_client.lib`) implements the heavier logic. + +## High-Level Architecture + +``` + ┌───────────────────────────┐ + │ Client (Facade) │ + │ build_context / get_switcher + │ load_snapshot / assume │ + └──────────────┬──────────────┘ + │ creates / configures + ▼ + ┌───────────────────────────┐ + │ Switcher (SwitcherData) │ + │ fluent criteria builder │ + └──────────────┬──────────────┘ + │ is_on() / is_on_with_details() + ┌────────────────────┼─────────────────────┐ + ▼ ▼ ▼ + ┌─────────────┐ ┌───────────────┐ ┌────────────────┐ + │ Bypasser │ │ ExecutionLogger│ │ _submit() │ + │ (mocks/tests)│ │ (throttle cache)│ │ local vs remote │ + └─────────────┘ └───────────────┘ └───┬────────┬────┘ + ▼ ▼ + ┌───────────┐ ┌──────────┐ + │ Resolver │ │ Remote │ + │ (local eval)│ │ (HTTP) │ + └─────┬─────┘ └────┬─────┘ + │ │ + ▼ ▼ + ┌───────────────┐ ┌───────────┐ + │ GlobalSnapshot │ │ RemoteAuth│ + │ (snapshot state)│ │ (token) │ + └───────────────┘ └───────────┘ +``` + +Cross-cutting infrastructure: `SnapshotAutoUpdater` and `SnapshotWatcher` (background threads keeping the +snapshot fresh), and `TimedMatch` (isolated-process regex evaluation used by the `Resolver`). + +## Package Layout + +``` +switcher_client/ +├── __init__.py # Public API surface (re-exports) +├── client.py # Client facade (entry point) +├── switcher.py # Switcher: execution/orchestration logic +├── switcher_data.py # SwitcherData: fluent input builder (base class of Switcher) +├── testing.py # assume_test / switcher_test decorator utilities +├── version.py +├── errors/ # Exception hierarchy +└── lib/ # Internal implementation details + ├── remote.py # HTTP client wrapper (httpx) for Switcher-API + ├── remote_auth.py # Token lifecycle, auto-renew, silent-mode token + ├── resolver.py # Local snapshot-based criteria evaluation + ├── snapshot.py # StrategiesType/OperationsType + operation processors + ├── snapshot_loader.py # Load/save/validate snapshot files, check_switchers (local) + ├── snapshot_auto_updater.py # Background thread: periodic snapshot refresh + ├── snapshot_watcher.py # Background thread: file-change watcher + ├── types.py # Domain model (Snapshot, Domain, Group, Config, StrategyConfig, ResultDetail) + ├── compat.py # typing.Self polyfill for older Python versions + ├── bypasser/ # Mocking engine (Bypasser, Key) + ├── globals/ # Process-wide state (Context, GlobalAuth, GlobalSnapshot, RetryOptions) + └── utils/ # execution_logger, ipcidr, payload_reader, date_moment, timed_match/ +``` + +## Core Components + +### Client (Facade) + +`switcher_client/client.py` + +- Static-only class (namespace) that is the single entry point for consumers. +- Owns process-wide singletons: `_context` (`Context`), `_switcher` (cache of named `Switcher` instances), + `_snapshot_auto_updater`, `_snapshot_watcher`, `_test_mode`. +- `build_context()` resets/initializes all collaborators (`Bypasser`, `GlobalSnapshot`, `RemoteAuth`) and applies + `ContextOptions` via a **handler-table dispatch** (`_build_options`) rather than nested conditionals — new + options are added by registering a handler, not by modifying branching logic. +- Delegates snapshot lifecycle (`load_snapshot`, `check_snapshot`, `schedule_snapshot_auto_update`, + `watch_snapshot`), mocking (`assume`/`forget`), execution introspection (`get_execution`, `clear_logger`), and + validation (`check_switchers`) to the appropriate `lib` module. + +### Context & Options + +`switcher_client/lib/globals/global_context.py` + +- `Context` is a plain data holder for connection info (`domain`, `url`, `api_key`, `component`, `environment`) + plus a `ContextOptions` instance — passed explicitly to `Switcher`/`SwitcherData` rather than accessed as a + global, which keeps evaluation logic testable and free of hidden global reads. +- `ContextOptions` centralizes all tunables (local mode, logger, freeze, snapshot location/interval, silent + mode, throttle workers, regex limits, `restrict_relay`) with documented defaults. +- `RemoteOptions` isolates HTTP transport tuning (timeouts, connection pool limits, cert path, auto-renew) so + `Remote`'s `httpx.Client` can be rebuilt only when its configuration actually changes. + +### Switcher / SwitcherData (Builder) + +`switcher_client/switcher.py`, `switcher_client/switcher_data.py` + +- `SwitcherData` is an abstract base implementing the **fluent builder** pattern: `check_value`, + `check_network`, `check_regex`, `check_payload`, `throttle`, `remote`, `default_result`, `restrict_relay` all + return `Self`, enabling method chaining (`switcher.check_value(...).check_network(...).is_on(key)`). +- `Switcher` extends `SwitcherData` and adds **execution behavior**: `is_on`, `is_on_with_details`, `prepare`, + `validate`, and the internal `_submit` dispatcher. +- `_submit()` is the routing decision point: bypass (mocks) → cache (throttle) → local snapshot vs. remote API + vs. silent-mode fallback, wrapping remote failures with `default_result`/`silent_mode` recovery. +- Each `Switcher` owns a dedicated `ThreadPoolExecutor` (`_background_executor`) for non-blocking cache + refreshes when throttling is enabled (see [Concurrency Model](#concurrency-model)). + +### Resolver (Local Strategy Engine) + +`switcher_client/lib/resolver.py`, `switcher_client/lib/snapshot.py` + +- Pure, static, side-effect-free evaluator that walks the snapshot tree: `Domain → Group → Config → + StrategyConfig`, short-circuiting on the first disabling condition (domain/group/config disabled, relay + restriction, unmet strategy). +- Strategy evaluation is delegated to `snapshot.process_operation`, which **dispatches by `StrategiesType`** + (`VALUE`, `NUMERIC`, `DATE`, `TIME`, `PAYLOAD`, `NETWORK`, `REGEX`) using Python's structural `match` + statement — a clean **Strategy pattern** without a class-per-strategy, since each strategy is a small pure + function operating on `(operation, values, input_value)`. +- `OperationsType` enumerates supported operations per strategy (`EXIST`, `EQUAL`, `BETWEEN`, `HAS_ONE`, etc.). + +### Remote & RemoteAuth (Remote Strategy Engine) + +`switcher_client/lib/remote.py`, `switcher_client/lib/remote_auth.py` + +- `Remote` wraps `httpx.Client` for all Switcher-API HTTP calls (`auth`, `check_api_health`, `check_criteria`, + `check_switchers`, `check_snapshot_version`, `resolve_snapshot` via GraphQL). It **lazily rebuilds** the + client only when `RemoteOptions` change (`_get_client_config` fingerprinting), reusing keep-alive connections + otherwise. +- Network errors are normalized into `RemoteError` subclasses so callers never see raw `httpx` exceptions. +- `RemoteAuth` owns the authentication token lifecycle: acquiring tokens, detecting expiration + (`is_token_expired`), and — when `silent_mode` is configured — setting a sentinel `'SILENT'` token with a + computed expiration so the system knows to evaluate locally until connectivity is restored + (`check_health`/`update_silent_token`). +- Optional **auto-renew** (`RemoteOptions.auto_renew_token`) schedules a `threading.Timer` to refresh the token + shortly before expiry; a monotonically increasing *generation counter* guards against races between + overlapping timers/threads (stale generations are dropped). + +### Bypasser (Mocking) + +`switcher_client/lib/bypasser/bypasser.py`, `key.py` + +- `Bypasser` stores forced ("assumed") switcher values in a `contextvars.ContextVar`, not a plain dict — this + scopes mocks to the current execution context (thread/async task), preventing leakage across concurrently + running tests in the same process. +- `Key` is a small fluent builder (`true()/false()/when()/with_metadata()`) producing a `ResultDetail` on + `get_response`, optionally varying the result based on `when(strategy, input)` conditions matched against the + actual inputs passed to `is_on`. +- Checked first in `Switcher.is_on`/`is_on_with_details`, short-circuiting both cache and network/local + evaluation — mocks always win. + +### Snapshot Management + +`switcher_client/lib/globals/global_snapshot.py`, `snapshot_loader.py`, `snapshot_auto_updater.py`, +`snapshot_watcher.py` + +- `GlobalSnapshot` is process-wide storage for the currently loaded `Snapshot` (parsed domain tree). +- `snapshot_loader.py` handles I/O: reading/writing `{environment}.json` snapshot files, validating a snapshot + against the remote version (`validate_snapshot`), and local `check_switchers` validation. +- `SnapshotAutoUpdater` runs a daemon thread that periodically calls back into `Client.check_snapshot`, + invoking a user callback with `(error, updated)`. +- `SnapshotWatcher` runs a daemon thread polling the snapshot file's mtime (`_POLL_INTERVAL = 1s`) and reloads + `GlobalSnapshot` on change, notifying a `WatchSnapshotCallback` (`success`/`reject`). +- Both background services are disabled/guarded in `Client.test_mode()` to avoid file-locking issues in test + suites. + +### Execution Logger (Throttle Cache) + +`switcher_client/lib/utils/execution_logger.py` + +- In-memory list-based cache keyed by `(key, inputs)`, used to serve `throttle()`-enabled switchers without + hitting local/remote evaluation on every call. +- Also the mechanism behind `Client.get_execution(switcher)` (introspection) and asynchronous error + notification (`subscribe_notify_error`) surfaced when background refreshes or silent-mode fallbacks fail. +- **Design strategy: Stale-While-Revalidate (SWR)**. `throttle(period)` does not block callers waiting for a + fresh evaluation; instead `Switcher._try_cached_result()` immediately returns the last cached `ResultDetail` + ("stale") from `ExecutionLogger`, and — unless `freeze` is enabled — kicks off `schedule_background_refresh()` + ("revalidate") on the switcher's own `ThreadPoolExecutor`. The refresh runs `_submit()` asynchronously and + overwrites the cache entry (`ExecutionLogger.add`) once it completes, so the *next* call after `period` + milliseconds observes the updated value. `_next_refresh_time` acts as the revalidation gate, ensuring only + one background refresh is in flight per throttle period. This trades strict consistency for consistently + low, predictable latency — the same trade-off popularized by HTTP's `stale-while-revalidate` `Cache-Control` + directive (RFC 5861). + +### TimedMatch (ReDoS Protection) + +`switcher_client/lib/utils/timed_match/` + +- Implements the SDK's **security feature**: regex evaluation is delegated to a persistent worker subprocess + (`multiprocessing`, `spawn` context) so a catastrophic/pathological regex cannot block or crash the main + process — it is simply terminated and replaced after `regex_max_time_limit` elapses. +- Maintains a bounded **blacklist** (`regex_max_black_list`) of previously-timed-out `(patterns, input)` + combinations to short-circuit repeat attempts without re-invoking the worker. +- `worker.py` defines the message protocol (`WorkerTask`/`WorkerResult`, `TaskType.MATCH`/`SHUTDOWN`) exchanged + over `multiprocessing.Queue`s with the isolated worker loop (`persistent_regex_worker`). + +### Errors + +`switcher_client/errors/__init__.py` + +- Flat, purpose-specific exception hierarchy rooted at `RemoteError` (remote-only) plus standalone + `LocalSwitcherError`, `LocalCriteriaError`, `SnapshotNotFoundError`, `TestModeError` — each mapped to a + specific failure mode (auth, criteria evaluation, switcher-not-found, missing snapshot, disallowed test-mode + operation), enabling callers to `except` precisely. + +### Testing Utilities + +`switcher_client/testing.py` + +- `assume_test(key)` builds a `TestAssumption` (deep-clonable fluent builder mirroring `Key`'s API). +- `@switcher_test(*assumptions)` is a decorator (supporting both sync and `async def` tests) that applies + assumptions via `Client.assume` before the test and guarantees `Client.forget` cleanup in a `finally` block, + even on test failure — built on top of `Bypasser`'s context-scoped mocking for safe concurrent test runs. + +## Execution Flow + +`switcher.is_on(key)` / `is_on_with_details(key)`: + +1. **Validate args** — update `_key`/`_show_details` if provided. +2. **Bypasser check** — if a mock was registered via `Client.assume(key)`, return its (possibly `when`-derived) + result immediately. +3. **Cache check** — if throttling is enabled and a cached `ExecutionLogger` entry exists, return it (and + opportunistically schedule a background refresh unless `freeze` is set). +4. **`_submit()`**: + - If `local` is enabled and not forced remote → `_execute_local_criteria()` (via `Resolver` + + `GlobalSnapshot`). + - Else → `validate()` (checks auth/token/API health), then: + - If the auth token is the `'SILENT'` sentinel → evaluate locally instead. + - Otherwise → `_execute_remote_criteria()` (via `Remote.check_criteria`). + - On any exception during remote evaluation: notify subscribed error callback; if `silent_mode` is + configured, switch the token to `'SILENT'` and retry locally; otherwise propagate (unless + `default_result` was set, in which case that value is returned instead of raising). + +## Software Patterns Used + +| Pattern | Where | Purpose | +|---|---|---| +| **Facade** | `Client` | Single simplified entry point over many internal subsystems | +| **Fluent Builder** | `SwitcherData`/`Switcher`, `Key`, `TestAssumption` | Chainable, readable criteria/mock construction | +| **Strategy** | `snapshot.process_operation` dispatch by `StrategiesType` | Pluggable evaluation logic per strategy type | +| **Static namespace / Singleton-like state** | `Client`, `GlobalAuth`, `GlobalSnapshot`, `RemoteAuth`, `Bypasser`, `ExecutionLogger`, `TimedMatch` | Process-wide shared state without DI plumbing | +| **Circuit Breaker** | `RemoteAuth` silent mode + `Switcher._submit` | Fail fast to local evaluation when remote API is unavailable | +| **Stale-While-Revalidate** | `Switcher.throttle`/`_try_cached_result`/`schedule_background_refresh` + `ExecutionLogger` | Serve cached result instantly while refreshing it asynchronously | +| **Decorator** | `testing.switcher_test` | Test lifecycle wrapping (setup mocks / guaranteed cleanup) | +| **Context Object** | `Context`/`ContextOptions`/`RemoteOptions` | Explicit dependency passed to evaluators instead of hidden globals | +| **Producer/Consumer (worker process)** | `TimedMatch`/`worker.py` | Isolate untrusted regex execution with timeout + blacklist | +| **Data Transfer Object** | `types.py` (`Snapshot`, `Domain`, `Group`, `Config`, `StrategyConfig`, `ResultDetail`) | Typed, parsed representation of the snapshot JSON contract | + +## Concurrency Model + +- **Thread-based**: `SnapshotAutoUpdater` and `SnapshotWatcher` each run a single daemon `threading.Thread`; + `Switcher` uses a per-instance `ThreadPoolExecutor` for background throttle refreshes; `RemoteAuth` uses + `threading.Timer` for token auto-renewal, guarded by a lock and generation counter to avoid stale timers + overwriting a newer token. +- **Process-based**: `TimedMatch` isolates regex execution in a separate `multiprocessing` worker to guarantee + that a hung/malicious regex can be killed without affecting the host process. +- **Context-local state**: `Bypasser` uses `contextvars.ContextVar` so mocked values don't leak across + concurrently executing async tasks/threads sharing the same process — critical for parallel test execution. + +## Extensibility Points + +- **New strategy type**: add an entry to `StrategiesType`, a `_process_` function in `snapshot.py`, and a + `case` in `process_operation`; add a corresponding `check_` fluent method on `SwitcherData` if it should + be user-facing. +- **New context option**: add a field to `ContextOptions` (and default constant), then register a handler in + `Client._build_options` if it requires side effects on `build_context`. +- **New remote operation**: add a method to `Remote` following the existing `_do_get`/`_do_post` + typed + `error_cls` convention. +- **New error type**: add to `switcher_client/errors/__init__.py` and export it in `__all__`. diff --git a/Pipfile b/Pipfile index 83d03de..b755021 100644 --- a/Pipfile +++ b/Pipfile @@ -10,9 +10,9 @@ typing-extensions = {version = "==4.16.0", markers = "python_version <= '3.12'"} [dev-packages] exceptiongroup = "*" tomli = "*" -pylint = "==4.0.6" +pylint = "==4.0.7" pytest = "==9.1.1" pytest-cov = "==7.1.0" pytest-httpx = "==0.36.2" -python-dotenv = "==1.2.2" +python-dotenv = "==1.2.3" switcher-client = {file = ".", editable = true} diff --git a/Pipfile.lock b/Pipfile.lock index 8038e4a..ff1ced9 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "88c56c654416a49176c6a1b89b77cce1f0ef422e9e60e3338eff20e809d27492" + "sha256": "9cdaa77e655cb56b04a2af8843574b41f7f8e60baff28c5f0fb02f4ff0c0bb99" }, "pipfile-spec": 6, "requires": {}, @@ -16,19 +16,19 @@ "default": { "anyio": { "hashes": [ - "sha256:4e5533c5b8ff0a24f5d7a176cbe6877129cd183893f66b537f8f227d10527d72", - "sha256:8d648a3544c1a700e3ff78615cd679e4c5c3f149904287e73687b2596963629e" + "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", + "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f" ], "markers": "python_version >= '3.10'", - "version": "==4.14.1" + "version": "==4.14.2" }, "certifi": { "hashes": [ - "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", - "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db" + "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", + "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55" ], "markers": "python_version >= '3.7'", - "version": "==2026.6.17" + "version": "==2026.7.22" }, "h11": { "hashes": [ @@ -40,11 +40,11 @@ }, "h2": { "hashes": [ - "sha256:6c59efe4323fa18b47a632221a1888bd7fde6249819beda254aeca909f221bf1", - "sha256:c438f029a25f7945c69e0ccf0fb951dc3f73a5f6412981daee861431b70e2bdd" + "sha256:0e25f1462b23c9cb82d9eb02e28bc706dac2a68cb457c6a0d74d63c8a2a5d0e6", + "sha256:4e866ffb1a869ae14dd9b5e6beb5c24a13da0495ad72b65925ded182521c1516" ], - "markers": "python_version >= '3.9'", - "version": "==4.3.0" + "markers": "python_version >= '3.10'", + "version": "==4.4.1" }, "hpack": { "hashes": [ @@ -101,11 +101,11 @@ "develop": { "anyio": { "hashes": [ - "sha256:4e5533c5b8ff0a24f5d7a176cbe6877129cd183893f66b537f8f227d10527d72", - "sha256:8d648a3544c1a700e3ff78615cd679e4c5c3f149904287e73687b2596963629e" + "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", + "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f" ], "markers": "python_version >= '3.10'", - "version": "==4.14.1" + "version": "==4.14.2" }, "astroid": { "hashes": [ @@ -117,11 +117,11 @@ }, "certifi": { "hashes": [ - "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", - "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db" + "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", + "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55" ], "markers": "python_version >= '3.7'", - "version": "==2026.6.17" + "version": "==2026.7.22" }, "colorama": { "hashes": [ @@ -136,100 +136,130 @@ "toml" ], "hashes": [ - "sha256:003fff99412ea848c0aaebcc78ed2b6ce7d8a1227ed17e68470672770b78a02a", - "sha256:02adc79a920c73c647c5d117f55747df7f2de94571884758ce8bc58e04f0a796", - "sha256:03d1f922757662eb7af586e77834792274cff776bc7b1d1a0b66a49ea9d84735", - "sha256:08fb028000ed0aaa0a4cbdfbb98be7cb42f370db973fbbb469733505ab20e13e", - "sha256:0bf781da64326b677be344df505171435b6f58716108606621d5d27d964fff8b", - "sha256:0bfc0be1f702042207a93a00523b1065ee1fe951e96edf311581c0bbc2e34888", - "sha256:0e4950c9d6d3e39c64c991814ff315e2d0b9cb8152363594212c9e55208c0a8f", - "sha256:106781b8482749162d0b47056937ba0933508e5d9447f65a5e7d5c422f0d6bb4", - "sha256:110cbdf8d2e216577312cf06ccf85539c0e5a5420ef747e4a4719b5e483c88cd", - "sha256:13fb759be317fdc62e0f56bffdf61cfcb45c7761ad6b71e3e583e71a67ae753c", - "sha256:1ac2c4c27c7df851dc9a017c2d7de00b69147e84ba3d96f37a530b0b6fb51035", - "sha256:20c8a976c365c8cb12f0cbd099508772ea41fb5fa80657a8506df0e11bd278c5", - "sha256:20d9ccc4ebd0edc434d86dfd2a1dd2a8efa6b6b3073d0485a394fee86459ebb4", - "sha256:276646e9481703d09f854f3b2f018f24e19fd7049ae670a92570043eb97203b1", - "sha256:27f31ecb458da3f859aab3f15ada871eb7a7768807d88df4a9f186bb17737970", - "sha256:2bcf9afaf064172c6ec3c58a325a9957ad1178c05dd934e25f253321776e0676", - "sha256:2c57a275078ee3fa185f83e400f765bc764a549de66d99b47881645cbd4ea629", - "sha256:2c5d4619214f1d9993e7b00a8600d14614b7e9d84e89507460b126aa5e6559e5", - "sha256:2c6f0fa473003905c6d5bac328ee4eba9fbea654f15bc24b8a3274b23363fa99", - "sha256:309990eb5fb8014b9f67cb211f7fd41876ec8a88a88d3ae76de0ed1d611e3640", - "sha256:31e5c3e70c85307ea35a12964e2e40f56ca2ee4b1c8c721ccf4609d17071080b", - "sha256:3200b6204935f928c64b2ca1f923ab8c1acb7c9de45ec61569711b34d25cccaf", - "sha256:346771144d34f7fa84ec28386f78e0f31653f33cf35e19d253d5b35f9e8201da", - "sha256:359e141ccd33893ce3f1ad5525f8b96083003677c82182e5907d62d4ea5799fc", - "sha256:363ab38cc78b615f11c9cac3cf1d7eef950c18b9fdedfb9066f59461dcf84d68", - "sha256:3812c61afc6685c7999b39320779ab8f43b7a3081fdb0def39976e56fbdb9a21", - "sha256:3bb3040e9f4bbe26fcb0cd7cc85ac63e630d3f3a9c74f027abf4caa27e706663", - "sha256:41cb79af843222e11da87127ad0ecbfa878abadd0f770a4a99391a27d3887324", - "sha256:4de4b4d3f5545aa6c60dc4efd9c63b5b5dcc3bf00fe83146b2bdfffb8f6613bd", - "sha256:50913d4bf5ddafa6ca3693da5e4dd833dd1b772e0283c99ca7f7d287db67331a", - "sha256:51aa20f6ae2788fd197747766edf4cd8234fd9423309b934257fa6b21a592723", - "sha256:52f9a4d2c4c56c8848bc2f524916698354b0211488b38c49ad9ae54f6cafbff6", - "sha256:54fd9c53a5fafff509195f1b6a3f9be615d8e8362a3629ff1de23d270c03c86b", - "sha256:56da6a4cbe8f7e9e80bd072ca9cefe67d7106a440a7ec06519ec6507ac94ad19", - "sha256:589b54513e901739f4b4582c705ce96b80c96f57641b1464607e2367a270e540", - "sha256:5be4caf3b28836f078abe700f8944dac4a65d78f16d6c600c89cb624e5535782", - "sha256:5c504097b2a89b1e85bc6070d920df77daec701337e3aeef2c17775a5dd0ca90", - "sha256:5cbd804bf2784ce7b45114516050f346ecd50f960c4bb630a7ee9e1d78fa2118", - "sha256:5f764a3fa339bde6b3aa97657f5a6a3a9451e4a5b4ea98a2892c773a43525f77", - "sha256:65a6b6164ee5c39e2f3803f314292d6c61a607ba7fee253d1e03c42dc3903502", - "sha256:6c664c5444b1d970b1b2a450e21fb19ee5c9cfdf151ded2dda37260031cca0da", - "sha256:6cb3602054ccbe9f0d8c2dc04bbeba90d5719236e2cd06e042ddd6d3fc7b6e37", - "sha256:6eb7c300fbed667fd6e3588eba71c1904cdb06110ca6fdf908c26bdd88b8e382", - "sha256:75128817f95a5c45bb01d65fd2d8b9cb54bbe03d81608fb70e3e14b437ad56c2", - "sha256:769e8ece11a596315ebf5aa7ec383aeeed016c091d2bf6363ffb996d41529092", - "sha256:781a704516e2d8346fbbd5be6c6f3412dd824785146528b3a01816f26c081007", - "sha256:7d2008989ef8fe54188d3f3bfa2e3099b025af11e90a6a1b9e7dc433d04263d8", - "sha256:821e92b3631d762a339695824cadbbc73020354eba2a23a551a99ad34938fbe6", - "sha256:840481b12e083dbcbafab14794a8781a958edf327c8d3d70b4eee42f9b8253aa", - "sha256:8773e15c23305b58882a4611fb9b2755977eae0dc2e515366a1b6c98866cc4c2", - "sha256:87b47553097ba185ed964866078e7e63adea9f5f51b5f39691c34f30afd21080", - "sha256:884499f42e382675be80770391983b90e0c0c774d87dbeeebf5f991cf6612b20", - "sha256:94c9686bfe8a9a6810297aecbd99beaa3445f9e8dc2f80b1382cca0d86b64461", - "sha256:9887bb428fe2d4cd4bee89bac1a6c9932f484afd5b36fbd4ff6ea5f825bb1f5e", - "sha256:9ac3fe7a1435986463eaa8ee253ae2f2a268709ba4ae5c7dd1f52a05391ad78f", - "sha256:9bd671c25f9d85f09d7ec481d0e43d5139f486c06a37139847a7ce569788af72", - "sha256:a263060f1de0b4b74b4e089c2a70b8003b3781c733329a9c8fd54995328f9950", - "sha256:a6d6acc9a7666245e6133dd15144ca038a85a9cd5026bb06d6bbae9e77440dc9", - "sha256:ab282853ed5fbd64bbb162f19cb8fcb7087187508a6374b4f9c34ec1577c4e8f", - "sha256:aeefb2dd178fe7eee79f0ad25d75855cb35ee9ed472db2c5ea06f5b4fd00cec5", - "sha256:b3b3e22030f3f6f5e01a5ce69936552a5c0f6992b7698777377b99041961031f", - "sha256:b5fb23fa2de9dce1f5c36c09066d8fcda16cd96e8e26686caa2d7cb9b567d65c", - "sha256:b713aa7fcf325a01d4184d848acb46fd84f78fdb0978470c636b23a06a753d91", - "sha256:b75ee5e8cb7575636ac598719b4307ac529ec8fcd79608a35c3cd4d4dada812d", - "sha256:b761a1d504fd4bd1f20f418753964dca9f5862a511fc854dac58296b3b223671", - "sha256:baf06bc987115d6fb938d403f7eab684a057766c490367999a2b71a6883110c6", - "sha256:bb25d825d885ca8036795dacfc3924d33091fc76d71ebc99420c6b79e77d96fa", - "sha256:bbcbb317c2e5ded5b21104af81c29f391be2af98d065693ffbe8d23949b948e5", - "sha256:bd4a1b44bcb65ee29e947ac92bbee04956df3a6bfc6143641bb6cae7ede00fc9", - "sha256:be616bf61346883b2cfdc5178669647e03531d81ab761a7e378558b7e8bcb628", - "sha256:c48decf16e0dfd5b049c7d5e82200c23c08126719142998d4f172444e3d0529e", - "sha256:cc78d9843bd576fbe2118248258d485e968dc535f95ed504a7b0867ba9b51389", - "sha256:cc7bafc3fe1059463a8fdd97ca79972d6e2bf819d775c7d54991b5b1971201d6", - "sha256:cec79341dbe6281484024979976d0c7f22beae08b4a254655decd25d42cbe766", - "sha256:d34a010905fb6401324ba016b5da03d574967f7b21ce48ea41e66f0f1f95f641", - "sha256:d5cf007add5ab4bb8fa9f4c77e3732127c9e6cad501d7db43355fbfafca0be84", - "sha256:daf96f37f5fc3a7b6c6da862eb4aee61c426bd63da236ed4a73ef0e503b4bca5", - "sha256:dd58ad1404704303ca8d4f4b8a1095e7cbc7040ef17a66df1e6619aa10176430", - "sha256:e38e6fba2d56652fdfaf0231f8f78aeb805234a912de25dc291ee5cce5b8faa4", - "sha256:e43b045e11c16e897895758ae90e4a90cf99e93d58549e2f90c0e2272e155695", - "sha256:e4d0bb73455bf97ab243a8f12c37c686ccf1c13bb614b7b85f1d062f06f42b2c", - "sha256:f00d5ae1dd2fe13fb8186e3e7d37bcbd8b25c0d764ff7d1b32cef9be058510a8", - "sha256:f0405f2ff97b1c4c0e782cb32e02f32369bcf2e6b618b591d67e1ea754575dfe", - "sha256:f50e40081494c1dc4239ebb202014cbcc3306ea96fb6302a34c8cc0967fc5ae8", - "sha256:f58185f06edf6ad68ec9fb155d63ef650c82f3fbd7e1770e2867751fb13158f4", - "sha256:f64627d55def5a43282d70e08396672692f77e4da610a5bb8bb4060b432b6859", - "sha256:f6e80ed91f98316e86b9c137206b04b2bcfbffccbdff49bd2eb09dddb1cf14e0", - "sha256:f948fd5ba1b9cbca91f0ae08b4c1ce2b139509149a435e2585d056d57d70bf01", - "sha256:fb7dc0c3b7d8a1077abea0b8546ebc5e26d6ef6ecefc2f0f5ad2b8a53bdad837", - "sha256:fe9c87ff42e5472d80d21704972e1f96e104a0a599d77c5e35db5a3c562e2571", - "sha256:ffb31267816b93b075302248cc1737506081b4f163df4401e9df1a6424aafabe" + "sha256:002a438859f7b430bc99afeaf01a6d187dad1d0dc907b64cdeffc632a5db8fd8", + "sha256:050467a7983b8e2fe7dd41a78bb30c3e7f8c0b8cafda14b1c46f8b5e3cf2dd3c", + "sha256:050f66a08805acb5b8a23c6d4a517b1ecf82c08e81ed0e4bd727df065e5c6624", + "sha256:0548198fff07ccf4faf469520bce1c2eceb1ce3e62891921138dec10907f9d00", + "sha256:05e491d4f3165d62d4f5c8fd48dfeabf2ae8f42cbbd484319af33ea851b78982", + "sha256:07545711d4f0f32852a18f18ad11f76f0109909d09e78b9008b4cfc67e829429", + "sha256:12b59c90084e3234fb11184886bf4a40f4f16a8c8f867be2e087b81f8e8868d4", + "sha256:141fae2cabf5569b782c10afc4c850ce10f618c13f8db54765cba99cc839da1f", + "sha256:150089274bdc9f940628552cb92844e0223c987f1902ab8efe9f45a2ec758d88", + "sha256:1587fb771d1ccceef708fdde1e5af8c7ed24b486b61d13a321acb7d8145390aa", + "sha256:1c9bf40ebef178a45192c75c4964760bb261b0e6ad725da5fc4c93f674f19753", + "sha256:1cd685005cd2c4200adfc14cf39a603b9320efab3f18a8f7f156d20c9cc3345f", + "sha256:1d10025d96ea89fc2f73714dbc4cbd433fe012c1ac9e23f895d7728b238b6e52", + "sha256:1f4f826d70f772ab8b0c052329580d7fe8b8abd191e4ce0c8f81aec6614665d3", + "sha256:21b803935e2efc3acebe9697197a294fccf5dc4e5382bd6369542ff7a7d2a1d7", + "sha256:226c66e80ec0598d3b9b4874123df167ccca342aca8714f77cac6829688ee09c", + "sha256:2413074a5ecbb61a01a7888fc72db0ca324d13588c5b38bc0dd8564cdcdfea26", + "sha256:288bde2a2d7ab6b6c2d7252fcde8b524387f2d970bdba9658fc6f8bbcaef0f9b", + "sha256:2c9872e4d9dc5d3cf616bf4b382f5a00359305a5be666a3dd0b5cdb4e49597f9", + "sha256:2e179f19bfe1d31f8eeeaa12990194d761c4f62f0759661000bca6cd8729f40b", + "sha256:2ff8f5e9b8f7a94f0c11c45631eee103dbcb7d63274edd12c56efe1be690b3b4", + "sha256:317db01a2cb02552fd67e2b1cca77a4b528a2a277176c5e0bf2cecbb639d3f54", + "sha256:337399ad2c93b3acd2a937627dae8b3e86b66707cd3d3e856347999aadf1ef8d", + "sha256:349062d66f00b40fa2c1c222438bad25fabf755631b5d82937fe985c8008615c", + "sha256:357a173465c7ce028d07a95cc2b63b5bf59f50ecdd5ad75c5cbb78ada984048e", + "sha256:37e2f0cdf58e2e1fed4e4d5a8f8786ae2f7eb80b478016876667dc4a01d60a97", + "sha256:39ece820e29e0a2ba34b3ecb3be83c27e997eed8926f2ba6fe7ce7a0bda5843b", + "sha256:3a54f5a0d85050c73a38f6793090ee83974531e67fe5e57a1da9bee11398aa5e", + "sha256:3d6f4955b73b5445271379a59e3792b0d978f42d4a01e0cf7a67d9c33a3bb0a5", + "sha256:3da9e92d1c551fd7563833e9ade686efb0c4b7363ab7681a94283958c950bf5e", + "sha256:3fc2130bf37df31852a8384f12601563a45a0024bccc6624f38355cba7a8d360", + "sha256:3fc9e047706fb4a9abb54f719d3aa643e80e5bb3818182c40aee01ac0f0247ba", + "sha256:3ff205ab5e3ecc670f6a4dd19d9cbf12ede53dd41cfc1e15716ec961ea6d314e", + "sha256:4256ced708e598e05209bc1a8ab4074e04a51dba4c62fb45926a229af675ace7", + "sha256:425920379052ff1fe465268f3361d35804a241bbdd5a1b592c8cb60df4c52325", + "sha256:43619d04c3671792d2c4706ae8bf45e265dc87bbd4078189ef8b847ea1e74be2", + "sha256:460115e32ee40566476db5048f9bec1e842c127ad8e6f8be745aad3ac9cbc839", + "sha256:46822e9b6ff1c6a72b518c162c44a8f45a61a1d609c51084bf5b16c023c5037b", + "sha256:474223409d88eb20d2d6a0d37ea60e8647a65a90cc008dc1f0410af5f64f1e0d", + "sha256:4a4bf917c9953f57c957be31c1cd504e3bd2f34d4a352b9d391a3025336f6768", + "sha256:4dff9daa47d83120c3ec38ce921214242944a832aa04e903e50b5b7ebac8972d", + "sha256:4e6f6f632b7b2f714bf7a1346e8f97b650ee71f3c298aaad42a2ab60f0f07645", + "sha256:5172326e861a38b48b48befca15e0f477a26b283337a33a739c8fed229934e36", + "sha256:5669c8378ebde86f5def7a25d29586631b58acc27ffde04399f678f3dfc6e082", + "sha256:5a325e815318638aed1655d9c06e6d7c2d3d46c09231ce988070428a8762d734", + "sha256:5c3431b2161279b7db5c2a1aa58ae02e5cb8c3c42d93a5094be3f5537bd5b11b", + "sha256:63fd6fcd1dd6e158f7eb78606e72933b3f6d01e7b747f99c6c12d764307a0fdc", + "sha256:67bc345491ab55b837277d76f5775d057e8c7f1ac44d890d8c2c82adde258c6f", + "sha256:6879ded16a27f3eeca19b900c147e81616e7054db451471a611b2755ee5249f7", + "sha256:68be5e1de60ff13c9095bbec0e5a7fa45b33b101752215b91345ea1f61c4a278", + "sha256:69484d1aca26e322e1c3ce03f09341e84524ababad2d7202161738d83cc9f82e", + "sha256:69bb2400abef928e365ea7d4d9925169ada78ed2295546780002d4b65de3df88", + "sha256:6befeab5fb2b51c958ca4ac6c5d141a1e8240f4f76e46350f1911963deda49cd", + "sha256:6e0a8a5083b096487d6cfced94cdd514d8f5db6f113610fb36c0620edb1028cf", + "sha256:770e9325ab5ea6d56f77e59b29ecfe0ac20b57a82a601876f90494a4dda0386f", + "sha256:7a2b580774a4786c1053157c0165e04476e03ff293993d7c148eee784a94bae6", + "sha256:7bbd7d6418e0dab31a206af5203bd43ae36edb8e7fba1940b055d3e9249290d7", + "sha256:7c922735321eef3f87c280a3d39afff6b646723a2880b862cda4ac7a093b8aa8", + "sha256:7d1abebdb047729e852b9c77a00497dfbeb11eb3a117e037d7dbc3ac8e5f5c54", + "sha256:7f2f62ae3cd189dd2e13aece758c57b3eecbd27be070dbd4cbd10936049e5dbf", + "sha256:81294c7e6ab30c5f74c0353b11b2fd6320e72d9bee6ac73b357caa8b916323a5", + "sha256:81661f82d302484e3119e7c80c519c02fa9bcc2a6b339baf67d67bc89c580f04", + "sha256:83cf06cdd687677742caff1a9134833b7a8b75f111519d2cb0e0ba1b9a851e15", + "sha256:899b9da30f3c6c336566e3707495bb23e8302d39d862f01fa78c48b99b9437e2", + "sha256:8af623e5cd92080acddd02b38f2f406a2c3a0893c38950b211890361448fbf26", + "sha256:8b4f1c3a69ca580f3fbd6b2046915f536d7f586874f25c1bb23add2a3c88d50f", + "sha256:8bc16bb47b7679670eceff71d78bfb7d6e5b143f6c2cd117487ec7c75e0d4b78", + "sha256:8df457da2249d3c75ca2e5e835d59c725abfe92d27fdff6cd99eed85b51d5e9a", + "sha256:8ee3838dcb656602c3b51e16aed9bfb0822f8d8d6d1c5966d32ec8c104be8e20", + "sha256:8fa4de68e2a752468ff14b4e15db7def689a71be759e826a31ccecbef69c5fd0", + "sha256:93a3a0b662abcc10c73a47cbc72cd60f63618d6989fb2d1286e50eacd974f303", + "sha256:964730a1e9de9c0cf11be6a1a3c79ce419c34882842abd256086ba4698705e84", + "sha256:96e257121228ec5cd2bb919276e94ac11074471bc37d68dbae0e8308cce15fff", + "sha256:986be58c3ab54aae8d3496a6225eea74f760fdbe739b38bd442c7e8d133aa53b", + "sha256:9e71e7bc71c686a123347ae47a0de33a175e797a85bb57b791492adf4eec8ed8", + "sha256:a0865421cfdc53654b342d515e5a233187590882d20b95752150e53f65460017", + "sha256:a093fd37229918976f602aa07aa59e0973cde82186f220c8e197f721f5be0ce4", + "sha256:a58a94fed5da6997d258e8f7668c1e195fbd04a691d781b7558f1e468f9e68bc", + "sha256:a67a9f78b2942d87ba8ce3059c642164d2aedd65337377fb52fe9803656bc5c7", + "sha256:a9447978a92f405d301123cfd39ff49895490efb769a758fe2734c7f631bf8ce", + "sha256:a9464451c4efffe8d47ace5a540b10b0dc10e879066290f8600872b7f54a419d", + "sha256:ac41cc14bebda0dbfb0628036b7f75706935c95bcc07fefe9a0f93614aa60a57", + "sha256:af6c538498ce66c10d3fd541c2a8d5b03da5850355add34e6cba564210cb9e72", + "sha256:b10075e5421d04265766a6d1dac809bbeb8a946fbb23c8f82c227409b2190719", + "sha256:b24e078eabcd6a9caa8b0713f9bc1eeb310bcc960a29d45a3b4fcd4b16d5b11d", + "sha256:bbac5abad70df71019988f83f26ac7092ff2642975def4429e98dc7585ef3490", + "sha256:be619439dbcd31a2eab10b32de9fff62c26ed4bab69dc32b8363fdaaa0882809", + "sha256:c2de40895718f91951b86712b4c5b694acaf9a0a49be13874896f599a1eed3f4", + "sha256:c38efe30fd74e5c19e9433f11fb1f5dc9c6522770971b7c6145bbaa413dc8800", + "sha256:c6103639613fe6c1e989082948419bc77a2d26b6c825c99d7fad25f7d3d87afc", + "sha256:c65a9e0dfc6143491879da4e13b5e30f8be192055de508d737fb14601edbd22c", + "sha256:c705b28feb2775dc82a25f1d473a370bc37ff93f5177f4e29ce2425f560f6921", + "sha256:c7dbc748ac8a1e3e59a2b28bea47675e6e778081dbbf081bde0d75def2fcbe1d", + "sha256:cb476b2e828ecb71cb6b6a928d23fd20a7ddb501188022dae1c37499149cc338", + "sha256:cbde877ef9dd7baf272b9bfef2b8a25edd45d9170fc326951dd20eb480335e85", + "sha256:cfe20cc8cf8821d4fe54f89106cbf06aa27f37b5bbe3535568065a81539b4150", + "sha256:d003b7a5708ddad5c206c79607a6b92abb6fc13c57d99d8a4468cc03a2941ced", + "sha256:d0be6daac4cce6b8c8dc65886bae1b082ddbca4da8e5cbb5e15166acf253e264", + "sha256:d12b33a3a50a1676b7784dc8d00a0c6d66a9f2add4b85a041c19b6a7e53ef23c", + "sha256:d15715e8c46552827e5e4f30a35575a2dbcad14454cf3284c54483946bd16931", + "sha256:d28a4a899354d0ea6214cc59b4fa19eefbce1b9ff1688ab579acf49e894bd3fb", + "sha256:d3af93dddb5659276c63bc16ac6466ac2033a70ca816097bbc06345b8ccdf571", + "sha256:d4fedd1f7f428f9fe83b1ead5e7cc87a43427be31aadafbac3ac0636dc7abb22", + "sha256:d802e1947603162ded419bff83ac7489820355d2b856dfb09206574e3a37ac0c", + "sha256:d80f974b20782d9612c8b4c9beeca867074c7cf4079d1419843fa25a26428b25", + "sha256:d9df165544774574ee004b953023d1bebada1894a80b1052a43d798b0f676e67", + "sha256:de602f34123c2f4af1c1869c6dbbbd60da6d5983bf01937367295d135cccbfce", + "sha256:def597967dafc2e8d97c9097ea453c464e0bb8ed38f193a43070f10dc623bb6d", + "sha256:e101dbb4b9b72f0cddd8cdc8c9c5b47f456766f5e0ac82dbfb75e5c55409b78a", + "sha256:e4193a04b518f7968f3099755f5509ee7cccc6dc2b92a6b14841934d22e222c9", + "sha256:e98dcc55d572b38e69d117da7e8e8efb8500f1f5eaf81ecd460a63220790b839", + "sha256:ea82116c9893fa89e929b7f197ee5a1950a76e91cc5c85ba503fc02379d04890", + "sha256:ebd5a6d8466ff30836572f3ba2cae8a5e8f85029b1c6d5e2ed338dc472a5166a", + "sha256:f0204ed122758782970526057093f448051a39db9d810d4e344bb87a3546f425", + "sha256:f21b56dcace11dfe013014201f577dcd592b2a9b72182d930361b47cf6f73f25", + "sha256:f41c17c4668a655ce96d090d8d5ffdc24ef64b5a02f9753884d08483e8a4a41a", + "sha256:f9de0a24a4079b53e523b5c5e2c5945ec251ab486652659955187cf255a259bc", + "sha256:fb55d0e70bb15f2e81477613627286581414693d74ac7963c93a790dd453ca9d", + "sha256:ff97a14362eef486483ed44042ca2027ea257df6ff768e62358ee0c9776925ac", + "sha256:ffb3c2aacea411cc7e1d27712490c11108e2de1d39019ae32915493a59a8b9ed", + "sha256:ffb58d7eff5b7f6ecc6fa21d6288ab7f968a212cb67d682c269c09b9eba3b66f" ], "markers": "python_version >= '3.10'", - "version": "==7.15.0" + "version": "==7.15.4" }, "dill": { "hashes": [ @@ -258,11 +288,11 @@ }, "h2": { "hashes": [ - "sha256:6c59efe4323fa18b47a632221a1888bd7fde6249819beda254aeca909f221bf1", - "sha256:c438f029a25f7945c69e0ccf0fb951dc3f73a5f6412981daee861431b70e2bdd" + "sha256:0e25f1462b23c9cb82d9eb02e28bc706dac2a68cb457c6a0d74d63c8a2a5d0e6", + "sha256:4e866ffb1a869ae14dd9b5e6beb5c24a13da0495ad72b65925ded182521c1516" ], - "markers": "python_version >= '3.9'", - "version": "==4.3.0" + "markers": "python_version >= '3.10'", + "version": "==4.4.1" }, "hpack": { "hashes": [ @@ -333,19 +363,19 @@ }, "packaging": { "hashes": [ - "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", - "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661" + "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", + "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c" ], - "markers": "python_version >= '3.8'", - "version": "==26.2" + "markers": "python_version >= '3.9'", + "version": "==26.3" }, "platformdirs": { "hashes": [ - "sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7", - "sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a" + "sha256:5ed065d443751de711da036041a7a214122efc4a4de393b3f4137ba5576540e7", + "sha256:66a73d38a849810252df809a3d8bcbda8e26f6c189920e7535ad608a48dbb5ab" ], "markers": "python_version >= '3.10'", - "version": "==4.10.0" + "version": "==4.11.3" }, "pluggy": { "hashes": [ @@ -365,12 +395,12 @@ }, "pylint": { "hashes": [ - "sha256:52f19191bee08bf103f9705ad1a0ece4aa5a0a4ef2bdcbd969375a1e6f6579d5", - "sha256:d11a0e1fdb7b1cd46ec5d6fc78fee8b95f28695b2d6140e5809925f61e32ea54" + "sha256:9b2d1d15791c84b77a4fe2aafe8f0d9570717e2dea06d53b19c105cf60275a52", + "sha256:be4a3111557a614411ed1fc89347ce4a8e1013a59e1f33d11485227a02e3304d" ], "index": "pypi", "markers": "python_full_version >= '3.10.0'", - "version": "==4.0.6" + "version": "==4.0.7" }, "pytest": { "hashes": [ @@ -401,16 +431,17 @@ }, "python-dotenv": { "hashes": [ - "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", - "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3" + "sha256:904552145e8bfed22162c09dab1c2b9b54fefa7b23ba780f4f26ca0316b0f0d9", + "sha256:a20a594dabeaa385725aa239d5244871c143ecb356add8a20fcf23773a6c3a35" ], "index": "pypi", "markers": "python_version >= '3.10'", - "version": "==1.2.2" + "version": "==1.2.3" }, "switcher-client": { "editable": true, - "file": "." + "file": ".", + "markers": "python_version >= '3.9'" }, "tomli": { "hashes": [ @@ -468,11 +499,11 @@ }, "tomlkit": { "hashes": [ - "sha256:4dbc8f0fc024412b57ced8757ac7461305126a648ff8c2c807fcb8e133a78738", - "sha256:7d1a9ecba3086638211b13814ea79c90dd54dd11993564376f3aa92271f5c7a3" + "sha256:177a05aece5a8ca5266fd3c448abb47b8d352f09d477d3ca8332db4d89b24304", + "sha256:e25bbf38843005246210a12982776f27f99cb9be67160e14434d0c0d21ee1e97" ], "markers": "python_version >= '3.9'", - "version": "==0.15.0" + "version": "==0.15.1" } } } diff --git a/tests/test_client_check_switchers.py b/tests/test_client_check_switchers.py index e98c037..6b9bf62 100644 --- a/tests/test_client_check_switchers.py +++ b/tests/test_client_check_switchers.py @@ -26,7 +26,7 @@ def test_check_remote_switchers_not_found(httpx_mock): # test try: Client.check_switchers(['MY_SWITCHER', 'ANOTHER_SWITCHER']) - assert False, 'Expected RemoteSwitcherError to be raised' + raise AssertionError('Expected RemoteSwitcherError to be raised') except RemoteSwitcherError as e: assert str(e) == 'MY_SWITCHER not found' @@ -41,7 +41,7 @@ def test_check_remote_switchers_api_error(httpx_mock): # test try: Client.check_switchers(['MY_SWITCHER', 'ANOTHER_SWITCHER']) - assert False, 'Expected RemoteError to be raised' + raise AssertionError('Expected RemoteError to be raised') except Exception as e: assert str(e) == '[check_switchers] failed with status: 500' @@ -55,7 +55,7 @@ def test_chek_remote_switchers_auth_error(httpx_mock): # test try: Client.check_switchers(['MY_SWITCHER', 'ANOTHER_SWITCHER']) - assert False, 'Expected RemoteError to be raised' + raise AssertionError('Expected RemoteError to be raised') except Exception as e: assert str(e) == 'Invalid API key' diff --git a/tests/test_client_context.py b/tests/test_client_context.py index 2fe3a97..6ce256f 100644 --- a/tests/test_client_context.py +++ b/tests/test_client_context.py @@ -25,9 +25,11 @@ def test_context_remote_validation(): domain='My Domain' ) + switcher = Client.get_switcher() + # test with pytest.raises(ValueError) as excinfo: - Client.get_switcher().validate() # used by is_on() + switcher.validate() # used by is_on() assert 'Missing or empty required fields (url, component, api_key)' in str(excinfo.value) diff --git a/tests/test_switcher_integration.py b/tests/test_switcher_integration.py index 5ae6fdf..6251bc6 100644 --- a/tests/test_switcher_integration.py +++ b/tests/test_switcher_integration.py @@ -64,7 +64,7 @@ def test_check_switcher_availability(): def given_context(options: Optional[ContextOptions] = None): Client.build_context( - url='https://api.switcherapi.com', + url='http://localhost:3001', api_key=API_KEY, domain='Switcher API', component='switcher-client-python', diff --git a/tests/test_switcher_remote.py b/tests/test_switcher_remote.py index b06b70f..585d322 100644 --- a/tests/test_switcher_remote.py +++ b/tests/test_switcher_remote.py @@ -240,7 +240,7 @@ def test_remote_err_with_remote_reqquired_request_no_local(): # test with pytest.raises(ValueError) as excinfo: - switcher.remote().is_on('MY_SWITCHER') + switcher.remote() assert 'Local mode is not enabled' in str(excinfo.value) From f13581e5b3ba5a48aef87265e29d6ba165d3a63f Mon Sep 17 00:00:00 2001 From: petruki <31597636+petruki@users.noreply.github.com> Date: Sun, 23 Aug 2026 15:51:21 -0700 Subject: [PATCH 2/2] fix(tests): replaced global assignment with monkeypatch --- tests/remote/test_remote_auth.py | 4 ++-- tests/test_switcher_integration.py | 17 +++++++++------ tests/test_switcher_remote.py | 30 ++++++++++++++------------- tests/test_switcher_stub_decorator.py | 2 +- 4 files changed, 30 insertions(+), 23 deletions(-) diff --git a/tests/remote/test_remote_auth.py b/tests/remote/test_remote_auth.py index 8fb08f6..6650810 100644 --- a/tests/remote/test_remote_auth.py +++ b/tests/remote/test_remote_auth.py @@ -62,7 +62,7 @@ def fake_auth(_): assert mock_auth.call_count == 1 mock_schedule.assert_not_called() -def test_stop_auto_renew_ignores_stale_generation(): +def test_stop_auto_renew_ignores_stale_generation(monkeypatch): """ Should not cancel the active renewer when stop is requested with a stale generation """ # given @@ -71,7 +71,7 @@ def test_stop_auto_renew_ignores_stale_generation(): )) active_timer = Mock() current_generation = _current_generation() - RemoteAuth._RemoteAuth__auto_renew_timer = active_timer # type: ignore + monkeypatch.setattr(RemoteAuth, '_RemoteAuth__auto_renew_timer', active_timer) # test RemoteAuth._stop_auto_renew(generation=_stale_generation()) diff --git a/tests/test_switcher_integration.py b/tests/test_switcher_integration.py index 6251bc6..a0e04a3 100644 --- a/tests/test_switcher_integration.py +++ b/tests/test_switcher_integration.py @@ -1,3 +1,7 @@ +# Please, in order to run these tests, include a .env file in the root of the project with the following content: +# SWITCHER_URL=https://api.switcherapi.com +# SWITCHER_API_KEY= + import os from typing import Optional @@ -8,13 +12,14 @@ from switcher_client.lib.globals.global_snapshot import LoadSnapshotOptions load_dotenv() -API_KEY = os.getenv('SWITCHER_API_KEY') +SWITCHER_API_KEY = os.getenv('SWITCHER_API_KEY') +SWITCHER_URL = os.getenv('SWITCHER_URL') WARN_SKIP = 'API key not found. Please set the SWITCHER_API_KEY environment variable.' def test_is_on(): """ Should call the remote API with success """ - if not API_KEY: + if not SWITCHER_API_KEY: print(WARN_SKIP) return @@ -29,7 +34,7 @@ def test_is_on(): def test_load_snapshot(): """ Should load the snapshot from the remote API with success """ - if not API_KEY: + if not SWITCHER_API_KEY: print(WARN_SKIP) return @@ -47,7 +52,7 @@ def test_load_snapshot(): def test_check_switcher_availability(): """ Should check the switcher availability with success """ - if not API_KEY: + if not SWITCHER_API_KEY: print(WARN_SKIP) return @@ -64,8 +69,8 @@ def test_check_switcher_availability(): def given_context(options: Optional[ContextOptions] = None): Client.build_context( - url='http://localhost:3001', - api_key=API_KEY, + url=SWITCHER_URL, + api_key=SWITCHER_API_KEY, domain='Switcher API', component='switcher-client-python', options=options diff --git a/tests/test_switcher_remote.py b/tests/test_switcher_remote.py index 585d322..3a5c45a 100644 --- a/tests/test_switcher_remote.py +++ b/tests/test_switcher_remote.py @@ -1,4 +1,5 @@ import pytest +import sys import time import threading import httpx @@ -206,12 +207,12 @@ def test_remote_with_remote_required_request(httpx_mock): # test assert switcher.remote().is_on(key) -def test_remote_with_custom_cert(httpx_mock): +def test_remote_with_custom_cert(httpx_mock, monkeypatch): """ Should call the remote API with success using a custom certificate """ # Reset Remote client to ensure fresh SSL context creation from switcher_client.lib.remote import Remote - Remote._client = None + monkeypatch.setattr(Remote, '_client', None) # given given_auth(httpx_mock) @@ -306,7 +307,7 @@ def test_remote_err_check_criteria(httpx_mock): assert '[check_criteria] failed with status: 500' in str(excinfo.value) -def test_remote_err_check_criteria_default_result(httpx_mock): +def test_remote_err_check_criteria_default_result(httpx_mock, monkeypatch): """ Should return the default result when the check criteria fails """ # given @@ -314,8 +315,9 @@ def test_remote_err_check_criteria_default_result(httpx_mock): given_check_criteria(httpx_mock, show_details=True, status=500) given_context() - globals().update(async_error=None) - Client.subscribe_notify_error(lambda error: globals().update(async_error=str(error))) + this_module = sys.modules[__name__] + monkeypatch.setattr(this_module, 'async_error', None) + Client.subscribe_notify_error(lambda error: monkeypatch.setattr(this_module, 'async_error', str(error))) switcher = Client.get_switcher() # test @@ -350,12 +352,12 @@ def test_remote_health_check_unavailable(httpx_mock): # test assert not Remote.check_api_health(Client._context) -def test_remote_client_rebuilds_when_timeout_changes(httpx_mock): +def test_remote_client_rebuilds_when_timeout_changes(httpx_mock, monkeypatch): """ Should rebuild the shared remote client when timeout options change """ # given - Remote._client = None - Remote._client_config = None + monkeypatch.setattr(Remote, '_client', None) + monkeypatch.setattr(Remote, '_client_config', None) given_auth(httpx_mock) given_check_criteria(httpx_mock, response={'result': True}, match={'entry': []}, match_extensions={ @@ -401,12 +403,12 @@ def test_remote_client_rebuilds_when_timeout_changes(httpx_mock): # test assert Client.get_switcher().is_on('MY_SWITCHER') -def test_remote_client_uses_default_connection_limits(): +def test_remote_client_uses_default_connection_limits(monkeypatch): """ Should build the shared remote client using the default connection limit options """ # given - Remote._client = None - Remote._client_config = None + monkeypatch.setattr(Remote, '_client', None) + monkeypatch.setattr(Remote, '_client_config', None) given_context() # test @@ -416,12 +418,12 @@ def test_remote_client_uses_default_connection_limits(): assert pool._max_connections == 100 assert pool._keepalive_expiry == 30.0 -def test_remote_client_rebuilds_when_connection_limits_change(): +def test_remote_client_rebuilds_when_connection_limits_change(monkeypatch): """ Should rebuild the shared remote client when connection limit options change """ # given - Remote._client = None - Remote._client_config = None + monkeypatch.setattr(Remote, '_client', None) + monkeypatch.setattr(Remote, '_client_config', None) given_context(options=ContextOptions( remote=RemoteOptions( max_keepalive_connections=5, diff --git a/tests/test_switcher_stub_decorator.py b/tests/test_switcher_stub_decorator.py index ebb366a..ba62f7e 100644 --- a/tests/test_switcher_stub_decorator.py +++ b/tests/test_switcher_stub_decorator.py @@ -71,7 +71,7 @@ def flaky_assume(key: str): ) def decorated_test(): """ This test should never run because setup fails before execution. """ - assert False + pytest.fail('The decorated test should not run when setup fails.') with pytest.raises(RuntimeError, match='setup failed'): decorated_test()