diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8e1a4b6..4343870 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,7 +27,7 @@ jobs: pip install pytest pytest-asyncio pytest-cov ruff mypy - name: Lint - run: ruff check sillo_wire tests _sillo_wire_bootstrap.py + run: ruff check sillo_wire tests - name: Types run: mypy sillo_wire @@ -35,19 +35,28 @@ jobs: - name: Install the package run: pip install -e . - # The `.pth` only fires for a real install, so the alias is checked - # against a built wheel rather than the editable checkout. - - name: Check the sillo.wire alias against a real install + # Checked against a built wheel rather than the editable checkout: an + # editable install can import a package the wheel does not actually ship. + - name: Check a built wheel installs and imports run: | pip install build && python -m build --wheel -o /tmp/w . python -m venv /tmp/v && /tmp/v/bin/pip install -q sillo-framework /tmp/w/*.whl mypy /tmp/v/bin/python -c " - import sillo, sillo.wire, sillo_wire, os - assert sillo.wire is sillo_wire + import os, sillo, sillo_wire + from sillo_wire import Hub + # The package is top-level and adds nothing to the framework's own + # directory; it used to ship a .pth that aliased sillo.wire onto it. assert 'wire' not in os.listdir(os.path.dirname(sillo.__file__)), 'wrote into sillo/' - print('alias ok, sillo/ untouched') + try: + import sillo.wire + except ImportError: + pass + else: + raise AssertionError('sillo.wire still resolves; the alias was removed') + print('sillo_wire imports, sillo/ untouched, no alias') " - printf 'from sillo.wire import Hub\nh: Hub = Hub()\n' > /tmp/tc.py + # Types come from the package's own inline annotations now, not stubs. + printf 'from sillo_wire import Hub\nh: Hub = Hub()\n' > /tmp/tc.py /tmp/v/bin/python -m mypy /tmp/tc.py - name: Test diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6738225..ed4aca2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -77,9 +77,10 @@ jobs: - name: Build the package run: uv build - # The whole point of this package's layout: the wheel adds a top-level - # `sillo_wire` plus the `.pth`/bootstrap at the site-packages root, and - # it must never write into the framework's own `sillo/` directory. + # The wheel adds a top-level `sillo_wire` and nothing else; it must never + # write into the framework's own `sillo/` directory. Two distributions + # sharing one package directory goes wrong in both directions, which is + # why this is asserted rather than assumed. - name: The wheel must stay out of sillo/ run: | python - <<'PY' diff --git a/CHANGELOG.md b/CHANGELOG.md index 936ce1d..923bbcf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,27 @@ # Changelog +## Unreleased + +### Removed + +- **The `sillo.wire` import alias.** `sillo_wire` is now the only import path: + + ```python + from sillo_wire import Hub, Peer # was: from sillo.wire import ... + ``` + + The alias was a meta-path finder registered by a `.pth` at interpreter + startup, plus PEP 561 stubs under `sillo-stubs/` to serve type checkers, + which never run import hooks. It read as part of the framework, but it cost a + `.pth` executing on every interpreter start in every environment the package + was installed in, a second set of stubs to keep in step with the real + package, and a name that static analysis only resolved because a second set + of files said so. A plain top-level package needs none of that. + + `_sillo_wire_bootstrap.py`, `sillo_wire.pth` and `sillo-stubs/` are gone, and + so are the `force-include` blocks that shipped them. Inline types in + `sillo_wire` are now the single source of truth for type checkers. + ## 0.1.0.dev1 Development pre-release for testing. Install with diff --git a/README.md b/README.md index b005d26..517b3ab 100644 --- a/README.md +++ b/README.md @@ -6,11 +6,11 @@ Rooms, presence and fan-out for [Sillo](https://sillo.build) WebSockets. pip install sillo-wire ``` -Installs as `sillo-wire`, imports as `sillo.wire`. +Installs as `sillo-wire`, imports as `sillo_wire`. ```python from sillo import SilloApp -from sillo.wire import Hub, Peer +from sillo_wire import Hub, Peer app = SilloApp() hub = Hub() @@ -66,7 +66,7 @@ await hub.replay(peer, "lobby", since=last_seq_the_client_saw) When a peer's queue fills, what happens is a choice, not a default: ```python -from sillo.wire import Overflow, Peer +from sillo_wire import Overflow, Peer Peer(socket, overflow=Overflow.DROP_OLDEST) # keep current — prices, cursors Peer(socket, overflow=Overflow.DROP_NEWEST) # keep order — reconcile later @@ -98,7 +98,7 @@ joins the rooms, pumps messages, and guarantees the peer is removed from every room when the connection ends — including when a hook raises. ```python -from sillo.wire import Hub, RoomConsumer +from sillo_wire import Hub, RoomConsumer hub = Hub() @@ -122,7 +122,7 @@ app.add_ws_route(path="/ws/{room}", handler=Chat.as_handler()) Retention is per room and capped by payload bytes, evicting oldest first: ```python -from sillo.wire import Hub, MemoryBacklog, NullBacklog +from sillo_wire import Hub, MemoryBacklog, NullBacklog Hub(backlog=MemoryBacklog(capacity_bytes=4 * 1024 * 1024)) Hub(backlog=NullBacklog()) # keep nothing — typing indicators, telemetry @@ -133,10 +133,10 @@ importing anything from here. ## Testing -`sillo.wire.testing` ships the piece unit tests are missing — a socket: +`sillo_wire.testing` ships the piece unit tests are missing — a socket: ```python -from sillo.wire import Hub, Peer +from sillo_wire import Hub, Peer from sillo_wire.testing import FakeSocket, drain async def test_a_broadcast_reaches_the_room(): @@ -165,32 +165,6 @@ to reproduce against a real server and the two most worth testing. | `Backlog` | `MemoryBacklog` `NullBacklog`, or your own | | `Overflow` | `DROP_OLDEST` `DROP_NEWEST` `CLOSE` | -## The two import paths - -`sillo.wire` and `sillo_wire` name the same objects. The code lives in the -top-level `sillo_wire` package; `sillo.wire` is an alias, so it reads as part -of the framework: - -```python -from sillo.wire import Hub # both of these -from sillo_wire import Hub # bind the same class -``` - -The alias is a meta-path finder registered by a `.pth` at interpreter startup — -the only hook that runs before an `import sillo.wire` could fail. Type checkers -never run import hooks, so they are served separately by the partial stubs in -`sillo-stubs/` (PEP 561), which are additive: mypy resolves `sillo.wire` and -still uses the framework's own inline types for the rest of `sillo`. - -Nothing is written into the framework's package directory. Shipping -`sillo/wire/` in there would be simpler, and it is what this did first — but -two distributions sharing one directory goes wrong in both directions. -Installing the framework from a checkout moves where `sillo` resolves and -orphans the copy in site-packages; removing or replacing the framework leaves -that directory standing with no `__init__.py`, which is an override rather than -an addition. Uninstalling either package here leaves the other exactly as it -was. - ## Working on it The alias works under an editable install too — the `.pth` is shipped by the @@ -199,7 +173,7 @@ editable build target as well as the wheel. ```bash pip install -e ".[dev]" pytest --cov # 100% required, bootstrap included -ruff check sillo_wire tests _sillo_wire_bootstrap.py +ruff check sillo_wire tests mypy sillo_wire ``` diff --git a/_sillo_wire_bootstrap.py b/_sillo_wire_bootstrap.py deleted file mode 100644 index 579d9a4..0000000 --- a/_sillo_wire_bootstrap.py +++ /dev/null @@ -1,104 +0,0 @@ -"""Make ``sillo.wire`` resolve to this package, without touching the framework. - -The code lives in the top-level ``sillo_wire`` package. This module registers a -meta-path finder that maps the name ``sillo.wire`` onto it, so both import -paths reach the same objects: - - from sillo.wire import Hub # reads as part of the framework - from sillo_wire import Hub # where the code actually is - -It is loaded by ``sillo_wire.pth`` at interpreter startup, which is the only -hook that runs *before* an ``import sillo.wire`` could fail. Nothing is -imported here — neither ``sillo`` nor ``sillo_wire`` — so the cost is one -object appended to ``sys.meta_path``. - -Why not simply ship ``sillo/wire/`` into the framework's own package directory: -two distributions writing into one directory goes wrong in both directions. -Installing the framework from a checkout moves where ``sillo`` resolves and -orphans whatever the other package left in ``site-packages``; and removing or -replacing the framework leaves that directory standing with no ``__init__.py`` -in it, which is an override rather than an addition. Nothing here writes into -``sillo/`` at all. - -Static analysis does not run import hooks, so type checkers are served -separately, by the partial stubs in ``sillo-stubs/`` (PEP 561). -""" - -from __future__ import annotations - -import sys -from importlib.abc import Loader, MetaPathFinder -from importlib.machinery import ModuleSpec -from importlib.util import find_spec - -ALIAS = "sillo.wire" -REAL = "sillo_wire" - - -def _resolve(fullname: str) -> str | None: - """The real module *fullname* stands for, or ``None`` if it is not ours.""" - if fullname == ALIAS: - return REAL - if fullname.startswith(ALIAS + "."): - return REAL + fullname[len(ALIAS) :] - return None - - -class _AliasLoader(Loader): - """Hands back the already-imported target, so both names are one object. - - Loading the source a second time under the other name would give two - ``Hub`` classes and two sets of rooms — a broadcast would reach half of - them, and ``isinstance`` would disagree with itself. - """ - - def __init__(self, target: str) -> None: - self.target = target - - def create_module(self, spec: ModuleSpec): - import importlib - - return importlib.import_module(self.target) - - def exec_module(self, module) -> None: - """Already executed under its own name; nothing to run again.""" - - -class _AliasFinder(MetaPathFinder): - """Answers for ``sillo.wire`` and anything beneath it.""" - - def find_spec(self, fullname: str, path=None, target=None): - real = _resolve(fullname) - if real is None: - return None - try: - if find_spec(real) is None: - return None - except (ImportError, ValueError): - # The package is half-installed, or its parent is missing. Decline - # rather than raise: another finder may do better, and the import - # error a caller gets should be the ordinary one. - return None - - import importlib - - module = importlib.import_module(real) - spec = ModuleSpec(fullname, _AliasLoader(real)) - spec.submodule_search_locations = getattr(module, "__path__", None) - return spec - - -def install() -> bool: - """Register the finder. Returns whether it was newly added. - - Idempotent, because a ``.pth`` is not the only thing that may import this - module — a test does too, and a second finder would be dead weight on every - import in the process. - """ - if any(isinstance(finder, _AliasFinder) for finder in sys.meta_path): - return False - sys.meta_path.insert(0, _AliasFinder()) - return True - - -install() diff --git a/pyproject.toml b/pyproject.toml index f98bdb5..0571fbf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,21 +39,7 @@ Source = "https://github.com/sillohq/wire" dev = ["pytest>=8.0", "pytest-asyncio>=0.24", "pytest-cov>=5.0", "ruff>=0.6", "mypy>=1.11"] [tool.hatch.build.targets.wheel] -# `sillo-stubs` serves type checkers, which never run the import hook below. -packages = ["sillo_wire", "sillo-stubs"] - -# The bootstrap and its .pth land at the site-packages root. The .pth is what -# runs at interpreter startup and registers the `sillo.wire` alias; see -# _sillo_wire_bootstrap.py for why the package is not shipped into `sillo/`. -[tool.hatch.build.targets.wheel.force-include] -"_sillo_wire_bootstrap.py" = "_sillo_wire_bootstrap.py" -"sillo_wire.pth" = "sillo_wire.pth" - -# The same two files for `pip install -e .`, so the `sillo.wire` alias works in -# a development checkout and not only from a built wheel. -[tool.hatch.build.targets.editable.force-include] -"_sillo_wire_bootstrap.py" = "_sillo_wire_bootstrap.py" -"sillo_wire.pth" = "sillo_wire.pth" +packages = ["sillo_wire"] [tool.pytest.ini_options] asyncio_mode = "auto" @@ -61,11 +47,6 @@ asyncio_default_fixture_loop_scope = "function" testpaths = ["tests"] [tool.coverage.run] -# `_sillo_wire_bootstrap` is deliberately not measured. Its `.pth` imports it -# at interpreter startup, before coverage exists, so its `def` and `class` -# statements never run under the tracer and it reads as 65% however thoroughly -# it is tested. tests/test_alias.py covers it; the number would be an artefact -# of import timing rather than of testing. source = ["sillo_wire"] branch = true diff --git a/sillo-stubs/py.typed b/sillo-stubs/py.typed deleted file mode 100644 index b648ac9..0000000 --- a/sillo-stubs/py.typed +++ /dev/null @@ -1 +0,0 @@ -partial diff --git a/sillo-stubs/wire.pyi b/sillo-stubs/wire.pyi deleted file mode 100644 index 20d9edd..0000000 --- a/sillo-stubs/wire.pyi +++ /dev/null @@ -1,48 +0,0 @@ -"""Type stubs for ``sillo.wire``. - -The runtime alias is installed by ``_sillo_wire_bootstrap`` via a ``.pth``, and -a type checker never runs import hooks — so without this file -``from sillo.wire import Hub`` type-checks as a missing module even though it -imports fine. - -``py.typed`` next to this file contains the word ``partial`` (PEP 561), which -is what keeps these stubs additive: a checker uses them for ``sillo.wire`` and -falls back to the framework's own inline types for the rest of ``sillo``. -Without it, this directory would claim to describe all of ``sillo`` and hide -the types the framework ships. - -Nothing is declared here. Everything is re-exported from ``sillo_wire``, whose -inline annotations are the single source of truth. -""" - -from sillo_wire import Backlog as Backlog -from sillo_wire import DeliveryReport as DeliveryReport -from sillo_wire import Encoding as Encoding -from sillo_wire import Envelope as Envelope -from sillo_wire import Hub as Hub -from sillo_wire import MemoryBacklog as MemoryBacklog -from sillo_wire import NullBacklog as NullBacklog -from sillo_wire import Overflow as Overflow -from sillo_wire import Peer as Peer -from sillo_wire import PeerGone as PeerGone -from sillo_wire import RoomConsumer as RoomConsumer -from sillo_wire import RoomNotFound as RoomNotFound -from sillo_wire import WireError as WireError - -__version__: str - -__all__ = [ - "Backlog", - "DeliveryReport", - "Encoding", - "Envelope", - "Hub", - "MemoryBacklog", - "NullBacklog", - "Overflow", - "Peer", - "PeerGone", - "RoomConsumer", - "RoomNotFound", - "WireError", -] diff --git a/sillo_wire.pth b/sillo_wire.pth deleted file mode 100644 index 7dcddfc..0000000 --- a/sillo_wire.pth +++ /dev/null @@ -1 +0,0 @@ -import _sillo_wire_bootstrap diff --git a/sillo_wire/__pycache__/__init__.cpython-313.pyc b/sillo_wire/__pycache__/__init__.cpython-313.pyc deleted file mode 100644 index 3d47343..0000000 Binary files a/sillo_wire/__pycache__/__init__.cpython-313.pyc and /dev/null differ diff --git a/sillo_wire/__pycache__/backlog.cpython-313.pyc b/sillo_wire/__pycache__/backlog.cpython-313.pyc deleted file mode 100644 index 0ebbbea..0000000 Binary files a/sillo_wire/__pycache__/backlog.cpython-313.pyc and /dev/null differ diff --git a/sillo_wire/__pycache__/consumer.cpython-313.pyc b/sillo_wire/__pycache__/consumer.cpython-313.pyc deleted file mode 100644 index 21e0905..0000000 Binary files a/sillo_wire/__pycache__/consumer.cpython-313.pyc and /dev/null differ diff --git a/sillo_wire/__pycache__/envelope.cpython-313.pyc b/sillo_wire/__pycache__/envelope.cpython-313.pyc deleted file mode 100644 index d4bd74b..0000000 Binary files a/sillo_wire/__pycache__/envelope.cpython-313.pyc and /dev/null differ diff --git a/sillo_wire/__pycache__/errors.cpython-313.pyc b/sillo_wire/__pycache__/errors.cpython-313.pyc deleted file mode 100644 index c50f333..0000000 Binary files a/sillo_wire/__pycache__/errors.cpython-313.pyc and /dev/null differ diff --git a/sillo_wire/__pycache__/hub.cpython-313.pyc b/sillo_wire/__pycache__/hub.cpython-313.pyc deleted file mode 100644 index e5c97ca..0000000 Binary files a/sillo_wire/__pycache__/hub.cpython-313.pyc and /dev/null differ diff --git a/sillo_wire/__pycache__/peer.cpython-313.pyc b/sillo_wire/__pycache__/peer.cpython-313.pyc deleted file mode 100644 index d5d3250..0000000 Binary files a/sillo_wire/__pycache__/peer.cpython-313.pyc and /dev/null differ diff --git a/sillo_wire/__pycache__/policy.cpython-313.pyc b/sillo_wire/__pycache__/policy.cpython-313.pyc deleted file mode 100644 index 51be371..0000000 Binary files a/sillo_wire/__pycache__/policy.cpython-313.pyc and /dev/null differ diff --git a/sillo_wire/__pycache__/testing.cpython-313.pyc b/sillo_wire/__pycache__/testing.cpython-313.pyc deleted file mode 100644 index edbfa50..0000000 Binary files a/sillo_wire/__pycache__/testing.cpython-313.pyc and /dev/null differ diff --git a/tests/test_alias.py b/tests/test_alias.py deleted file mode 100644 index 0de5dfb..0000000 --- a/tests/test_alias.py +++ /dev/null @@ -1,115 +0,0 @@ -"""The `sillo.wire` alias, and the finder that provides it. - -The alias is what a `.pth` installs at interpreter startup. These tests drive -the finder directly rather than relying on that, so they hold under an editable -install too — where the `.pth` is present but the checkout is what is imported. -""" - -from __future__ import annotations - -import sys - -import pytest - -import _sillo_wire_bootstrap as bootstrap -import sillo_wire - - -@pytest.fixture -def finder(): - """A finder registered for the duration of one test.""" - added = bootstrap.install() - yield next(f for f in sys.meta_path if isinstance(f, bootstrap._AliasFinder)) - if added: - sys.meta_path[:] = [ - f for f in sys.meta_path if not isinstance(f, bootstrap._AliasFinder) - ] - - -class TestNameResolution: - def test_the_alias_itself(self): - assert bootstrap._resolve("sillo.wire") == "sillo_wire" - - def test_anything_beneath_it(self): - assert bootstrap._resolve("sillo.wire.hub") == "sillo_wire.hub" - assert bootstrap._resolve("sillo.wire.a.b") == "sillo_wire.a.b" - - @pytest.mark.parametrize( - "name", - [ - "sillo", # the framework itself is not ours - "sillo.responses", # nor any other part of it - "sillo_wire", # the real name needs no help - "sillowire", # a prefix match is not a package match - "wire", - ], - ) - def test_names_that_are_not_ours(self, name): - assert bootstrap._resolve(name) is None - - -class TestTheFinder: - def test_it_declines_names_it_does_not_own(self, finder): - assert finder.find_spec("json") is None - assert finder.find_spec("sillo.responses") is None - - def test_it_answers_for_the_alias(self, finder): - spec = finder.find_spec("sillo.wire") - assert spec is not None - assert spec.name == "sillo.wire" - # A package, so `sillo.wire.hub` can be found beneath it. - assert spec.submodule_search_locations is not None - - def test_it_declines_when_the_target_is_absent(self, finder, monkeypatch): - """Half an install should produce the ordinary import error, not one - from in here.""" - monkeypatch.setattr(bootstrap, "REAL", "a_package_that_is_not_installed") - assert finder.find_spec("sillo.wire") is None - - def test_it_declines_when_the_target_cannot_be_probed(self, finder, monkeypatch): - def explode(name): - raise ValueError("malformed spec") - - monkeypatch.setattr(bootstrap, "find_spec", explode) - assert finder.find_spec("sillo.wire") is None - - def test_the_loader_returns_the_one_module(self, finder): - """Not a second copy: two `Hub` classes would be two sets of rooms.""" - spec = finder.find_spec("sillo.wire") - assert spec.loader.create_module(spec) is sillo_wire - - def test_exec_module_does_not_re_run_it(self, finder): - spec = finder.find_spec("sillo.wire") - assert spec.loader.exec_module(sillo_wire) is None - - -class TestInstall: - def test_it_registers_once(self): - before = sum(isinstance(f, bootstrap._AliasFinder) for f in sys.meta_path) - added = bootstrap.install() - after = sum(isinstance(f, bootstrap._AliasFinder) for f in sys.meta_path) - try: - # Importing this module already installed one, so the second call - # is a no-op — a duplicate would sit on every import in the process. - assert after == max(before, 1) - assert added is False or before == 0 - finally: - if added: - sys.meta_path[:] = [ - f - for f in sys.meta_path - if not isinstance(f, bootstrap._AliasFinder) - ] - - -class TestTheAliasEndToEnd: - def test_it_imports_and_is_the_same_package(self, finder): - import sillo.wire - - assert sillo.wire is sillo_wire - assert sillo.wire.Hub is sillo_wire.Hub - - def test_submodules_resolve_through_it(self, finder): - from sillo.wire.hub import Hub - - assert Hub is sillo_wire.Hub