From 8563955687d81d3a28fdfbdeed624df9bb32642c Mon Sep 17 00:00:00 2001 From: subzeroid <143403577+subzeroid@users.noreply.github.com> Date: Mon, 1 Jun 2026 00:21:13 +0300 Subject: [PATCH] harden source distribution contents --- .../plans/aiograpi-direct-readonly-design.md | 113 ---- .../plans/aiograpi-direct-readonly-plan.md | 550 ------------------ .gitignore | 5 + TODOS.md | 19 - pyproject.toml | 20 + tests/test_sdist.py | 70 +++ uv.lock | 51 ++ 7 files changed, 146 insertions(+), 682 deletions(-) delete mode 100644 .github/plans/aiograpi-direct-readonly-design.md delete mode 100644 .github/plans/aiograpi-direct-readonly-plan.md delete mode 100644 TODOS.md create mode 100644 tests/test_sdist.py diff --git a/.github/plans/aiograpi-direct-readonly-design.md b/.github/plans/aiograpi-direct-readonly-design.md deleted file mode 100644 index 355f1b9..0000000 --- a/.github/plans/aiograpi-direct-readonly-design.md +++ /dev/null @@ -1,113 +0,0 @@ -# Read-only aiograpi Direct Design - -Issue: https://github.com/subzeroid/insto/issues/4 - -## Goal - -Expose the safe read-only subset of aiograpi Direct through insto without widening the product boundary into account automation. - -This first PR implements only: - -- `/direct [N]`: list recent Direct threads for the authenticated aiograpi account. -- `/direct-thread [N]`: show recent messages in one Direct thread. -- JSON export for both commands. - -The PR does not implement Direct search, saved collections, personal feed, private GraphQL rewrites, polling, notifications, or any write operation. - -## Product Boundary - -Direct support is read-only. The CLI must not expose commands for sending messages, reacting, deleting, unsending, marking seen, muting, approving requests, updating titles, uploading attachments, or sharing media/profile/story objects. - -This matters because insto is an OSINT read tool. Direct write actions turn it into an account automation tool with much higher account-risk and abuse potential. - -## Command UX - -`/direct [N]` lists threads. Default count is 20. It renders a compact table with: - -- thread id -- title or participant usernames -- participant usernames -- last activity timestamp -- message count returned by aiograpi for the thread preview -- pending, archived, muted, group flags - -`/direct-thread [N]` lists messages from a specific thread. Default count is 20. It renders: - -- timestamp -- sender user id -- item type -- text preview when text exists -- shared media/code markers when the SDK exposes them cleanly - -Both commands support `--json`. Both reject `--csv` through the existing non-flat export guard unless we explicitly add flat rows in a later PR. - -## Data Model - -Add DTOs to `insto.models`: - -- `DirectThread`: stable thread metadata plus participants and preview messages. -- `DirectMessage`: stable message metadata plus a read-only summary of content references. - -DTOs store only plain Python values. They never expose raw aiograpi objects above the backend layer. - -The message body is still useful OSINT data, so JSON export includes text. The backend and commands must not log raw Direct payloads or message text. - -## Backend Contract - -Add optional methods to `OSINTBackend`, not abstract methods: - -- `iter_direct_threads(limit: int | None = None)` -- `iter_direct_messages(thread_id: str, *, limit: int | None = None)` - -Default behavior raises `BackendError("needs aiograpi backend")`. This keeps HikerAPI and future non-Instagram backends from inheriting raw `NotImplementedError` behavior. - -`AiograpiBackend` implements the methods via: - -- `client.direct_threads(amount=limit, thread_message_limit=1)` -- `client.direct_messages(thread_id, amount=limit)` - -The implementation keeps lazy login and `_translate` error mapping unchanged. - -## Capability Gate - -Introduce a backend capability token for Direct reads, for example `direct_read`. - -The aiograpi backend advertises it. HikerAPI and fake production backend do not. Command dispatch then rejects `/direct` and `/direct-thread` on unsupported backends before making any backend call. - -Unit tests may enable the capability on `tests.fakes.FakeBackend` when testing command rendering. - -## Tests - -All normal tests stay offline. Live Instagram checks remain opt-in only. - -Coverage required in this PR: - -- DTO dataclass shape and `dataclasses.asdict` behavior. -- aiograpi mapper behavior for text messages, media-share messages, and empty/non-text messages. -- backend optional-method defaults raise typed `BackendError`. -- command capability gate rejects unsupported backends clearly. -- `/direct` renders thread rows using `FakeBackend`. -- `/direct-thread` renders message rows using `FakeBackend`. -- JSON export writes the expected envelope and stdout form. -- CSV rejection remains clear. - -## Docs - -Update: - -- `docs/cli-reference.md` -- `docs/backends.md` -- `docs/roadmap.md` - -Docs must state that Direct support requires `insto[aiograpi]`, logs into a real Instagram account, carries account-risk, and is read-only. - -README only changes if the public command table needs to include Direct in the top-level command surface. - -## Acceptance Criteria - -- `/direct` works in REPL and one-shot dispatch on aiograpi. -- `/direct-thread` works in REPL and one-shot dispatch on aiograpi. -- Unsupported backends fail with a clear user-facing missing-capability message. -- JSON export works for both commands. -- No Direct write operation appears in command registration, facade API, docs, or tests. -- `ruff check`, `ruff format --check`, `mypy insto`, `pytest --cov=insto --cov-fail-under=75`, and `mkdocs build --strict` pass. diff --git a/.github/plans/aiograpi-direct-readonly-plan.md b/.github/plans/aiograpi-direct-readonly-plan.md deleted file mode 100644 index 0960e4c..0000000 --- a/.github/plans/aiograpi-direct-readonly-plan.md +++ /dev/null @@ -1,550 +0,0 @@ -# Read-only aiograpi Direct Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Add read-only Direct thread and message commands for the aiograpi backend. - -**Architecture:** Add Direct DTOs in `insto.models`, optional read methods on `OSINTBackend`, mapper helpers for aiograpi Direct payloads, thin facade methods, and a new `insto.commands.direct` module registered from `insto.commands.__init__`. The command layer exports JSON and renders tables, while capability gating prevents unsupported backends from executing the commands. - -**Tech Stack:** Python dataclasses, async iterators, aiograpi 0.9.6, Rich rendering, pytest-asyncio, strict mypy, ruff. - ---- - -## File Structure - -- `insto/models.py`: add `DirectThread` and `DirectMessage` DTOs. -- `insto/backends/_base.py`: add optional Direct methods and import DTOs. -- `insto/backends/_aiograpi_map.py`: add mapper helpers for aiograpi Direct objects. -- `insto/backends/aiograpi.py`: advertise `direct_read`, call aiograpi Direct read methods, map results. -- `insto/service/facade.py`: add `direct_threads()` and `direct_messages()`. -- `insto/commands/direct.py`: add `/direct` and `/direct-thread`. -- `insto/commands/__init__.py`: import the new command module. -- `tests/fakes.py`: add Direct fixtures, errors, and iterators. -- `tests/test_models.py`: cover DTO shape. -- `tests/test_backend_contract.py`: cover optional default errors. -- `tests/test_aiograpi_map.py`: cover Direct mappers. -- `tests/test_commands_direct.py`: cover command render/export/capability behavior. -- `docs/cli-reference.md`, `docs/backends.md`, `docs/roadmap.md`, optionally `README.md`: document the new read-only commands. - -## Task 1: Direct DTOs - -**Files:** -- Modify: `insto/models.py` -- Test: `tests/test_models.py` - -- [ ] **Step 1: Write failing DTO tests** - -Add tests that import `DirectThread`, `DirectMessage`, and `User`, construct representative values, and assert `dataclasses.asdict()` returns stable plain dictionaries. - -Expected fields: - -```python -DirectMessage( - pk="m1", - thread_id="t1", - sender_pk="100", - timestamp=1_700_000_000, - item_type="text", - text="hello", - media_pk=None, - media_code=None, - link_url=None, -) - -DirectThread( - pk="t1", - title="Alice", - users=[User(pk="100", username="alice")], - last_activity_at=1_700_000_000, - message_count=1, - is_group=False, - is_pending=False, - is_archived=False, - is_muted=False, - messages=[message], -) -``` - -- [ ] **Step 2: Run the DTO tests and verify RED** - -Run: - -```bash -uv run pytest tests/test_models.py -q -``` - -Expected: import failure for `DirectThread` / `DirectMessage`. - -- [ ] **Step 3: Implement DTOs** - -Add dataclasses near the other Instagram DTOs in `insto/models.py`: - -```python -@dataclass(slots=True) -class DirectMessage: - """Read-only Direct message summary.""" - - pk: str - thread_id: str - sender_pk: str - timestamp: int - item_type: str = "" - text: str | None = None - media_pk: str | None = None - media_code: str | None = None - link_url: str | None = None - - -@dataclass(slots=True) -class DirectThread: - """Read-only Direct thread summary.""" - - pk: str - title: str - users: list[User] = field(default_factory=list) - last_activity_at: int = 0 - message_count: int = 0 - is_group: bool = False - is_pending: bool = False - is_archived: bool = False - is_muted: bool = False - messages: list[DirectMessage] = field(default_factory=list) -``` - -- [ ] **Step 4: Run DTO tests and commit** - -Run: - -```bash -uv run pytest tests/test_models.py -q -uv run mypy insto -``` - -Commit: - -```bash -git add insto/models.py tests/test_models.py -git commit -m "feat: add direct read DTOs" -``` - -## Task 2: Backend Contract and FakeBackend - -**Files:** -- Modify: `insto/backends/_base.py` -- Modify: `tests/fakes.py` -- Test: `tests/test_backend_contract.py` - -- [ ] **Step 1: Write failing contract tests** - -Add tests that call `OSINTBackend.iter_direct_threads()` and `iter_direct_messages()` through a minimal concrete test backend and assert `BackendError` with `needs aiograpi backend`. - -Add fake-backend tests that configure `direct_threads` and `direct_messages` data and assert paging respects `limit`. - -- [ ] **Step 2: Run contract tests and verify RED** - -Run: - -```bash -uv run pytest tests/test_backend_contract.py -q -``` - -Expected: missing methods or raw `NotImplementedError`. - -- [ ] **Step 3: Implement optional backend methods** - -In `OSINTBackend`, import `DirectMessage` and `DirectThread`, then add: - -```python -def iter_direct_threads(self, *, limit: int | None = None) -> AsyncIterator[DirectThread]: - """Iterate read-only Direct threads. Default requires aiograpi.""" - raise BackendError("needs aiograpi backend") - - -def iter_direct_messages( - self, thread_id: str, *, limit: int | None = None -) -> AsyncIterator[DirectMessage]: - """Iterate read-only Direct messages in one thread. Default requires aiograpi.""" - raise BackendError("needs aiograpi backend") -``` - -- [ ] **Step 4: Extend `tests.fakes.FakeBackend`** - -Add `iter_direct_threads` and `iter_direct_messages` error slots, fixture fields, request logging, and paged iterators. - -Use dictionaries keyed by thread id: - -```python -direct_threads: list[DirectThread] = field(default_factory=list) -direct_messages: dict[str, list[DirectMessage]] = field(default_factory=dict) -``` - -- [ ] **Step 5: Run contract tests and commit** - -Run: - -```bash -uv run pytest tests/test_backend_contract.py -q -uv run pytest tests/test_commands_base.py::test_dispatch_rejects_command_requiring_missing_capability -q -uv run mypy insto -``` - -Commit: - -```bash -git add insto/backends/_base.py tests/fakes.py tests/test_backend_contract.py -git commit -m "feat: add direct backend contract" -``` - -## Task 3: aiograpi Direct Mappers - -**Files:** -- Modify: `insto/backends/_aiograpi_map.py` -- Test: `tests/test_aiograpi_map.py` - -- [ ] **Step 1: Write failing mapper tests** - -Use lightweight objects with attributes matching aiograpi Direct models. Cover: - -- text message maps `text`. -- media share maps `media_pk` and `media_code` when present. -- non-text message maps `item_type` and leaves text fields `None`. -- thread maps users, title, flags, last activity timestamp, and preview messages. - -- [ ] **Step 2: Run mapper tests and verify RED** - -Run: - -```bash -uv run pytest tests/test_aiograpi_map.py -q -``` - -Expected: mapper function import failure. - -- [ ] **Step 3: Implement mapper helpers** - -Add: - -```python -def _direct_ts(raw: Any, attr: str) -> int: - value = getattr(raw, attr, None) - if hasattr(value, "timestamp"): - return int(value.timestamp()) - if isinstance(value, int | float): - return int(value) - return 0 - - -def _maybe_str(value: Any) -> str | None: - if value is None: - return None - return str(value) - - -def map_direct_message(raw: Any, *, thread_id: str | None = None) -> DirectMessage: - media = getattr(raw, "media_share", None) or getattr(raw, "clip", None) - link = getattr(raw, "link", None) - return DirectMessage( - pk=str(getattr(raw, "id", "")), - thread_id=str(getattr(raw, "thread_id", None) or thread_id or ""), - sender_pk=str(getattr(raw, "user_id", "") or ""), - timestamp=_direct_ts(raw, "timestamp"), - item_type=str(getattr(raw, "item_type", "") or ""), - text=getattr(raw, "text", None), - media_pk=_maybe_str(getattr(media, "pk", None) or getattr(media, "id", None)) - if media is not None - else None, - media_code=_maybe_str(getattr(media, "code", None)) if media is not None else None, - link_url=_maybe_str(getattr(link, "url", None)) if link is not None else None, - ) - - -def map_direct_thread(raw: Any) -> DirectThread: - messages = [map_direct_message(msg, thread_id=str(getattr(raw, "id", ""))) for msg in getattr(raw, "messages", [])] - users = [ - User( - pk=str(getattr(user, "pk", "")), - username=str(getattr(user, "username", "") or ""), - full_name=str(getattr(user, "full_name", "") or ""), - is_private=bool(getattr(user, "is_private", False) or False), - ) - for user in getattr(raw, "users", []) - ] - return DirectThread( - pk=str(getattr(raw, "id", None) or getattr(raw, "pk", "")), - title=str(getattr(raw, "thread_title", "") or ""), - users=users, - last_activity_at=_direct_ts(raw, "last_activity_at"), - message_count=len(messages), - is_group=bool(getattr(raw, "is_group", False)), - is_pending=bool(getattr(raw, "pending", False)), - is_archived=bool(getattr(raw, "archived", False)), - is_muted=bool(getattr(raw, "muted", False)), - messages=messages, - ) -``` - -Rules: - -- Convert `datetime` values to Unix seconds with `int(dt.timestamp())`. -- Prefer `raw.thread_id`, fallback to explicit `thread_id`, fallback to empty string. -- Convert `raw.user_id` to `sender_pk`, fallback to empty string. -- Extract `media_pk` and `media_code` from `media_share` or `clip` only when attributes exist. -- Extract `link_url` from `link.url` when present. -- Do not retain raw aiograpi objects. - -- [ ] **Step 4: Run mapper tests and commit** - -Run: - -```bash -uv run pytest tests/test_aiograpi_map.py -q -uv run mypy insto -``` - -Commit: - -```bash -git add insto/backends/_aiograpi_map.py tests/test_aiograpi_map.py -git commit -m "feat: map aiograpi direct payloads" -``` - -## Task 4: Aiograpi Backend Methods - -**Files:** -- Modify: `insto/backends/aiograpi.py` -- Test: `tests/test_aiograpi_map.py` and existing backend import tests - -- [ ] **Step 1: Write backend behavior tests if a client stub exists** - -If the current test suite has aiograpi backend client stubs, add tests proving: - -- `iter_direct_threads(limit=3)` calls `client.direct_threads(amount=3, thread_message_limit=1)`. -- `iter_direct_messages("123", limit=5)` calls `client.direct_messages(123, amount=5)`. -- errors pass through `_translate`. - -If there is no existing client-stub pattern, keep this coverage in mapper and command tests to avoid inventing a large test harness in this PR. - -- [ ] **Step 2: Implement capability and methods** - -In `AiograpiBackend`: - -```python -capabilities = frozenset({"followed", "direct_read"}) -``` - -Add async generator methods: - -```python -async def iter_direct_threads( - self, *, limit: int | None = None -) -> AsyncIterator[DirectThread]: - amount = limit if limit is not None and limit > 0 else 20 - raws = await self._call( - lambda: self._client.direct_threads(amount=amount, thread_message_limit=1) - ) - for raw in raws: - yield map_direct_thread(raw) - - -async def iter_direct_messages( - self, thread_id: str, *, limit: int | None = None -) -> AsyncIterator[DirectMessage]: - amount = limit if limit is not None and limit > 0 else 20 - raws = await self._call(lambda: self._client.direct_messages(int(thread_id), amount=amount)) - for raw in raws: - yield map_direct_message(raw, thread_id=thread_id) -``` - -Reject non-numeric thread ids with `BackendError(f"invalid direct thread id: {thread_id!r}")`. - -- [ ] **Step 3: Run backend-adjacent tests and commit** - -Run: - -```bash -uv run pytest tests/test_aiograpi_map.py tests/test_backend_contract.py -q -uv run mypy insto -``` - -Commit: - -```bash -git add insto/backends/aiograpi.py tests/test_aiograpi_map.py -git commit -m "feat: wire aiograpi direct read methods" -``` - -## Task 5: Facade and Commands - -**Files:** -- Modify: `insto/service/facade.py` -- Create: `insto/commands/direct.py` -- Modify: `insto/commands/__init__.py` -- Test: `tests/test_commands_direct.py` - -- [ ] **Step 1: Write failing command tests** - -Create `tests/test_commands_direct.py` with tests for: - -- `/direct 2` renders two threads. -- `/direct-thread t1 2` renders two messages. -- `/direct --json` writes `output/direct/direct.json` or equivalent default path chosen by `default_export_path`. -- `/direct --json -` writes a valid JSON envelope to stdout. -- `/direct --csv -` is rejected by the existing CSV guard. -- unsupported backend without `direct_read` fails before backend calls. - -- [ ] **Step 2: Run command tests and verify RED** - -Run: - -```bash -uv run pytest tests/test_commands_direct.py -q -``` - -Expected: unknown command `/direct`. - -- [ ] **Step 3: Add facade methods** - -Add to `OsintFacade`: - -```python -async def direct_threads(self, *, limit: int = 20) -> list[DirectThread]: - return [t async for t in self.backend.iter_direct_threads(limit=limit)] - - -async def direct_messages(self, thread_id: str, *, limit: int = 20) -> list[DirectMessage]: - return [m async for m in self.backend.iter_direct_messages(thread_id, limit=limit)] -``` - -- [ ] **Step 4: Add direct command module** - -Implement `insto/commands/direct.py` with: - -- `_add_direct_args(parser)`: optional count default 20. -- `_add_direct_thread_args(parser)`: `thread_id` plus optional count default 20. -- `_resolve_count(ctx, default=20)`: global `--limit` wins. -- `direct_cmd`: `@command("direct", "List read-only Direct threads (aiograpi only)", add_args=_add_direct_args, requires=("direct_read",))`. -- `direct_thread_cmd`: `@command("direct-thread", "Show read-only Direct messages for one thread (aiograpi only)", add_args=_add_direct_thread_args, requires=("direct_read",))`. - -Use `dataclasses.asdict()` for JSON export. - -- [ ] **Step 5: Register command module** - -Add to `insto/commands/__init__.py`: - -```python -from insto.commands import direct as _direct # noqa: F401 (registers commands) -``` - -- [ ] **Step 6: Run command tests and commit** - -Run: - -```bash -uv run pytest tests/test_commands_direct.py tests/test_commands_base.py -q -uv run mypy insto -``` - -Commit: - -```bash -git add insto/service/facade.py insto/commands/direct.py insto/commands/__init__.py tests/test_commands_direct.py -git commit -m "feat: add read-only direct commands" -``` - -## Task 6: Docs and Final Verification - -**Files:** -- Modify: `docs/cli-reference.md` -- Modify: `docs/backends.md` -- Modify: `docs/roadmap.md` -- Modify: `README.md` if the command table should include Direct - -- [ ] **Step 1: Update docs** - -Document: - -- `/direct [N]` -- `/direct-thread [N]` -- aiograpi-only requirement -- read-only boundary -- account-ban risk -- no Direct write operations - -- [ ] **Step 2: Run docs build** - -Run: - -```bash -uv run --extra docs mkdocs build --strict --site-dir /tmp/insto-mkdocs-site -``` - -Expected: exit code 0. Existing Material for MkDocs warning is acceptable if the build exits 0. - -- [ ] **Step 3: Run full verification** - -Run: - -```bash -uv run ruff check -uv run ruff format --check -uv run mypy insto -uv run pytest --cov=insto --cov-fail-under=75 -uv run --extra docs mkdocs build --strict --site-dir /tmp/insto-mkdocs-site -``` - -- [ ] **Step 4: Guardrail scan for Direct write operations** - -Run: - -```bash -rg -n "direct_(send|answer|delete|unsend|like|unlike|seen|mute|approve|hide|create|update|upload|share)|message_seen|mark_unread|send_seen" insto docs tests -``` - -Expected: no production command exposure. Mapper tests may mention forbidden method names only if asserting they are absent. - -- [ ] **Step 5: Commit docs** - -Commit: - -```bash -git add docs/cli-reference.md docs/backends.md docs/roadmap.md README.md -git commit -m "docs: document read-only direct commands" -``` - -## Task 7: PR Prep - -**Files:** -- No source changes unless verification finds issues. - -- [ ] **Step 1: Push branch** - -Run: - -```bash -git push -u origin feat/aiograpi-direct-readonly -``` - -- [ ] **Step 2: Create PR** - -Use title: - -```text -feat: add read-only aiograpi direct commands -``` - -PR body must include: - -- Closes #4 partially. -- Scope: `/direct`, `/direct-thread`, JSON export, read-only aiograpi Direct. -- Non-goals: Direct writes, Direct search, saved/feed, private GraphQL rewrites. -- Verification commands and outputs. - -- [ ] **Step 3: Wait for CI** - -Run: - -```bash -gh pr checks --watch --fail-fast -``` - -Expected: CI success. diff --git a/.gitignore b/.gitignore index 9369cad..95dbd0c 100644 --- a/.gitignore +++ b/.gitignore @@ -3,13 +3,18 @@ docs/plans/ docs/internal/ .context/ .claude/ +.github/plans/ +.ralphex/ CLAUDE.md +TODOS.md +marketing/ # Python __pycache__/ *.py[cod] *.egg-info/ .venv/ +dist/ # Tooling caches .pytest_cache/ diff --git a/TODOS.md b/TODOS.md deleted file mode 100644 index 7855185..0000000 --- a/TODOS.md +++ /dev/null @@ -1,19 +0,0 @@ -# TODOS - -## Parallelize startup quota-refresh and target-resolve - -- **What:** At REPL startup, `_safe_refresh_quota(facade)` and the new - `_safe_set_startup_target(...)` run sequentially, each bounded to ~2s. Run - them concurrently with `asyncio.gather` so a slow network costs ~2s total - instead of ~4s. -- **Why:** Cuts worst-case startup latency on a slow/dead network roughly in - half. No effect on a fast network. -- **Pros:** Faster cold-start when the network is degraded. -- **Cons:** Adds concurrency to the startup path; the banner already waits on - quota, so the resolve must complete before `repl.run()` draws the banner — - gather'ing them is fine but the ordering/error handling needs care. -- **Context:** Introduced by the 2026-05-27 startup-target feature - (`docs/superpowers/specs/2026-05-27-startup-target-in-header-design.md`). - See `run_repl._main()` in `insto/repl.py`. Deferred from eng review as P3 — - marginal benefit, conflicts with the right-sized-diff goal for that PR. -- **Depends on / blocked by:** Lands after the startup-target feature merges. diff --git a/pyproject.toml b/pyproject.toml index 749e7c1..ab9cb32 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -55,11 +55,31 @@ insto = "insto.cli:main" requires = ["hatchling"] build-backend = "hatchling.build" +[tool.hatch.build.targets.sdist] +exclude = [ + "/.context", + "/.github", + "/.ralphex", + "/.venv", + "/CLAUDE.md", + "/TODOS.md", + "/docs/demo.gif", + "/docs/demo.tape", + "/docs/internal", + "/docs/plans", + "/docs/social-preview.png", + "/docs/superpowers", + "/marketing", + "/output", +] + [tool.hatch.build.targets.wheel] packages = ["insto"] [dependency-groups] dev = [ + "build>=1.2", + "hatchling>=1.25", "pytest>=8.0", "pytest-asyncio>=0.23", "pytest-cov>=5.0", diff --git a/tests/test_sdist.py b/tests/test_sdist.py new file mode 100644 index 0000000..c672b41 --- /dev/null +++ b/tests/test_sdist.py @@ -0,0 +1,70 @@ +"""Regression checks for source distribution contents.""" + +from __future__ import annotations + +import subprocess +import sys +import tarfile +import tomllib +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent + +FORBIDDEN_SDIST_PATHS = ( + ".context/", + ".github/", + ".ralphex/", + ".venv/", + "CLAUDE.md", + "TODOS.md", + "docs/demo.gif", + "docs/demo.tape", + "docs/internal/", + "docs/plans/", + "docs/social-preview.png", + "docs/superpowers/", + "marketing/", + "output/", +) + + +def test_sdist_excludes_private_and_non_package_paths(tmp_path: Path) -> None: + dist_dir = tmp_path / "dist" + + subprocess.run( + [ + sys.executable, + "-m", + "build", + "--sdist", + "--no-isolation", + "--outdir", + str(dist_dir), + ], + cwd=ROOT, + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + ) + + (sdist_path,) = dist_dir.glob("*.tar.gz") + with tarfile.open(sdist_path, "r:gz") as archive: + names = archive.getnames() + + package_root = sdist_path.name.removesuffix(".tar.gz") + "/" + relative_names = [name.removeprefix(package_root) for name in names] + + for forbidden in FORBIDDEN_SDIST_PATHS: + assert all( + name != forbidden.rstrip("/") and not name.startswith(forbidden) + for name in relative_names + ) + + +def test_sdist_exclude_config_covers_private_and_non_package_paths() -> None: + pyproject = tomllib.loads((ROOT / "pyproject.toml").read_text()) + excludes = set(pyproject["tool"]["hatch"]["build"]["targets"]["sdist"]["exclude"]) + + for forbidden in FORBIDDEN_SDIST_PATHS: + assert f"/{forbidden.rstrip('/')}" in excludes diff --git a/uv.lock b/uv.lock index 2fd2e7a..63b208a 100644 --- a/uv.lock +++ b/uv.lock @@ -69,6 +69,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/21/f8/d02f650c47d05034dcd6f9c8cf94f39598b7a89c00ecda0ecb2911bc27e9/backrefs-6.2-py39-none-any.whl", hash = "sha256:664e33cd88c6840b7625b826ecf2555f32d491800900f5a541f772c485f7cda7", size = 381077, upload-time = "2026-02-16T19:10:13.74Z" }, ] +[[package]] +name = "build" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "os_name == 'nt'" }, + { name = "packaging" }, + { name = "pyproject-hooks" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/e0/df5e171f685f82f37b12e1f208064e24244911079d7b767447d1af7e0d70/build-1.5.0.tar.gz", hash = "sha256:302c22c3ba2a0fd5f3911918651341ebb3896176cbdec15bd421f80b1afc7647", size = 89796, upload-time = "2026-04-30T03:18:25.17Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/fe/6bea5c9162869c5beba5d9c8abbed835ec85bf1ec1fba05a3822325c45f3/build-1.5.0-py3-none-any.whl", hash = "sha256:13f3eecb844759ab66efec90ca17639bbf14dc06cb2fdf37a9010322d9c50a6f", size = 26018, upload-time = "2026-04-30T03:18:23.644Z" }, +] + [[package]] name = "certifi" version = "2026.4.22" @@ -322,6 +336,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, ] +[[package]] +name = "hatchling" +version = "1.29.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, + { name = "pathspec" }, + { name = "pluggy" }, + { name = "trove-classifiers" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cf/9c/b4cfe330cd4f49cff17fd771154730555fa4123beb7f292cf0098b4e6c20/hatchling-1.29.0.tar.gz", hash = "sha256:793c31816d952cee405b83488ce001c719f325d9cda69f1fc4cd750527640ea6", size = 55656, upload-time = "2026-02-23T19:42:06.539Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d3/8a/44032265776062a89171285ede55a0bdaadc8ac00f27f0512a71a9e3e1c8/hatchling-1.29.0-py3-none-any.whl", hash = "sha256:50af9343281f34785fab12da82e445ed987a6efb34fd8c2fc0f6e6630dbcc1b0", size = 76356, upload-time = "2026-02-23T19:42:05.197Z" }, +] + [[package]] name = "hikerapi" version = "1.7.9" @@ -435,6 +464,8 @@ docs = [ [package.dev-dependencies] dev = [ + { name = "build" }, + { name = "hatchling" }, { name = "mypy" }, { name = "pytest" }, { name = "pytest-asyncio" }, @@ -460,6 +491,8 @@ provides-extras = ["completion", "aiograpi", "docs"] [package.metadata.requires-dev] dev = [ + { name = "build", specifier = ">=1.2" }, + { name = "hatchling", specifier = ">=1.25" }, { name = "mypy", specifier = ">=1.11" }, { name = "pytest", specifier = ">=8.0" }, { name = "pytest-asyncio", specifier = ">=0.23" }, @@ -1280,6 +1313,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f7/27/a2fc51a4a122dfd1015e921ae9d22fee3d20b0b8080d9a704578bf9deece/pymdown_extensions-10.21.2-py3-none-any.whl", hash = "sha256:5c0fd2a2bea14eb39af8ff284f1066d898ab2187d81b889b75d46d4348c01638", size = 268901, upload-time = "2026-03-29T15:01:53.244Z" }, ] +[[package]] +name = "pyproject-hooks" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/82/28175b2414effca1cdac8dc99f76d660e7a4fb0ceefa4b4ab8f5f6742925/pyproject_hooks-1.2.0.tar.gz", hash = "sha256:1e859bd5c40fae9448642dd871adf459e5e2084186e8d2c2a79a824c970da1f8", size = 19228, upload-time = "2024-09-29T09:24:13.293Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl", hash = "sha256:9e5c6bfa8dcc30091c74b0cf803c81fdd29d94f01992a7707bc97babb1141913", size = 10216, upload-time = "2024-09-29T09:24:11.978Z" }, +] + [[package]] name = "pytest" version = "9.0.3" @@ -1548,6 +1590,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374, upload-time = "2026-02-03T17:35:50.982Z" }, ] +[[package]] +name = "trove-classifiers" +version = "2026.5.22.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/86/b6/1c41aa221b157b624ea1a72e975404ef228724d249011ee411ac211a615e/trove_classifiers-2026.5.22.10.tar.gz", hash = "sha256:5477e9974e91904fb2cfa4a7581ab6e2f30c2c38d847fd00ed866080748101d5", size = 17061, upload-time = "2026-05-22T10:17:28.99Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c9/02/9a14d3048ffa4f45b7c60956a9b22688dd925d6de50f6baf7e55f3664942/trove_classifiers-2026.5.22.10-py3-none-any.whl", hash = "sha256:01fe864225726e03efb843827ecabfe319fc4dee8dd66d65b8996cb09be46e2c", size = 14225, upload-time = "2026-05-22T10:17:27.569Z" }, +] + [[package]] name = "typing-extensions" version = "4.15.0"