Skip to content

v0.6 (complete rewrite)#7

Merged
alexeyshockov merged 286 commits into
mainfrom
feat/v-next
May 11, 2026
Merged

v0.6 (complete rewrite)#7
alexeyshockov merged 286 commits into
mainfrom
feat/v-next

Conversation

@alexeyshockov

Copy link
Copy Markdown
Owner

No description provided.

alexeyshockov and others added 27 commits May 10, 2026 00:29
Importing HostRSGIApp at package init pulled localpost.http (and
transitively localpost.threadtools) into the hosting bootstrap, closing a
cycle the moment any non-hosting module needed current_service. Move
HostRSGIApp behind localpost.hosting.rsgi so the hosting package init no
longer depends on http; update tests, docs, and the docstring example.
threadtools._run_async can now import current_service at the top level.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
BlockingPortal.call() raises if invoked from the loop thread, so every
caller had to know its calling context up-front. AsyncExecutor.stop(),
AsyncWorkerExecutor.stop(), and friends carried "must be called from a
non-loop thread" caveats; hosting/_host.py duplicated the pattern with
a hand-rolled thread_id field, same_thread property, and in_host_thread
helper.

Introduce localpost.Portal: a wrapper that snapshots the loop thread's
ident at construction and exposes run_sync (direct call on-loop,
portal.call off-loop) and run_async (start_task_soon().result(), with
a clean RuntimeError on the loop thread instead of a deadlock).

Public API changes:
- ServiceLifetimeView.portal, AsyncExecutor(portal=), and
  AsyncWorkerExecutor(portal=) now use Portal instead of BlockingPortal.
- AsyncExecutor.stop() / AsyncWorkerExecutor.stop() are safe from any
  thread; their docstring caveats go away.
- Drop ServiceLifetime.thread_id, ServiceLifetime.same_thread,
  hosting._host.in_host_thread — replaced by Portal.same_thread /
  Portal.run_sync.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…async CMs

Counterpart to run_async for context managers: lets a worker thread enter
an async CM via the current service's portal, with a same-thread guard to
prevent loop deadlocks.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Workers in WorkerExecutor / AsyncWorkerExecutor now live for the
executor's lifetime — no per-worker `idle_timeout`, no
`DEFAULT_IDLE_TIMEOUT` constant. With workers no longer exiting
independently, switch to a single shared receiver: drop `_rx_template`,
`_open_receivers`, the per-worker `rx.clone()` / `rx.close()`, and the
`get_nowait` recheck under lock that resolved the producer-vs-timeout
race. The worker loop reduces to `for task in self._rx: task.run()`.

Rationale, alternatives, and the cross-language survey behind this
choice are captured in ADR-0005.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…d concurrency caps

Worker pools no longer manage their own concurrency cap or backlog —
callers control concurrency upstream of submit (Cloud Run-like gate,
consumer-level Semaphore, etc.). With the cap gone, the channel-backed
storage layer collapses to a plain deque + threading.Condition: one lock
per submit instead of put_nowait → WouldBlock → put, and no dependence
on Channel's capacity / clones / broadcast machinery.

AsyncExecutor (per-task spawn gated by CapacityLimiter) is removed; it
had no in-tree callers and per-task Future.cancel() propagation isn't
needed when concurrency is controlled at the call site.

ADR-0005 updated to reflect the deque-based shape and the broader
"caller owns concurrency" stance.
…xecutor

Both executors share the same wait/notify discipline (submit, _run_worker,
mark-closed). Moving them onto a private _WorkerPoolBase removes ~80
lines of duplication and gives a single source of truth for the queue
protocol; leaves stay @Final and own their own context-manager lifecycle
plus _spawn_worker.

WorkerExecutor now has stop() (was async-only): marks the pool closed
and wakes idle workers — busy workers finish their current task and exit
on the next iteration. Useful from a signal handler or any thread that
needs to start shutdown without waiting for the with-block to exit.

The WorkerExecutor.workers property is dropped — it was test-only and
leaked the internal Thread list. Tests now verify the contract via
threading.get_ident() inside tasks (which is what the property was
indirectly checking anyway). Both executors expose worker_count: int as
the symmetric public observation point.
…kers

Outer-scope cancellation already correctly unblocks idle workers stuck
in cond.wait, but only because __aexit__'s _mark_closed() runs
synchronously before the await on tg.__aexit__. If a future refactor
swaps these or sneaks an await between them, idle workers would stay
parked while AnyIO's abandon_on_cancel=False waits for the thread to
return — a permanent hang.

Add a test that wraps the scenario in fail_after(2.0) so the hang would
surface as a test failure, and a comment in __aexit__ pointing at the
test as the guard for the invariant.

Verified passing on both asyncio and trio.
- pyproject: add dev-tools group with ruff/ty/basedpyright so
  `uv sync --all-groups` installs the lint binaries the workflow runs.
- hosting/middleware: install the signal receiver via tg.start (with
  task_status.started) before the wrapped service runs, so a signal
  arriving right after READY can't kill the process before
  open_signal_receiver registers a handler.
- ci/tests-free-threaded: add the pure-Python optional groups
  (dev-http, dev-sentry, click extra) so tests/http and
  tests/hosting/services collect; ignore tests/openapi (needs the
  msgspec C ext, not part of the FT subset).
- tests/http: gate test_many_requests_served_from_worker_threads on a
  Barrier so two concurrent in-flight requests force a second worker;
  the previous handler returned fast enough that one worker could
  serve all 10 sequentially.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Re-add the ``# noqa: PLC0415`` directives the pre-commit.ci bot stripped:
  v0.9.7 of ruff (pinned in .pre-commit-config) doesn't know the rule
  and treats the comments as unused, but v0.15 (the workflow's ruff)
  still raises PLC0415, so the GH Actions lint-types job fails on the
  bare lazy-import sites. Bump the pre-commit ruff to v0.15.12 to align
  with dev-tools so the next autoupdate won't strip them again.
- tests-free-threaded: gate three httptools-specific tests in
  tests/http/app.py on pytest.importorskip("httptools") so they skip
  cleanly on the FT subset, and --ignore tests/hosting/rsgi.py since it
  drives lifecycle hooks that pull in granian's mocks but don't run
  cleanly on 3.14t.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ests on FT

- tests/http/app.py::TestWorkerPool::test_handlers_run_on_worker_threads
  now gates each handler on a 2-thread Barrier so two requests run
  concurrently and force a second worker; without the barrier the
  pool's single worker can serve all 8 requests sequentially (race
  observed on 3.12).
- Add ``pytest.importorskip("httptools")`` to
  tests/http/service.py::TestAcceptorTopology::test_serves_requests_httptools
  so it skips on the FT subset (httptools isn't installed because 0.7.x
  re-enables the GIL on 3.14t).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Rename the ``tests-free-threaded`` job to ``3.14t`` so the check
  name matches uv's interpreter naming.
- Drop the ``MemoryStream[T]`` wrapper from ``localpost/_utils.py``
  and inline ``anyio.create_memory_object_stream[T](...)`` at the two
  call sites. SonarCloud's analyzer reads PEP 695 ``class Foo[T]:``
  subscription as instance ``__getitem__`` and false-positives on
  ``MemoryStream[X]``; anyio's own subscription pattern is recognised
  and the wrapper was a thin pass-through anyway.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
GitHub Actions job IDs must match ^[A-Za-z_][A-Za-z0-9_-]*$; "3.14t"
violates both rules (starts with a digit, contains a dot), so the
workflow failed to parse. Rename the job to ``tests-3-14t`` and keep
"3.14t" as the display ``name:`` so the check label is unchanged.

Wire actionlint into pre-commit to catch this class of schema/syntax
error locally — it flags the invalid job ID and validates expressions,
action refs, and shell snippets via shellcheck. Drive-by: quote the
``$(which python)`` arg flagged by SC2046.
- Bump pre-commit-hooks v5 -> v6, codespell -> v2.4.2, actionlint -> v1.7.12.
- Rename ``ruff`` -> ``ruff-check`` (new canonical hook id; old one prints
  a legacy-alias warning).
- Repoint interrogate from ``tests`` to ``localpost`` with ``--fail-under=24``
  so it measures library-docstring coverage and prevents regressions
  (floor matches current coverage; ratchet up as docstrings land).
- Add three cheap safety nets from pre-commit-hooks: check-merge-conflict,
  check-case-conflict, check-added-large-files.
- Add ``validate-pyproject`` (catches pyproject.toml schema typos).
- Add ``astral-sh/uv-pre-commit`` (``uv-lock``) so the lockfile can't drift
  from ``pyproject.toml`` without a commit-time failure.
- Pin ``minimum_pre_commit_version`` for clarity.
Drop codespell in favour of crate-ci/typos: Rust-based, ~10x faster,
identifier-aware tokenizer (handles camelCase / snake_case), and a
smaller curated dictionary with materially fewer false positives.

The previous ``-L alog -L abl`` allowlist translates to zero entries
in typos — both tokens are correctly left alone by its tokenizer. The
only allowlisted word now is ``ands`` (genuine verb form for the
selector ``ANDs`` test name in tests/benchmarks/http_stacks.py).
Apply typos's preferred spelling (``unparsable`` rather than the
equally-valid ``unparseable``) across docs, the static-file handler,
and its tests. Includes the ``test_unparseable`` → ``test_unparsable``
rename and the matching docstring on ``_parse_range``.
…ained

Restructure CI test jobs so the free-threaded build isn't an oddball
single-job sibling of the stable matrix:

- ``tests`` matrix: 3.12, 3.13, 3.14, 3.15 (beta). Drop ``--cov`` flags
  and the upload-artifact step — coverage is owned by sonar now.
- ``tests-ft`` matrix (new): 3.14t, 3.15t (beta). Same FT-clean
  dependency subset and ``PYTHON_GIL=0`` env as before, now applied
  uniformly across the two free-threaded interpreters.
- ``sonarcloud`` now runs pytest itself with coverage (no
  download-artifact, no ``needs: tests``) on the project-pinned Python
  from ``.python-version``. Runs in parallel with the test jobs.
Remove the ``lint-types`` job. ruff is already covered by pre-commit.ci
on every PR; ty and basedpyright-verifytypes were the only checks
unique to this job, and basedpyright-verifytypes has been unmaintainable
in CI (requires 100% type completeness; reached 97.2% even after
``--ignoreexternal``). Move both to local-only tooling — ``just types``
and ``just type-coverage`` are still wired up.

Also convert the ``tests`` matrix to ``include`` form and add
``continue-on-error: true`` for the 3.15 entry. Python 3.15 is still
beta and ``granian``'s PyO3 0.27.2 doesn't compile against it yet —
this keeps 3.15 as an early-signal channel without blocking the PR.
- ``localpost/threadtools/_channel.py``: annotate
  ``ReceiveChannel.__exit__/__aexit__`` and ``SendChannel.__exit__/__aexit__``
  with standard ``type[BaseException] | None`` / ``BaseException | None`` /
  ``TracebackType | None``. Improves the type hints downstream users see
  when implementing or consuming the channel protocols.
- ``pyproject.toml``: ignore ``PLC0415`` (function-level imports) under
  ``tests/`` — tests legitimately use lazy / conditional imports for
  optional extras and internals under test.
- ``tests/http/backend_parity.py``: move ``import pytest`` to the top
  of the file and call ``pytest.skip`` directly; the per-function
  ``import pytest as _pytest`` was tripping both PT013 and PLC0415.
Mirror the ``tests`` matrix: convert ``tests-ft`` to ``include`` form
and mark 3.15t as ``experimental: true`` so its failure won't block
the PR. Free-threaded 3.15 is at the same beta-readiness as the
stable 3.15 build, and there's no reason to gate merges on it.
``just release-check`` prints a pre-release report:
- current type completeness (basedpyright --verifytypes, now with
  ``--ignoreexternal`` so the score reflects our own code only — was
  ~94% with werkzeug/flask noise, ~97% without)
- public-API breaking changes vs the previous stable tag, via
  ``griffe check`` — same tool Pydantic / FastAPI use for the same
  purpose

``just api-diff [base]`` is the griffe call on its own. Both recipes
default ``base`` to the latest tag matching strictly ``vX.Y.Z`` (no
``b1`` / ``rc1`` / ``.dev0`` suffix), via a justfile-level variable
``previous_stable_tag``.

Both are informational (always exit 0); for assessment, not
gatekeeping. ``griffe`` is already installed at user level via
``uv tool install griffe`` in ``just doctor``. ``type-coverage`` now
uses the ``-`` prefix to match the other type-check recipes — failure
shouldn't abort the umbrella.
- Setup: surface ``just doctor`` (user-level toolchain) and the
  one-time ``pre-commit install`` step, and note the project keeps
  tools at user level (not in the project venv).
- PR checklist: clarify that ``just type-coverage`` is informational
  now — basedpyright's score reports our public-API completeness but
  isn't a 100% gate (lint-types was dropped from CI).
- Release process: add ``just release-check`` as step 1 (pre-flight
  review of type coverage + griffe BC report vs the previous stable
  tag), renumber the remaining steps.
@sonarqubecloud

Copy link
Copy Markdown

@alexeyshockov
alexeyshockov merged commit fe5b1ed into main May 11, 2026
8 of 9 checks passed
@alexeyshockov
alexeyshockov deleted the feat/v-next branch May 11, 2026 12:22
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