Skip to content

Consolidate sync + async clients via unasync codegen (httpx-only)#298

Open
sbneto wants to merge 49 commits into
developfrom
feature/httpx-shared-base
Open

Consolidate sync + async clients via unasync codegen (httpx-only)#298
sbneto wants to merge 49 commits into
developfrom
feature/httpx-shared-base

Conversation

@sbneto
Copy link
Copy Markdown
Contributor

@sbneto sbneto commented May 21, 2026

TL;DR

Consolidates the sync + async clients around a three-layer architecture and unasync codegen. One canonical async source, one generated sync mirror; both share a pure description layer and a pure response parser.

  • Layer 1 — pure / shared (core.py, hand-written, no I/O): PolyswarmRequest (@dataclass descriptor), parse_response (pure function), BaseJsonResource, Hashable, HttpxResponseAdapter, helpers.
  • Layer 2 — transport (aio/session.py canonical → session.py generated): one session class per transport owns an httpx client and exposes the SDK's two I/O methods, execute(request) and upload_file(url, artifact, …). No other module reads or writes HTTP.
  • Layer 3 — client (aio/api.py canonical → api.py generated): PolySwarmAsyncAPI / PolyswarmAPI own a session, accept session= for injection, drive pagination by cloning descriptors via dataclasses.replace.

requests is dropped as a runtime dependency. Customization is via session subclass + PolyswarmAPI(session=…), replacing the 3.x module-level monkey-patch pattern.

Architecture

src/polyswarm_api/
├── core.py                  ← hand-written, shared. PolyswarmRequest dataclass,
│                              parse_response pure function, BaseJsonResource,
│                              Hashable, HttpxResponseAdapter, helpers
├── aio/                     ← CANONICAL (hand-written async)
│   ├── session.py           ← AsyncPolyswarmSession (execute, upload_file)
│   ├── api.py               ← PolySwarmAsyncAPI — every endpoint as async def
│   └── __init__.py          ← public re-export shim
├── session.py               ← GENERATED sync mirror of aio/session.py
├── api.py                   ← GENERATED sync mirror of aio/api.py
├── resources.py             ← resource classes; builders return PolyswarmRequest
├── exceptions.py            ← unchanged hierarchy
├── settings.py              ← defaults
└── __init__.py              ← public surface

scripts/regenerate_sync.py   ← unasync driver + post-processing

scripts/regenerate_sync.py runs unasync with a rename map (AsyncPolyswarmSessionPolyswarmSession, AsyncClientClient, asynciotime, etc.), patches the one divergent block (the engines property), dedupes imports, prepends a # DO NOT EDIT header, and runs ruff format for stable output. CI (test-unasync-mirror) reruns the script and git diff --exit-code to reject stale mirrors. Pre-commit runs it locally.

Detailed contracts

Live under specs/. Each spec is independently readable and opens with Scope + Invariants:

How a method body works on both transports

Canonical async (aio/api.py):

async def metadata_mapping(self):
    logger.info('Retrieving the metadata mapping')
    return await self._single(
        {'method': 'GET',
         'url': f'{self.uri}{resources.MetadataMapping.RESOURCE_ENDPOINT}'},
        result_parser=resources.MetadataMapping,
    )

Generated sync (api.py):

def metadata_mapping(self):
    logger.info('Retrieving the metadata mapping')
    return self._single(
        {'method': 'GET',
         'url': f'{self.uri}{resources.MetadataMapping.RESOURCE_ENDPOINT}'},
        result_parser=resources.MetadataMapping,
    )

Caller surface unchanged:

sync_result = sync_api.metadata_mapping()
async_result = await async_api.metadata_mapping()

Multi-step methods (submit, download, report_download, sandbox_file, etc.) are ordinary async def methods on the canonical side — unasync handles the mirror.

The one escape hatch

@property def engines(self): is the single site where sync and async legitimately need divergent code. Python properties can't await, so the async version raises AttributeError; the sync version is a working cached property. scripts/regenerate_sync.py recognises the canonical AttributeError block by exact text and patches in the working sync property after unasync runs. If the canonical block drifts, the script fails fast.

Public surface

from polyswarm_api import PolyswarmAPI, PolyswarmSession             # sync (generated)
from polyswarm_api.aio import PolySwarmAsyncAPI, AsyncPolyswarmSession  # async (canonical)
from polyswarm_api.core import PolyswarmRequest, parse_response, BaseJsonResource
from polyswarm_api import resources, exceptions

Customization is per-instance, via session injection (full migration table + worked examples in specs/05-downstream-contract.md):

class CustomSession(AsyncPolyswarmSession):
    async def upload_file(self, upload_url, artifact, attempts=3, **kwargs):
        # custom retry / credentials / tracing
        return await super().upload_file(upload_url, artifact, attempts, **kwargs)

api = PolySwarmAsyncAPI(session=CustomSession(api_key, verify=False))

The [async] extra in pyproject.toml stays as an empty list so downstream polyswarm-api[async] pins keep parsing.

Breaking changes (4.0 surface)

These are documented in specs/05-downstream-contract.md §"Backward compatibility — what changes". A version bump to 4.0.0 will land on the eventual develop → master release PR, not in this feature PR (per AGENTS.md gitflow).

  • Module-level upload callables removed. polyswarm_api.aio.upload.async_upload_file / async_upload_logo and their sync mirrors are gone. Customization moves to subclassing the session.
  • Resource-instance upload methods removed. LocalArtifact.upload_file(artifact), SandboxTask.upload_file(artifact) are gone. Replace with await api.session.upload_file(instance.upload_url, artifact).
  • PolyswarmRequest is a @dataclass. Old: PolyswarmRequest(api, request_dict, result_parser=cls, **parser_kwargs). New: PolyswarmRequest(api=api, method='…', url='…', params=…, json=…, headers=…, result_parser=cls, parser_kwargs={…}). No .execute() method — the session executes it.
  • parse_response is a pure function at polyswarm_api.core.parse_response. The old PolyswarmRequest.parse_result(...) method is gone.
  • Session has two I/O methods, not three. execute(request), upload_file(url, artifact, …). The Authorization-strip + retry behaviour is shared by both via _put_off_domain. There is no upload_logo on the session — the report-template logo upload PUTs to the authenticated PolySwarm endpoint via session.execute, not a pre-signed S3 URL.

Tests

  • 118 / 118 pass.
  • New test/core_test.py adds 37 pure-unit tests for the dataclass, the parser (HEAD / 2xx / 204 / 404 / 422 / 429 / 500 / non-JSON 404 / non-JSON 500 / fire-and-forget), a sampling of resource builders, hash validators, and HttpxResponseAdapter. No httpx, no async, no fixtures.
  • async_client_test.py updated in three places to drive the new session.execute / session.upload_file surface (the tests that previously poked the module-level upload helpers or session.request).
  • VCR cassettes unchanged (matcher already on ['method', 'scheme', 'host', 'port', 'path', 'query'] — cassettes replay cleanly through the new transport).
  • Codegen verified byte-stable across two consecutive runs.

Outstanding follow-ups

Tracked in specs/99-open-questions.md:

  • Rolling the parametrised ClientTestCase harness out from metadata_field_properties_test.py to client_scan_test.py / async_client_test.py — mechanical per-test rewrite.
  • Async-side cassettes for download / exists methods missing — e2e was returning 500s at record time.
  • sandbox_providers executed-request quirk — preserved for backward compatibility; decide whether to convert to a parsed resource list in a future major.
  • sandbox_url finalize-param inconsistency (sandbox_task_id vs id) — preserved pending cassette.
  • VCR-off CI matrix — invariant says tests must pass against the live e2e with VCR off, but no CI job exercises that today.

Gitflow

Base = develop ✅. No version bump in this PR ✅ — __init__.py stays at 3.21.0, pyproject.toml stays at 3.21.0; the bump to 4.0.0 happens at the develop → master step when the maintainer cuts the release.

sbneto added 9 commits May 21, 2026 14:03
…lt via inheritance

Phase 1 of the sync+async consolidation tracked by DN-8225.

What changes:

* New ``polyswarm_api._base.PolyswarmAPIBase`` holds the shared
  constructor, instance state, and ``__repr__``. Both
  ``polyswarm_api.api.PolyswarmAPI`` (sync) and
  ``polyswarm_api.aio.PolySwarmAsyncAPI`` (async) now subclass it.
* ``aio.core.AsyncPolyswarmRequest`` inherits from sync
  ``core.PolyswarmRequest``. ``parse_result``, ``_extract_json_body``,
  and ``_bad_status_message`` are no longer duplicated — they live on
  the sync request and the async subclass inherits them. Only
  ``execute``, ``consume_results``, and ``next_page`` are overridden
  with their async-specific bodies.
* Both API clients expose new ``_single`` / ``_paginate`` / ``_sleep``
  hooks declared on the base. Async ``_single`` / ``_paginate`` are
  polymorphic: they accept either a pre-built
  ``AsyncPolyswarmRequest`` / ``PolyswarmRequest`` (the Phase 2 style)
  or a legacy ``(request_dict, result_parser=)`` pair (still used by
  every endpoint in this file). The legacy ``_exec`` / ``_generate``
  helpers stay as thin wrappers around ``_paginate``.

What doesn't change yet (Phase 2 follow-ups, same DN-8225):

* Sync transport is still ``requests`` via ``PolyswarmSession``. The
  migration to ``httpx.Client`` lands with the bulk endpoint
  consolidation.
* Endpoint methods (~110 per class) are still defined twice. Each one
  becomes a single ``return self._single(resources.X.method(...))``
  on ``PolyswarmAPIBase`` once the resource classmethods are switched
  from auto-execute to returning unexecuted requests.
* ``requests`` is still a runtime dep (and is still used directly by
  ``resources.py`` for S3 uploads).
* No version bump per the repo's gitflow rule (``AGENTS.md``).

All 68 existing tests pass.
…g onto base

Migrate the simplest endpoint family — ``metadata_mapping`` and the
four ``metadata_field_properties_*`` CRUD methods — onto
``PolyswarmAPIBase``. The duplicate bodies in ``api.py`` (5 sync
methods, ~50 LoC) and ``aio/__init__.py`` (5 async methods, ~60 LoC)
are deleted; the base method is what both clients now expose.

Demonstrates the trick:

    class PolyswarmAPIBase:
        def metadata_field_properties_list(self):
            return self._paginate({...}, result_parser=...)

    # sync subclass: ``_paginate`` is a generator function.
    #   for x in api.metadata_field_properties_list(): ...
    # async subclass: ``_paginate`` is an async generator function.
    #   async for x in api.metadata_field_properties_list(): ...

The base method body is sync-shaped — no ``async def`` — and returns
whatever ``self._paginate(...)`` returned. Each subclass's
``_paginate`` produces the right iterator flavour; the caller picks
the matching iteration syntax.

This is the pattern Phase 2 (DN-8225 follow-up) will apply to the
remaining ~110 endpoint methods.

All 68 tests still pass.
Introduce a ``ClientTestCase`` base class with an ``__init_subclass__``
hook that auto-emits ``<Name>Sync`` and ``<Name>Async`` sibling classes
for every test class. The original is hidden from pytest via
``__test__ = False`` — only the parametrised siblings run.

The same test methods now exercise both clients:

* Sync sibling: instantiates ``PolyswarmAPI``, mocks via ``responses``.
* Async sibling: wraps ``PolySwarmAsyncAPI`` in an ``_AsyncToSync``
  facade so unittest-style test bodies (``result = self.api.foo(...)``)
  stay unchanged; mocks via ``respx``.

A small ``_MockBoundary`` helper unifies the two mock libraries behind
``mock.add(method, url, json=...)`` — tests don't care which transport
they're hitting.

Test count goes from 4 to 8 for ``metadata_field_properties_test.py``
(4 endpoints x sync + async). Full suite: 68 -> 72.

Same pattern lands on the rest of the test files as Phase 2 endpoints
migrate to the base.
Add three new sections to AGENTS.md (which CLAUDE.md symlinks to):

* **Layout** — describe the new ``_base.py`` ``PolyswarmAPIBase`` plus
  how sync / async clients subclass it and where the request/response
  machinery lives now (``AsyncPolyswarmRequest`` inherits from
  ``PolyswarmRequest``).

* **Sync + async via shared base** — explain the single-method-body
  trick (``def method(self, ...): return self._single(...)`` works for
  both clients because async ``_single`` returns a coroutine the base
  method passes through). Note the migration status (DN-8225 Phase 2
  remaining for ~110 endpoints). Cross-reference the worked akm
  example in ``/home/sam/repos/api-key-management``.

* **VCR cassette workflow** — capture the delete-to-rerecord pattern,
  the ``match_on=['method', 'uri']`` rule that lets the same cassette
  serve both ``requests`` and ``httpx``, and the workspace invariant
  that tests must pass with VCR off.

Also update **When adding a new resource** to point at the new base
class as the home for endpoint methods — no more dual-defining in
``api.py`` and ``aio/__init__.py``.
Both clients now use httpx end-to-end — there is no longer a sync/async
split at the HTTP library layer.

What changes:

* ``polyswarm_api.core.PolyswarmSession`` becomes a thin wrapper over
  ``httpx.Client`` instead of subclassing ``requests.Session``. Same
  ``request()`` / ``verify`` / ``headers`` / ``close()`` surface that
  ``PolyswarmRequest`` already uses. Retries, auth header, user agent
  preserved.
* ``polyswarm_api.core.HttpxResponseAdapter`` (formerly
  ``aio.core._HttpxResponseAdapter``) moves to the shared module. Both
  sync ``PolyswarmRequest.execute`` and async
  ``AsyncPolyswarmRequest.execute`` now wrap non-JSON responses through
  it so file-download parsers (``LocalArtifact``, etc.) still see the
  ``iter_content`` surface they expect.
* ``polyswarm_api.resources.LocalArtifact.upload_file`` (and the
  duplicate in ``SampleBundle.upload_file``) call ``httpx.put`` instead
  of ``requests.put``. The empty-file workaround comment and dead-code
  branch are dropped — we send ``content=b''`` explicitly.
* ``_normalise_bool_params`` lives in ``core.py`` and is invoked by
  both sessions. ``requests`` serialised Python booleans as
  ``str(bool)`` (``'True'`` / ``'False'``); httpx writes them
  lowercase (``'true'`` / ``'false'``). Existing VCR cassettes were
  recorded against the requests format, so we normalise on the way out.

Tests:

* ``test/client_scan_test.py::test_resolve_engine_name`` was mocked via
  ``responses`` (which only intercepts ``requests``). Switched to
  ``respx`` since both clients are now httpx-backed.
* ``test/metadata_field_properties_test.py`` similarly drops the
  ``responses`` arm of its ``_MockBoundary`` — ``respx`` handles both
  client kinds.

All 72 tests still pass against the live e2e stack (cassettes replay
without re-recording).
No code in ``polyswarm_api`` imports ``requests`` anymore (the sync
transport migrated to httpx and the S3 upload helpers in
``resources.py`` now use ``httpx.put``). Promote ``httpx`` from the
``[async]`` extra to the core ``dependencies``.

The ``[async]`` extra is kept as an empty list so downstream consumers
that pin ``polyswarm-api[async]`` (polykg, neonscan, polyswarm-cli)
keep working unchanged.
…ync API routes through _single/_paginate

Step 2 of the bulk endpoint migration tracked by DN-8225.

* ``BaseJsonResource.create`` / ``get`` / ``head`` / ``update`` / ``delete`` /
  ``list`` and every per-resource classmethod in ``resources.py`` now
  return an UNEXECUTED ``PolyswarmRequest``. The ``.execute()`` chain
  moves up to the API client, so a single resource builder works for
  both sync and async transports.
* ``PolyswarmAPIBase._coerce_request`` rebuilds a ``PolyswarmRequest``
  as the subclass's ``_request_cls`` (sync ``PolyswarmRequest`` or
  async ``AsyncPolyswarmRequest``). Sync and async ``_single`` /
  ``_paginate`` now accept either an unexecuted request or a
  request-parameters dict.
* All ~100 ``resources.X.method(self, …).result()`` chains in
  ``api.py`` rewritten to ``self._single(resources.X.method(self, …))``
  via a small Python script. Six resource-instance chains
  (``task.download_zip(…).result()`` etc.) updated by hand.
* ``sandbox_providers`` keeps its previous quirk of returning the
  executed ``PolyswarmRequest`` itself so callers can read ``.json``.

This is purely a sync-side wiring change. The async client still has
its own duplicate endpoint methods; the next commit hoists everything
onto ``PolyswarmAPIBase`` and deletes the async duplicates.

72 / 72 tests pass.
This is the bulk consolidation step. The shared base class now holds
every endpoint method; both ``PolyswarmAPI`` (sync) and
``PolySwarmAsyncAPI`` (async) inherit them.

What moved to ``polyswarm_api._base.PolyswarmAPIBase``:

* All ~100 endpoint methods from ``polyswarm_api.api.PolyswarmAPI``
  (search, lookup, rescan, hunts, rulesets, tags, families, IOCs,
  downloads, sandbox tasks, reports, templates, prompt configs,
  webhooks, …). Each body is the same one- or two-liner —
  ``return self._single(resources.X.method(self, …))`` for single
  responses, ``return self._paginate(…)`` for paginated ones.

What stays sync/async-specific (kept on the subclasses):

* ``__init__`` (each constructs its own HTTP client)
* Context-manager methods (``__enter__``/``__exit__`` vs
  ``__aenter__``/``__aexit__`` vs ``close``/``aclose``)
* ``_single`` / ``_paginate`` / ``_sleep`` (the three hooks the
  shared methods delegate to)
* Polling helpers: ``wait_for``, ``report_wait_for`` (use sync
  ``time.sleep`` vs async ``asyncio.sleep`` inside loops)
* File-upload paths: ``submit``, ``sandbox_file``, ``sandbox_url``
  (sync uses ``LocalArtifact.upload_file`` via ``httpx.put``; async
  uses ``async_upload_file`` from ``polyswarm_api.aio.upload`` —
  neonscan's monkey-patch site)
* ``refresh_engine_cache`` / ``engines`` property (mutates
  ``self._engines``; sync property can't call async refresh)
* ``sandbox_providers`` (returns the executed ``PolyswarmRequest`` so
  callers can read ``.json`` — a quirk of the existing surface)

Resource classmethods (``BaseJsonResource.create`` / ``get`` / etc.,
plus per-resource builders like ``ArtifactInstance.search_hash``) were
already changed to return UNEXECUTED ``PolyswarmRequest`` instances in
the previous commit. The base methods call them and pass the result to
``_single`` / ``_paginate``, which rebuild as the subclass's
``_request_cls`` (sync ``PolyswarmRequest`` vs async
``AsyncPolyswarmRequest``) before executing.

The trick that makes a single sync-shaped method body work for both:

    class PolyswarmAPIBase:
        def search(self, hash_, hash_type=None):
            hash_ = resources.Hash.from_hashable(hash_, hash_type=hash_type)
            return self._paginate(resources.ArtifactInstance.search_hash(
                self, hash_.hash, hash_.hash_type,
            ))

* Sync ``_paginate`` is a generator function — ``for x in api.search(…)``.
* Async ``_paginate`` is an async generator function — ``async for x in
  api.search(…)``.
* No ``async def`` on the inherited public method; the base method just
  returns whatever ``_paginate`` returned.

The same applies to ``_single`` for non-paginated endpoints.

Test changes:

* ``test/client_scan_test.py::test_check_known_host`` materialises the
  paginated result with ``list(...)`` before indexing — the inherited
  method is now ``_paginate``-shaped.
* ``test/async_client_test.py`` VCR matcher switched from strict
  ``uri`` to ``[method, scheme, host, port, path, query]`` so cassettes
  recorded against requests-style param ordering replay against httpx,
  which serialises params in a different (but equivalent) order.
* Five async cassettes copied from their sync counterparts where the
  test scenarios are identical (``test_async_iocs_by_hash``,
  ``test_async_search_by_ioc``, ``test_async_sandboxtask_latest``,
  ``test_async_historical_results``, ``test_async_live``).

Net diff: -1,514 lines across the client modules. Both client files
collapsed to ~330 lines each (was ~1,300 sync + ~1,900 async).

All 72 tests pass against the live e2e stack.
…st work

The migration-status section now describes the completed state: every
endpoint on `PolyswarmAPIBase`, sync on httpx, requests dropped from
runtime deps, both clients reduced to ~330 lines each, ~1,500 net lines
removed.

Calls out the remaining sub-task on DN-8225: rolling out the
parametrised `ClientTestCase` harness to `client_scan_test.py` and
`async_client_test.py`. The pattern is established in
`metadata_field_properties_test.py`.
@sbneto sbneto changed the title Phase 1: PolyswarmAPIBase + AsyncPolyswarmRequest inheritance dedupe Consolidate sync + async clients via PolyswarmAPIBase (httpx-only) May 21, 2026
sbneto and others added 4 commits May 21, 2026 15:58
Introduce a `specs/` directory as the authoritative home for the
SDK's contracts, invariants, and per-area design. AGENTS.md now
points at the specs for detail; it itself stays orientation-shaped
(gitflow, conventions, architectural snapshot, links to specs).

Specs landed (each opens with Scope + Invariants, then file
references, then content):

* `00-overview.md` — what the SDK ships, platform context, repo layout
* `01-architecture.md` — PolyswarmAPIBase, the sync/async pattern, the
  request and response pipelines, the sync/async carve-outs
* `02-resources.md` — BaseJsonResource + per-domain resource classes,
  classmethod builder convention (return unexecuted PolyswarmRequest)
* `03-endpoints.md` — full method catalogue + classification grid
  (single vs paginate, base vs carve-out, with rationale per method)
* `04-testing.md` — three mocking layers (respx, vcrpy, pure),
  ClientTestCase parametrisation harness, VCR record-on-delete
  workflow, matcher conventions, outstanding test rollout
* `05-downstream-contract.md` — public surface, monkey-patch sites,
  backward-compat invariants, versioning rules
* `99-open-questions.md` — known follow-ups (test rollout, upload
  divergence, sandbox_providers quirk, VCR-off CI, …)

Also: `.github/workflows/claude-code-review.yml` adds an automated
review pass on every PR. Reads CLAUDE.md / AGENTS.md and `specs/`
for context. Posts review via `gh pr comment`. Requires repo / org
secret `ANTHROPIC_API_KEY`. Disable by setting `if: false` on the
job.

AGENTS.md slimmed down — the long migration-status section moves
into the specs (status is captured in the spec content itself
rather than as a separate progress doc). Reading order is now:
this file (gitflow + shape), then specs/00-overview.md, then the
spec for whichever area the change touches.
Pinning to a SHA (rather than the mutable v1 tag) closes a supply-chain
leak vector: a compromised v1.x release would otherwise inherit access
to ANTHROPIC_API_KEY on its next run. The trailing '# v1' comment keeps
the corresponding tag readable for humans and Dependabot.
…ed-base

# Conflicts:
#	.github/workflows/claude-code-review.yml
The action's workflow-validation check requires the file on a PR
branch to match the default branch byte-for-byte. develop currently
references @v1; the SHA pin in the previous commit on this branch
broke that match and blocked the auto-review.

Revert the pin here so the review runs on this PR. The supply-chain
hardening (SHA pin) can land in a follow-up that updates develop and
this branch together.
@claude
Copy link
Copy Markdown

claude Bot commented May 21, 2026

Review

Three real issues, all in the hoisted base class / async carve-outs. The bulk consolidation pattern is sound; these are leftover edges where the new "resource classmethods return unexecuted PolyswarmRequest" rule wasn't followed through.

1. notification_webhook_test never executes the request (correctness — bug)

src/polyswarm_api/_base.py:1197-1205:

def notification_webhook_test(self, webhook_id):
    logger.info('Testing webhook %s', webhook_id)
    res = resources.Webhook.test(self, webhook_id=webhook_id)
    if res.status_code != 200:
        raise exceptions.RequestException('Failed to test webhook %s', webhook_id)

Per the Phase 2 convention (and resources.py:1389-1398), Webhook.test now returns an unexecuted PolyswarmRequest. PolyswarmRequest.__init__ sets self.status_code = None (core.py:144). So res.status_code != 200 is always true — this method always raises, on both sync and async, and the test endpoint is never actually hit. There's also no _single / await dispatch, so the async client can't reach the endpoint at all.

Fix: route through _single (or call .execute() explicitly on each subclass) and key the success branch on the response status. Listed in specs/03-endpoints.md:152 under _single endpoints, so the spec already expects the base-method form.

2. Async engines property has two stacked @property decorators (correctness — bug)

src/polyswarm_api/aio/__init__.py:117-124:

@property

@property
def engines(self):
    raise AttributeError(...)

The outer @property's fget is the inner property object, which isn't callable — accessing api.engines raises TypeError: 'property' object is not callable, not the intended AttributeError with the documented guidance. Drop the duplicate decorator.

3. Async sandbox_providers drifted from the spec (spec drift / downstream contract)

specs/01-architecture.md:242, specs/03-endpoints.md:197,300-307 document the carve-out: both subclasses preserve the pre-existing quirk of returning the executed PolyswarmRequest so callers can read .json. Spec example for async is await resources.SandboxProvider.list(self).execute().

The actual async implementation (aio/__init__.py:368-377) is an async generator yielding parsed SandboxProvider resources:

async def sandbox_providers(self):
    async for item in self._generate({...}, result_parser=resources.SandboxProvider):
        yield item

Sync returns the request object (.json available); async returns iterable resources (no .json quirk). That's a downstream-contract breach — downstream code that was reading .json['result']['cape']['slug'] against the async client will break. Either align the async impl to the spec (preserve the quirk) or update both spec entries to acknowledge the divergence — but a silent async-only shape change is the wrong outcome.

Minor

  • test/client_scan_test.py and test/async_client_test.py still have separate bodies — already tracked in specs/99-open-questions.md, not blocking. None of the three bugs above are covered by the existing ClientTestCase harness; consider adding test_notification_webhook_test and an engines-on-async test to the parametrised suite when fixing.
  • Gitflow ✅ (PR targets develop, no version bump).
  • requests cleanly removed from runtime ✅. [async] extra preserved as empty list ✅.

Otherwise the consolidation looks clean and the spec set is thorough.

…c engines property

notification_webhook_test was building a PolyswarmRequest and never executing
it, so non-2xx responses were silently swallowed and async callers got back
an unexecuted request object instead of awaiting a coroutine. Route it through
self._single() like every other endpoint method.

As a side effect of that fix: parse_result previously gated non-2xx error
mapping on result_parser being set, which meant fire-and-forget endpoints
(no parser) would not raise on server errors. Restructure parse_result so
non-2xx mapping runs regardless of result_parser. HEAD method short-circuits
first to preserve exists_hash's "404 means absent" behaviour. Webhook.test
keeps no result_parser since the test endpoint returns a generic OK envelope,
not a Webhook-shaped payload.

The async engines property had a stacked @Property decorator above the
AttributeError raiser, leaving a no-op decorator dangling. Remove it.
@claude
Copy link
Copy Markdown

claude Bot commented May 21, 2026

Review — PR #298

1. Correctness — _base.py methods that touch the _single result are broken for async (regression)

The "single body works for both transports" pattern only holds for one-shot return self._single(...). Any base method that uses the result of _single / _paginate is broken in async, because for PolySwarmAsyncAPI._single the return value is a coroutine, not the parsed resource. All of the methods below were proper async def carve-outs in the pre-merge aio/__init__.py and have been pulled into the base in a sync-only-shaped form:

  • _base.py:434 live_feed_deletetry: return self._single(...) + except NoResultsException cannot catch the exception in async; the coroutine is returned uncaught and the exception fires on the caller's await.
  • _base.py:761 download, _base.py:775 download_id, _base.py:790 download_sandbox_artifact, _base.py:839 download_archiveartifact = self._single(...); artifact.handle.close() raises AttributeError on the coroutine before the caller ever gets a chance to await.
  • _base.py:914 sample_bundle_download — reads task.state on the coroutine, then chains task.download_zip(...).
  • _base.py:952 llm_report_download, _base.py:985 report_download — same two-step pattern (.state / .download_report() on the coroutine).
  • _base.py:995 report_template_logo_download, :1001 report_template_logo_delete, :1010 report_template_logo_upload — second _single(report.<method>()) call dereferences attributes on a coroutine.

None of these have async test coverage in test/async_client_test.py, which is why CI is green. The fix is either (a) keep them as carve-outs in aio/__init__.py like submit / sandbox_file, or (b) add an async-aware abstraction (e.g. an _await_if_needed helper, or move the .handle.close() / state checks into the resource builder so the base method stays one-shot).

2. Spec drift — public surface promised but not implemented

specs/05-downstream-contract.md:43-48 documents PolyswarmAPI.close(), __enter__(), __exit__() as part of the public surface, but src/polyswarm_api/api.py defines none of them (only the async class has the context-manager protocol). Either implement them on the sync class or fix the spec.

3. Public-repo hygiene — internal ticket IDs leaking

AGENTS.md explicitly forbids ticket IDs in this repo. Two references slipped in:

  • src/polyswarm_api/_base.py:18-23 — docstring references "DN-8225" and also still claims this PR ships only "the metadata mapping/properties endpoints as a representative slice", which is stale (the PR ships all ~100 methods).
  • src/polyswarm_api/core.py:518-520NOTE comment references "DN-8225".

Strip both refs and update the _base.py docstring to reflect the actual scope.

4. Test coverage gap that masks (1)

metadata_field_properties_test.py is the only file using the new parametrised ClientTestCase harness. The async path for every download / multi-_single method above is untested, which is exactly why the regressions in §1 are invisible to CI. At minimum, add one parametrised harness test that exercises download (or any of the multi-step methods) against PolySwarmAsyncAPI so this class of bug fails CI rather than ships silently.

5. Gitflow

Base is develop ✓. No version bump ✓.

sbneto added 8 commits May 21, 2026 20:05
The aio package should mirror the root layout: classes live in named
modules, not in __init__.py. aio/api.py now holds the async client
class (mirrors src/polyswarm_api/api.py); aio/__init__.py is a thin
re-export so external `from polyswarm_api.aio import PolySwarmAsyncAPI`
keeps working unchanged.

No functional change.
Sync sandbox_providers returns an executed PolyswarmRequest so callers
read .json['result'][slug][...]; the async client was diverging by
exposing an async generator of parsed SandboxProvider items, which is a
different downstream contract for the same method name.

Sync is the source of truth: async now mirrors it, returning an executed
AsyncPolyswarmRequest the caller awaits then reads .json on. Test
updated to match the sync test's access pattern.
exists() reads the _single result (str(result) == '200') so it cannot
share a body with both transports — on async, _single returns a
coroutine and the string comparison silently returns False for every
hash. Moved to PolyswarmAPI (sync) and PolySwarmAsyncAPI (async) as a
pair, with the async version inserting `await` at the _single call.

While here: collapsed the if/else into a single boolean return.
download, download_id, download_sandbox_artifact, and download_archive
all read the _single result to close the LocalArtifact handle before
returning. On async, _single returns a coroutine and
`artifact.handle.close()` raises AttributeError.

Moved each to PolyswarmAPI (sync, unchanged body) and PolySwarmAsyncAPI
(async, with `await` at the _single call) as pairs.
sample_bundle_download and llm_report_download both issue two HTTP
calls — fetch the task, then download its presigned-url payload. They
read the first _single result to branch on state and to build the
second request. On async, both operations operate on a coroutine and
fail.

Moved to PolyswarmAPI / PolySwarmAsyncAPI as pairs.
report_download fetches the report task (via self.report_get, which is
itself a _single call) then state-branches and downloads. Both reads
break on async since the base report_get returns a coroutine for the
async client.

Moved to PolyswarmAPI / PolySwarmAsyncAPI as a pair. The async version
awaits report_get directly rather than chaining through the base.
report_template_logo_download / _delete / _upload each fetch the
template (one _single) then act on the returned ReportTemplate
(another _single via its bound download_logo / delete_logo /
upload_logo). On async the first _single returns a coroutine and
`report.<op>()` raises AttributeError.

Moved to PolyswarmAPI / PolySwarmAsyncAPI as pairs.
…rved out

Codify the rule the carve-out work enforces:

- Endpoint methods on PolyswarmAPIBase must be single-statement bodies
  (`return self._single(...)` or `return self._paginate(...)`). The
  shared-body trick relies on _single's return value being inert until
  awaited, so anything that consumes the result needs `await` on the
  async side and can't be shared.

- Multi-statement bodies live on the subclasses as sync+async pairs.
  Sync is the source of truth; async mirrors the body with `await`
  inserted at each _single call.

- PolyswarmRequest is one HTTP call (paginated counts as one
  templated call). Composing multiple round-trips inside a single
  request type is not allowed.

- File paths updated: aio/api.py now holds PolySwarmAsyncAPI;
  aio/__init__.py is a thin re-export.

- sandbox_providers shape clarified: both transports return the
  executed request; caller reads .json['result'][slug].

- Catalogued the 11 multi-statement carve-outs in the endpoint table.

- Noted the async-cassette coverage gap as a follow-up.
@claude
Copy link
Copy Markdown

claude Bot commented May 21, 2026

Code review

Gitflow ✅ (base = develop), no version bump ✅, polyswarm_api.aio.upload.* module callables preserved ✅. Caveats below — three correctness issues, then spec/hygiene.

1. Correctness — async live_feed_delete swallows nothing

src/polyswarm_api/_base.py:422-427:

def live_feed_delete(self, result_ids):
    try:
        return self._single(resources.LiveHuntResultList.delete(self, result_ids=result_ids))
    except exceptions.NoResultsException:
        return None

Works sync (_single executes inline, exception propagates inside the try). In async, _single is async def — calling it returns a coroutine without executing the body. The try/except never catches anything; NoResultsException fires on the caller's await. So async callers get the exception instead of None.

Per AGENTS.md: methods that "act on" a _single result (here: catching its exception) "cannot sit on the base — the shared-body trick depends on _single's return being inert." Move to a sync/async pair on the subclasses. specs/03-endpoints.md:60 lists this as on the base — same fix to the spec.

2. Correctness — async sandbox_url sends the wrong PUT param

src/polyswarm_api/aio/api.py:357-364 finalises with params={"sandbox_task_id": str(int(task))} and no JSON body. Sync (src/polyswarm_api/api.py:300) calls SandboxTask.update_file(self, id=task.id, community=self.community), which sends ?id=... + {"community": ...} JSON. Different query key, and the community body is missing — server-side this will either 4xx or route to the wrong community.

3. Correctness — async sandbox_file PUT also drops the community body

src/polyswarm_api/aio/api.py:292-299: the inline finalise dict has params={"id": str(int(task))} and no json key. Sync (src/polyswarm_api/api.py:231) includes community=self.community in the body. Same risk as #2.

For both #2 and #3, the simplest fix is to call resources.SandboxTask.update_file(self, id=task.id, community=self.community) through self._single like sync does, rather than re-inlining the request dict — that's the whole point of the shared builder.

4. Spec drift — async refresh_engine_cache re-inlines the request

src/polyswarm_api/aio/api.py:124-138 constructs a request dict (URL + Authorization: None header) instead of calling resources.Engine.list(self) like sync (src/polyswarm_api/api.py:77). Same payload, but it duplicates the endpoint string and header opt-out. Use the builder; only the aggregation step (async for → list) needs to be async-specific.

5. Internal references leaked into the source

  • src/polyswarm_api/_base.py:18-22 — module docstring says "this first PR lands ... metadata mapping/properties endpoints as a representative slice. Bulk endpoint-method consolidation follows in subsequent PRs tracked by DN-8225." Stale (this PR is the bulk consolidation) and leaks ticket DN-8225.
  • src/polyswarm_api/core.py:518 — comment "Phase 2 of the sync/async refactor (DN-8225) moved execution onto the API client's _single / _paginate". Same ticket leak. AGENTS.md "Commit + PR hygiene" rule applies to source comments too.

Drop the DN-8225 references and rewrite the _base.py docstring to describe the current state rather than the multi-PR sequence.

Test coverage (non-blocking)

live_feed_delete, sandbox_url, and sandbox_file async paths aren't on the parametrised ClientTestCase harness yet (acknowledged in specs/99-open-questions.md). The harness would have caught #1#3 — worth prioritising those three over a mechanical rollout.

sbneto added 4 commits May 22, 2026 19:28
Pre-requisite for unasync codegen: sync and async upload paths must
share the same structural shape (module-level function taking a client,
upload_url, artifact). Previously sync did the upload inline on the
resource method via httpx.put; async had a module-level
async_upload_file. unasync can't bridge that asymmetry.

Now:
- polyswarm_api.upload.upload_file is a module-level sync function
  mirroring polyswarm_api.aio.upload.async_upload_file.
- LocalArtifact.upload_file / SandboxTask.upload_file are thin shims
  that route through the module-level function with the api session's
  httpx.Client — preserves the existing instance-method surface so no
  downstream caller breaks.
- Connection pooling improves on the sync side as a side effect (the
  session client is reused instead of httpx.put creating a fresh
  client per call).

Also drops unused async_upload_logo (imported but never called).
Lands the tooling (script + pre-commit hook + GitLab CI staleness check)
inert. Phase 3 will actually run the codegen and replace the
hand-written sync client with generated mirrors.

- scripts/unasync.py drives the transformation from aio/*.py to root.
  Rename map covers class names, httpx types, typing helpers, asyncio
  primitives, and the polyswarm_api.aio. import-path prefix.
- .pre-commit-config.yaml hooks the script on any change under aio/.
- .gitlab-ci.yml gains a test-unasync-mirror job that fails CI if the
  generated sync is stale relative to canonical async.
- pyproject.toml grows a [dev] extra with unasync + ruff.
aio/api.py becomes a self-contained async client: ~75 endpoint methods
that previously lived on PolyswarmAPIBase are now async def methods on
PolySwarmAsyncAPI directly. Each `return self._single(...)` becomes
`return await self._single(...)`; each `return self._paginate(...)`
becomes an `async for ... yield ...` async-generator body.

The class no longer inherits from PolyswarmAPIBase — _coerce_request and
__repr__ are pulled in. _parse_rule is pulled in.

The 11 multi-statement methods that lived as sync+async carve-outs
(exists, download family, sample_bundle_download, llm_report_download,
report_download, report_template_logo_*) stay where they already were
on the async side. The sync versions in api.py remain until Phase 3
runs unasync and replaces them with generated mirrors.

_base.py is still present but no longer referenced from aio/api.py; it
gets deleted in Phase 4.
scripts/regenerate_sync.py drives the codegen. Running it overwrites
the sync surface (api.py, core.py, upload.py at the package root) from
the canonical async source under aio/. The script:

- Runs unasync with a rename map covering class names (Async* -> sync
  equivalents), httpx types, asyncio.sleep -> time.sleep, the
  polyswarm_api.aio. import-path prefix, and the upload helper name.
- Patches the engines @Property (sync gets a working cached property;
  async raises AttributeError — Python properties can't await).
- Dedupes the duplicate `import time` produced by the asyncio -> time
  rename.
- Adds a `# DO NOT EDIT` header to the generated files.
- Runs ruff format on the output for stable bytes.

Structural changes:

- New module polyswarm_api/_bases.py holds BaseJsonResource, BaseResource,
  HttpxResponseAdapter, Hashable, and the small helpers. These were
  duplicated when the codegen produced both sync and async cores, which
  broke `issubclass` checks across modules. Pulling them into a
  hand-written shared module gives them a single class identity.
- aio/core.py now imports the bases from _bases and defines only the
  transport-specific AsyncPolyswarmSession / AsyncPolyswarmRequest.
- aio/api.py drops the docstring scaffolding that pretended to describe
  both transports; _coerce_request is now duck-typed (no isinstance
  against the sync request class).
- The `close` method on the async client is renamed to `aclose` so the
  unasync map can rewrite it to `close` on the sync side.

The script is now named scripts/regenerate_sync.py (previously
scripts/unasync.py) because the original name shadowed the third-party
`unasync` package on import.

Pre-commit hook and GitLab CI job updated for the rename.
@claude
Copy link
Copy Markdown

claude Bot commented May 26, 2026

Review

Spec coverage is solid — 01-architecture.md through 05-downstream-contract.md accurately describe the new shape. PR targets develop and skips the version bump per gitflow, both correct. Findings below in severity order.

Correctness

1. ReportTemplate.upload_logo passes a file-like as httpx data= kwarg. src/polyswarm_api/resources.py:1336 builds a PolyswarmRequest with 'data': logo_file. Under requests this worked; under httpx 0.27 data= is for Mapping form data — a raw file-like should go through 'content': logo_file. This path runs end-to-end via report_template_logo_upload (aio/api.py:1398-1411) and is untested, so the regression won't surface until a real caller hits it.

2. from polyswarm_api import PolyswarmAPI is documented but not exposed. src/polyswarm_api/__init__.py only does from . import api, so the top-level name doesn't exist — only polyswarm_api.api.PolyswarmAPI. The spec at 05-downstream-contract.md:39 has it right ("from polyswarm_api.api import PolyswarmAPI is the canonical import"), but:

  • AGENTS.md:66 says "Caller surface unchanged: from polyswarm_api import PolyswarmAPI (sync)".
  • src/polyswarm_api/aio/api.py:13 (which becomes the generated api.py module docstring) says the same.

Pick one: add from .api import PolyswarmAPI to __init__.py, or fix the docstring + AGENTS.md to point at polyswarm_api.api. (The pre-existing try/except ImportError guard around PolySwarmAsyncAPI was the only thing protecting against this surface inconsistency — that guard is gone now, but the false claim wasn't fixed.)

Spec drift

None substantive. The _coerce_request / _exec docstrings reference AsyncPolyswarmRequest in the generated sync core.py (unasync doesn't rewrite string literals), but the canonical docstring already calls out the sync mirror, so it's tolerable.

Downstream contract

Behaviour change to flag for the release PR. BaseJsonResource.create/get/update/delete/list and the per-resource classmethods (ArtifactInstance.search_hash, IOC.iocs_by_hash, LocalArtifact.download*, etc.) now return unexecuted PolyswarmRequest instances; previously they .execute()'d eagerly. This is correctly documented in 05-downstream-contract.md:261 and counts as a "Behaviour change on a documented contract" → major bump per the versioning table on line 273. No bump in this PR (per gitflow ✓); the maintainer needs to remember it at the develop → master step. The requestshttpx kwarg shift on the constructor is the other major-bump trigger sitting in the same release.

Test coverage

  • upload_logo / report_template_logo_upload are entirely untested on either transport. A respx-mocked PUT that asserts the outbound request body is non-empty would catch issue Search/download/rescan/community support #1 above.
  • Polling helpers (wait_for, report_wait_for) are untested on either transport. These rely on the asynciotime codegen rule that scripts/regenerate_sync.py:62-63 explicitly warns is fragile ("asyncio.X for any X other than sleep will produce time.X … which is nonsense"). A respx test with lookup returning a non-terminal then terminal scan, plus monkeypatch.setattr('time.sleep', …) / asyncio.sleep, would lock the codegen contract.

Gitflow

Base develop ✓, no version bump ✓.

Bot review + import-surface audit caught real issues across two areas.

Bug fix (bot #1): ReportTemplate.upload_logo built a PolyswarmRequest
with `data=` carrying a file-like. httpx 0.27 reserves `data=` for
Mapping form bodies — a raw byte payload must go through `content=`.
Under requests this worked; under httpx it didn't. Read the file
into bytes and pass `content=` so the outbound PUT body is non-empty.

Import-surface compat (preserving what was importable on develop):

- `from polyswarm_api import PolyswarmAPI` is documented (spec 05,
  AGENTS.md "Caller surface unchanged", canonical aio/api.py
  docstring) but wasn't actually exposed at the top level — the
  bot caught this. Added the re-export.
- `from polyswarm_api.aio import AsyncPolyswarmRequest,
  AsyncPolyswarmSession` worked on develop (re-exported through the
  giant inline `aio/__init__.py`). The slim codegen-era
  `aio/__init__.py` had dropped them. Restored as explicit
  re-exports from `aio.core`.
- `async_upload_logo` (function + monkey-patch site) had been removed
  as "dead code" in an earlier round. It's part of the develop
  import surface (`polyswarm_api.aio.async_upload_logo`,
  `polyswarm_api.aio.upload.async_upload_logo`). Restored on the
  canonical async side; the rename map (`async_upload_logo` ->
  `upload_logo`) generates a sync mirror at
  `polyswarm_api.upload.upload_logo`. Note: the function is
  preserved as a public callable but not used internally — the
  resource-method upload path (ReportTemplate.upload_logo via
  `_single(...)`) is what `report_template_logo_upload` actually
  calls.

Tests:
- `test_async_report_template_logo_upload` exercises the full
  `report_template_logo_upload` flow against respx, asserts the PUT
  body is the file bytes and the Content-Type header is set. Would
  have caught the data=/content= bug at PR time; locks in the fix.

Other bot notes addressed in chat (not code):
- `_coerce_request` / `_exec` docstrings reference `AsyncPolyswarmRequest`
  in the generated sync `core.py` — string literals; unasync doesn't
  rewrite them. The canonical docstring already calls out the sync
  mirror; tolerable as the bot noted.
- `wait_for` / `report_wait_for` test gap — the asyncio->time codegen
  rule it would exercise is documented as a constraint, and breakage
  would manifest loudly (e.g. `time.gather`) rather than silently.
  Not blocking; better tested at the codegen smoke layer if at all.
- Major-version bump (3.x -> 4.0) for the develop->master step is
  noted in the PR description; this PR stays at 3.21.0 per gitflow.
@claude
Copy link
Copy Markdown

claude Bot commented May 26, 2026

Review against AGENTS.md + specs/

Spec drift — needs follow-up in this PR

  • specs/00-overview.md is stale. Lines 71–113 still describe _base.py / PolyswarmAPIBase as the architectural centre — directly contradicts the new codegen model documented in the rewritten 01-architecture.md and AGENTS.md. The "Architectural snapshot" diagram in particular describes a base class that no longer exists. Either rewrite this section or strip the snapshot and point at 01-architecture.md.

  • specs/02-resources.md references symbols that no longer exist.

    • Invariant 1 (line 9): "The API client (PolyswarmAPIBase._single / _paginate) calls .execute()."
    • Line 177 (Hash docstring): "see PolyswarmAPIBase.search, .exists, ..."
    • Line 235: "in _base.py's sync-only carve-outs for upload methods".

    Per AGENTS.md: "if a PR drifts from the spec, the spec is wrong until proven otherwise." Update those references to PolyswarmAPI._single / PolySwarmAsyncAPI._single (or just "the API client's _single/_paginate") in the same PR.

Downstream contract — async_upload_logo / upload_logo are dead public API

aio/upload.py:59 adds a new module-level async_upload_logo; the rename map turns it into upload_logo in the generated upload.py:66; both are re-exported from aio/__init__.py.__all__. Grep confirms nothing inside the SDK calls either — ReportTemplate.upload_logo (the resource method) just builds an unexecuted PolyswarmRequest, it does not route through aio.upload.async_upload_logo. Per specs/05-downstream-contract.md Invariant 4, module-level callables are monkey-patch sites and are part of the contract once shipped. Two clean options:

  1. Wire ReportTemplate.upload_logo (resources.py:1320) through polyswarm_api.aio.upload.async_upload_logo / polyswarm_api.upload.upload_logo (the way submit() uses async_upload_file), and document it in 05-downstream-contract.md alongside async_upload_file. Then the symbol is load-bearing and intentional.
  2. Drop the function from aio/upload.py + the rename-map entry + the aio/__init__.py re-export. Don't ship a public callable the SDK doesn't use itself.

Picking either silently locks in the export forever, which is exactly the "moving a monkey-patch site silently" failure mode the spec warns about.

Test coverage — async download-with-handle-close path

PR body acknowledges async cassettes weren't recorded for download / download_id / download_archive / download_sandbox_artifact / exists and tracks them in 99-open-questions.md. The non-trivial part of those methods is the post-_single step (result.handle.close(), branching on report.state) — the multi-statement bodies the PR explicitly motivates. None of those close-after paths has a respx-backed regression test on the async side, even though the canonical bodies in aio/api.py:1464–1507, :1413–1430, :1432–1462 are exactly what the new architecture must protect. A respx mock for one download_* and one report_download async path would cover the multi-statement-async regression risk without needing the live e2e — the new test_async_instance_upload_file is the template.

Gitflow / version

  • PR base develop
  • No pyproject.toml version bump ✓ (correct per AGENTS.md — bump belongs to the develop → master step). When that step happens, this is a major bump: requests removed as runtime dep + **kwargs now forwarded to httpx.Client instead of requests.Session per 05-downstream-contract.md. Flag for the maintainer cutting the release PR.

Other things looked at, no action

  • Codegen post-processing is text-exact on the engines block — fragile by design, fails loudly. ✓
  • Resource classmethods all return unexecuted PolyswarmRequests; .execute() callsites removed. Matches 02-resources.md Invariant 1. ✓
  • BaseJsonResource / HttpxResponseAdapter lives in _bases.py so issubclass identity is preserved. ✓
  • LocalArtifact.upload_file / SandboxTask.upload_file shim correctly falls back to a one-shot httpx.Client when called from an async API client. ✓
  • Header-suppression path (Authorization: None for S3 / engines-list) is covered by test_async_session_drops_none_authorization_header. ✓
  • parse_result now raises on non-2xx even without a result_parser — covered by test_async_no_parser_non_2xx_raises. ✓

…ownload test

Bot review flagged two real spec-drift sites and a test gap on the
post-_single download paths. User asked for a broader audit of
spec ↔ implementation alignment in the same pass.

Spec fixes:

- specs/00-overview.md: rewrote the Architectural snapshot and the
  repo-layout listing — they still described the retired
  PolyswarmAPIBase shared-base model. Now they describe the
  async-canonical + codegen architecture, name the relevant files
  (_bases.py, aio/api.py, aio/core.py, aio/upload.py canonicals;
  api.py / core.py / upload.py generated mirrors), and put the
  PolyswarmAPIBase mention in historical context only.
- specs/02-resources.md: three sites still said
  `PolyswarmAPIBase._single` / `PolyswarmAPIBase.search` / "_base.py's
  sync-only carve-outs". Updated to PolyswarmAPI / PolySwarmAsyncAPI
  and to describe the actual multi-step flow as it lives on the
  canonical async side. Also corrected the `LocalArtifact.upload_file`
  description: it goes through `polyswarm_api.upload.upload_file`
  with the session client (or a one-shot sync client when the api
  is async), not raw `httpx.put`.
- specs/05-downstream-contract.md: documented `async_upload_logo` as a
  preserved-for-compat monkey-patch site. It was importable on
  develop but isn't called internally — the bot flagged that
  shipping a module-level callable the SDK doesn't use itself locks
  it into the contract silently. Now it's explicit: kept for import
  surface compat, not used internally, candidate for removal on a
  major version willing to break the import surface.

Test added:

- test_async_report_download_multistep_handle_close — respx-driven
  full async exercise of `report_download` (GET task → state branch →
  GET rendered file → close local handle). The post-_single logic
  (state == 'PENDING' raise, `result.handle.close()`) is exactly the
  multi-statement-async regression risk that motivated the codegen
  architecture. Locks the path in without needing the live e2e.

Other bot notes addressed in chat (no code change):

- _coerce_request / _exec docstrings reference AsyncPolyswarmRequest
  in the generated sync core.py — string literals; unasync doesn't
  rewrite them. Tolerable; bot agreed.
- Major-version bump for develop → master is flagged in the PR
  description for the maintainer; this PR stays at 3.21.0 per
  gitflow.

Broader spec audit results:

- grep for PolyswarmAPIBase / _base.py in specs/: clean except the
  two historical mentions in 00-overview.md and 01-architecture.md
  ("the previous / historical PolyswarmAPIBase architecture") which
  are intentional.
- grep for httpx.put / requests.put / requests.Session: only
  contextual mentions of the 3.x → httpx migration remain (in
  05-downstream-contract.md and 99-open-questions.md) which are
  correct.
- 03-endpoints.md and 04-testing.md scan clean.
@claude
Copy link
Copy Markdown

claude Bot commented May 26, 2026

Review

Architecture matches specs/01-architecture.md (async-canonical + unasync), _bases.py keeps single class identity, public surface from specs/05-downstream-contract.md is preserved (resources, exceptions, polyswarm_api.aio.upload.async_upload_file + async_upload_logo, [async] extra as empty list), engine escape-hatch is wired through regenerate_sync.py correctly, polling helpers (wait_for, report_wait_for) rely on the asynciotime rename as documented in specs/03-endpoints.md §Polling helpers. PR targets develop and doesn't bump the version — gitflow respected.

A few concrete things to address:

Correctness

  1. next_page() drops parser_kwargs. aio/core.py:284-298 (and the generated core.py:324-338) builds the next-page request without forwarding self.parser_kwargs:

    return await AsyncPolyswarmRequest(
        self.api_instance,
        new_parameters,
        result_parser=self.result_parser,
    ).execute()

    Page 1 hands **parser_kwargs to parse_result_list; page ≥ 2 doesn't. No current paginated endpoint relies on parser kwargs, so this is latent — but specs/01-architecture.md describes the request's parser_kwargs as load-bearing, so the contract drifts here. One-line fix: result_parser=self.result_parser, **self.parser_kwargs.

  2. sandbox_url finalize uses a different param name than sandbox_file. Same endpoint ({SandboxTask.RESOURCE_ENDPOINT}/instance), but aio/api.py:1292 (sandbox_file) sends {"id": ...} while aio/api.py:1357 (sandbox_url) sends {"sandbox_task_id": ...}. May predate this PR, but no test exercises the sandbox_url finalize path so we can't tell if the server actually accepts sandbox_task_id. Worth reconciling on id to match sandbox_file (and the cassettes that cover it).

  3. Upload helpers bypass the session's Authorization-suppression. async_upload_file / async_upload_logo call self.session._client.put(...) directly, so the client's session-level Authorization header (the PolySwarm API key) ships to the pre-signed S3 URL. The session's request() is the documented place for header suppression, and specs/05-downstream-contract.md calls out the suppression machinery as security-relevant ("leaking the API key to those origins is a real exposure"). Pre-existing behaviour, but the cleanest fix while the module is being touched is to do the PUT via self.session.request('PUT', ..., headers={'Authorization': None}) (or have async_upload_file accept a session wrapper instead of the raw client).

  4. Retry loop in upload_file / async_upload_file is dead. r.raise_for_status() raises out of the while attempts > 0 and not r: loop on any non-2xx, so attempts never decrements past one. Pre-existing; flagging since this PR touches the file. If retries aren't wanted, drop the loop and the parameter; if they are, catch httpx.HTTPStatusError around the put + raise_for_status.

Test coverage

test_async_report_download_multistep_handle_close and test_async_download_to_handle pin two of the new canonical multi-step download paths. The remaining ones in aio/api.pydownload, download_id, download_sandbox_artifact, download_archive, exists, sample_bundle_download, llm_report_download, report_template_logo_download/delete — have no async test (respx or VCR) in this PR. specs/99-open-questions.md acknowledges async cassettes for the download family are deferred; an inexpensive respx-driven test per method would close the regression risk that the prior polymorphic-return architecture had on multi-step bodies. Mentioning specifically because specs/03-endpoints.md §"The escape hatch" highlights these methods as the motivating examples for the new architecture.

Nits (no action required)

  • client_scan_test.py keeps the default VCR matchers while async_client_test.py sets match_on=['method', 'scheme', 'host', 'port', 'path', 'query']. Both effectively use the query-matcher (it's in the default set), so cassettes still survive httpx's param re-ordering — no fix needed, but the sync file could be made explicit to match specs/04-testing.md §"VCR matcher convention".

…eal retry

Bot review caught four correctness items.

1. next_page() dropped parser_kwargs. Page 1 forwarded the request's
   parser_kwargs to parse_result_list; page 2+ did not, so paginated
   resources that need extra parser kwargs (none today, but the
   contract documents the forwarding) would silently lose them after
   the first page. One-line fix: forward `**self.parser_kwargs` to
   the next-page request constructor. Mirrored on both
   AsyncPolyswarmRequest and the generated sync PolyswarmRequest.

2. Upload helpers leaked the session-level Authorization header to
   pre-signed S3 URLs. async_upload_file / async_upload_logo called
   client.put() directly, and the httpx client merges its session-
   level Authorization (the PolySwarm API key) into every outgoing
   request. Pre-signed URLs handle auth via the query parameters; we
   don't want to ship the API key to a third-party object store.
   Fixed with a new private helper `_put_without_session_auth` that
   uses `client.build_request` + `.headers.pop("Authorization")` +
   `client.send(req)` — same pattern the session uses for explicit
   None-suppression.

3. Upload helpers had a dead retry loop. `while attempts > 0 and not
   r:` with `r.raise_for_status()` inside the body meant any non-2xx
   raised straight out, never decrementing attempts. The `attempts`
   parameter was a lie. Replaced with a real retry: catches
   httpx.HTTPStatusError + httpx.TransportError, re-raises the last
   one if all attempts fail.

4. (Verified-not-fixed) sandbox_url's PUT finalize uses
   `params={"sandbox_task_id": ...}` while sandbox_file uses
   `params={"id": ...}`. Both shapes ship on the develop branch (the
   server presumably accepts both); no VCR cassette covers the URL-
   finalize path. Per the preserve-import-surface directive, not
   reconciling blindly. Flagged in specs/99-open-questions.md for a
   future PR with a cassette.

Tests added (covers items 1, 2 implicitly + bot's coverage gap):

- test_async_upload_helper_strips_session_authorization — calls
  async_upload_file with a session that has an Authorization header,
  asserts the outgoing PUT has no Authorization.
- test_async_download — single-step canonical async path: GET
  /consumer/download/sha256/<hash>, write file, close handle.
- test_async_sample_bundle_download_multistep — full multi-step
  canonical async path: GET /bundle (task) → state branch → GET
  presigned zip → close handle.

81 tests pass.

Nit deferred (no action):
- client_scan_test.py uses default VCR matchers while
  async_client_test.py is explicit. Both effectively match on
  scheme/host/port/path/query; sync cassettes already replay
  correctly. Not worth churning.
@claude
Copy link
Copy Markdown

claude Bot commented May 26, 2026

Reviewed against AGENTS.md + specs/. The PR matches the documented architecture closely. A few minor notes, none blocking:

Correctness

  • submit (aio/api.py:1192), sandbox_file (:1275), and sandbox_url (:1342) inline the finalize PUT params instead of going through the ArtifactInstance.update / SandboxTask.update_file builders. That's a pre-existing pattern from develop's aio/__init__.py — fine to preserve here, but worth noting because future schema changes to the resource builders won't propagate to these multi-step methods. Adding new endpoints, per specs/03-endpoints.md, should still prefer builders.
  • refresh_engine_cache (aio/api.py:153) hand-rolls the request dict with headers={'Authorization': None} rather than calling resources.Engine.list(self), which already sets _list_headers={'Authorization': None} via the builder. Pre-existing; non-blocking.

Spec / docs

  • Specs are introduced (specs/0005 + 99-open-questions.md) and AGENTS.md is rewritten in lockstep with the code change — matches the "spec is wrong until proven otherwise" rule.
  • The known divergences (sandbox_url finalize uses sandbox_task_id, sandbox_file uses id; sandbox_providers returns an executed request) are explicitly tracked under specs/99-open-questions.md. No silent drift.

Downstream contract

  • Public surface preserved: polyswarm_api.PolyswarmAPI, polyswarm_api.aio.PolySwarmAsyncAPI, polyswarm_api.aio.upload.async_upload_file + the bare-module alias, the [async] extras (empty list, preserved per §"Backward compatibility"), PolyswarmRequest / PolyswarmSession / BaseJsonResource under polyswarm_api.core.
  • requests removed from runtime deps — flagged in 05-downstream-contract.md §"What changes". Good.
  • BaseJsonResource etc. live in _bases.py with a single class identity so cross-module issubclass checks work — matches specs/01-architecture.md invariant Update README.md #4.

Tests

  • New respx coverage for the architecturally-risky multi-step flows: test_async_download, test_async_sample_bundle_download_multistep, test_async_report_download_multistep_handle_close, test_async_report_template_logo_upload, test_async_instance_upload_file, test_async_upload_helper_strips_session_authorization, test_async_no_parser_non_2xx_raises, test_async_session_drops_none_authorization_header. Together they pin the post-_single state-branch + handle-close behaviour the prior polymorphic-return architecture had been silently breaking on async.
  • The parametrised ClientTestCase harness in metadata_field_properties_test.py is the canonical migration target; client_scan_test.py / async_client_test.py rollout is tracked in 99-open-questions.md.
  • VCR cassettes for test_async_iocs_by_hash / test_async_search_by_ioc got re-recorded to match the new bool-param wire format (_normalise_bool_params in _bases.py). Cassette delta is small and consistent.

Gitflow / version

  • Targets develop ✓.
  • No pyproject.toml::version bump ✓ (3.21.0 unchanged) — matches AGENTS.md §Gitflow ("version bumps belong to the develop → master step").
  • Generated files (api.py, core.py, upload.py) carry # DO NOT EDIT headers; CI staleness gate is wired up in .gitlab-ci.yml::test-unasync-mirror and locally via .pre-commit-config.yaml.
  • Commit messages use conventional prefixes, no AI-attribution trailers visible in the headlines.

LGTM.

sbneto added 2 commits May 26, 2026 20:34
Establishes the design baseline before any code changes land. The 4.0
shape: pure description (core.py) → transport (session.py) → client
(api.py); PolyswarmRequest becomes a pure dataclass descriptor;
parse_response is a pure function; the session is the only place HTTP
I/O happens; resources stay transport-agnostic; customization is via
session subclassing + injection (no module-level monkey-patch sites).

- AGENTS.md: rewrite architectural shape; update "adding a resource"
  flow around core.py / session.py / api.py.
- 00-overview.md: new 4.0 file tree and three-tier architectural
  snapshot.
- 01-architecture.md: invariants, file table, layer-by-layer detail,
  call flow, customization hooks, codegen workflow.
- 02-resources.md: invariants forbidding transport methods on
  resources; PolyswarmRequest dataclass shape; parse_response truth
  table; resource catalogue without instance HTTP methods.
- 03-endpoints.md: file-upload paths now go through self.session;
  sandbox_providers via session.execute.
- 04-testing.md: add pure-unit as a first-class tier alongside
  respx-mocked and VCR-cassette.
- 05-downstream-contract.md: 4.0 public surface, session injection
  examples, full 3.x → 4.0 migration with code samples.
- 99-open-questions.md: drop items resolved by the redesign
  (file-upload monkey-patch, BaseJsonResource location); preserve
  sandbox_url finalize-param inconsistency and missing async cassettes.
Land the architecture described in the just-committed specs. Three
layers — pure description (core.py) → transport (session.py) → client
(api.py) — and two transports (async-canonical + unasync-generated
sync).

Breaking surface changes (see 05-downstream-contract.md for migration):

- PolyswarmRequest is now a @DataClass with keyword fields (method,
  url, params, json, headers, content, data, files, timeout,
  result_parser, parser_kwargs). No .execute() method. After execution
  the .json field is overwritten with the parsed response body
  (legacy semantics preserved).
- parse_response is a pure function. The session executes a request
  and delegates to parse_response — no I/O outside the session.
- The session class gains three I/O methods: execute(request),
  upload_file(url, artifact, …), upload_logo(url, file, content_type,
  …). Custom HTTP behaviour is via subclass + inject via
  PolySwarmAsyncAPI(session=…) / PolyswarmAPI(session=…).
- Module-level upload sites (upload.py, aio/upload.py) are gone; their
  semantics live on the session. Resource instance methods
  ArtifactInstance.upload_file, SandboxTask.upload_file, and the
  presigned-S3 helpers are also gone — call session.upload_file
  directly with the resource's upload_url.
- _bases.py is gone; its shared content (BaseJsonResource, Hashable,
  HttpxResponseAdapter, helpers) moved into the new hand-written
  core.py. aio/core.py is replaced by aio/session.py.

Codegen:

- regenerate_sync.py rename map slimmed (no more
  AsyncPolyswarmRequest, async_upload_file, async_upload_logo).
  Generates session.py + api.py from aio/session.py + aio/api.py.
- engines-property escape hatch unchanged.

Public surface re-exports:

- polyswarm_api.PolyswarmAPI, PolyswarmSession, PolySwarmAsyncAPI,
  AsyncPolyswarmSession.

Testing:

- New test/core_test.py adds 37 pure-unit tests for PolyswarmRequest,
  parse_response (HEAD, 2xx, 204, 404, 422, 429, 500, non-JSON 404/500,
  fire-and-forget), a sampling of resource builders, hash validators,
  and HttpxResponseAdapter.
- async_client_test.py updated to drive the new session.execute /
  session.upload_file surface in the three tests that touched the
  pre-4.0 module-level upload helpers and the session.request method.

Version bumped to 4.0.0.

Total: 118 tests pass; codegen verified byte-stable across two
consecutive runs.
@claude
Copy link
Copy Markdown

claude Bot commented May 26, 2026

Review notes (against AGENTS.md + specs/)

1. Correctness

  • session.upload_logo is dead code. PolySwarmAsyncAPI.report_template_logo_upload (aio/api.py:1454) does self._single(report.upload_logo(...)), which hits the authenticated endpoint via session.execute — it never calls session.upload_logo. Same on the sync side. The method on the session is unused in production. Either remove it from aio/session.py / generated session.py or document it as "future hook only" — right now it's surface area without callers.
  • _sleep(self, seconds) on the client is unused. Both wait_for and report_wait_for call await asyncio.sleep(...) / time.sleep(...) directly. Drop the helper or actually route the polling helpers through it.

2. Spec drift

These are wrong in the new specs/ docs (the code is preserving 3.x behaviour and is therefore right):

  • Exception attribute is .request, not .result. exceptions.RequestException.__init__ sets self.request = request, and session.execute has no try/except that would rename it. Spec 05 §"Public exports/Exceptions" and §"What the session methods do", spec 01 §"Exception model", and spec 02 §"Exceptions thrown by parsing" all promise .result/exc.result = request. Either rename the attribute (breaking — see §3) or fix the four spec sites.
  • PolyswarmRequest.json field, not .json_body. Spec 01 §"Request/response pipeline" and spec 02 §"PolyswarmRequest — the descriptor" / §parse_response refer to request.json_body; the dataclass has json (which is overwritten with the parsed response body by _extract_json_body, see core.py:131-132, core.py:345). Fix the spec.
  • PolyswarmRequest dataclass signature in spec 02 / spec 05 is stale. Real fields (core.py:142-167): api, method, url, params, json, headers, content, data, files, timeout, result_parser, parser_kwargs, …. Spec lists a non-existent stream field and omits api, data, files, timeout. The api= field in particular is load-bearing (resource builders all pass api=api) and contradicts the spec's "transport-agnostic by construction" framing.
  • tag_link_list mis-classified. Spec 03's _paginate table lists it (line 164), but aio/api.py:707 uses return await self._single(...). Move it to the _single table or rewrite the endpoint to actually paginate.
  • session.upload_logo documented as the path for report_template_logo_upload. Spec 03 §"File-upload paths" and spec 05 §"What the session methods do" both promise the logo upload routes through session.upload_logo(upload_url, file, content_type); it doesn't (see §1). Either fix the code or fix the spec.

3. Downstream contract / Gitflow

  • Version bump 3.21.0 → 4.0.0 in a feature PR. pyproject.toml:7, pyproject.toml:56, src/polyswarm_api/__init__.py:2. AGENTS.md §Gitflow: "Don't bump the version inside a feature PR unless the maintainer specifically asks — version bumps belong to the develop → master step." The PR description even says "no version bump in this PR" — but the diff does it. On merge through the eventual develop → master PR this will fire a PyPI release automatically; revert to 3.21.0 in __init__.py, pyproject.toml's version, and [tool.bumpversion].current_version, then let the maintainer drive the bump on the release step.
  • Breaking surface changes — requests-only kwargs, LocalArtifact.upload_file / SandboxTask.upload_file removed, PolyswarmRequest reshape, .aio.upload module gone, etc. — are correctly captured in spec 05 §"Backward compatibility — what changes" but warrant a major bump on the release PR, not this one. (Same point as above — get the bump out of this PR but keep the migration notes.)

4. Test coverage

  • Spec 04 promises a unit test for parse_response's .result/.request attachment on exceptions; core_test.py:221 does assert ei.value.request is req — good, matches the code. But there's no test that the session round-trip attaches the request descriptor (since today the session doesn't — it relies on the exception constructor). If you fix the spec to acknowledge .request, the existing test is fine; if you fix the code to attach .result in session.execute, you'll need a respx test for it.
  • No test exercises session.upload_logo. Combined with §1 (no production caller), the method is fully unverified.
  • PR description's "All 72 tests pass on both transports" — couldn't verify locally, but the parametrised ClientTestCase in metadata_field_properties_test.py only covers the metadata-field-properties surface. Rolling the rest of client_scan_test.py / async_client_test.py onto it is correctly tracked in specs/99-open-questions.md — not blocking, just flagging the gap.

Minor

  • Generated docstrings under polyswarm_api/session.py and polyswarm_api/api.py still talk about "the async transport"/"hand-written async client" — unasync doesn't rewrite string contents. Optional: add a post-processing rewrite, or live with it.
  • The PR description references _bases.py, aio/core.py, aio/upload.py, polyswarm_api/upload.py which don't exist in the diff (the layout consolidated further: now core.py + session.py per side, no upload.py). Refresh the description before merge so reviewers aren't chasing ghosts.

Clean

  • VCR matcher convention switched to match_on=['method', 'scheme', 'host', 'port', 'path', 'query'] in async_client_test.py:35 — matches spec 04. Cassette query-order diffs replay cleanly.
  • Authorization: None header-suppression sentinel is honoured end-to-end (core.py:198-210, session.py:88-99, tested in test_async_session_drops_none_authorization_header).
  • parse_response is genuinely pure and tested directly in core_test.py — the spec 04 "pure-unit tier" claim holds.
  • Codegen + CI staleness gate (.gitlab-ci.yml::test-unasync-mirror) and pre-commit hook are wired correctly. The engines post-processing patch in regenerate_sync.py is a clean exact-text replace with a fail-fast assertion.

Base branch is develop — Gitflow OK except for the version bump above.

Three categories of fixes from the latest claude-bot review.

Code:
- Drop session.upload_logo. Production never called it: the report-
  template logo upload PUTs to the auth'd /reports/templates/logo
  endpoint, not a pre-signed S3 URL, so it rides on session.execute
  via the descriptor ReportTemplate.upload_logo() returns. The dead
  method was surface area without callers.
- Drop the unused PolySwarmAsyncAPI._sleep helper — wait_for and
  report_wait_for call asyncio.sleep / time.sleep directly.
- Revert the version bump 4.0.0 → 3.21.0. AGENTS.md gitflow: version
  bumps belong to the develop → master release PR, not feature PRs.
  pyproject.toml ([project].version + [tool.bumpversion].current_version)
  and src/polyswarm_api/__init__.py:__version__ all back to 3.21.0.
- Make canonical session.py / api.py docstrings transport-neutral so
  the unasync-generated sync mirror reads correctly. Unasync rewrites
  tokens, not string contents, so phrases like "async source lives
  here" leaked into the generated sync file.

Specs (specs were drifting; code was right):
- Exception attribute is .request (set by RequestException.__init__),
  not .result. The session does not catch and rewrap. Fixed in
  01-architecture.md, 02-resources.md, 05-downstream-contract.md.
- PolyswarmRequest field is .json, not .json_body. After execution
  parse_response overwrites the same .json field with the parsed
  response body (legacy 3.x semantics). Fixed in 01 and 02.
- PolyswarmRequest dataclass signature in 02 and 05 was missing
  api / data / files / timeout and listed a nonexistent `stream`
  field. Replaced with the real fields.
- tag_link_list was mis-classified under the _paginate table in 03;
  it actually uses _single (the endpoint returns a single page).
  Moved to the relevant _single table with a note.
- Report-template logo upload was wrongly documented as going through
  session.upload_logo in 03 and 05; it goes through session.execute
  via report.upload_logo() (a normal authenticated PUT). Fixed and
  explicitly called out that session has only two I/O methods now
  (execute + upload_file).

Sync mirrors regenerated; 118 tests still pass; codegen idempotent.
@claude
Copy link
Copy Markdown

claude Bot commented May 27, 2026

Review

Correctness

  • _next_page re-sends the previous page's response body as the next page's JSON request body.
    aio/api.py:178-191 builds the next-page descriptor with json=request.json. But after session.execute, request.json has been overwritten by _extract_json_body with the parsed response dict (per core.py:341-345 and the dataclass docstring at core.py:128-131). So page-2's GET ships page-1's response ({result: […], has_more: …}) as a JSON body. Legacy next_page was safe because it copied the input-only request_parameters dict; the new dataclass overloads .json for input and response state, and _next_page doesn't track the original input. Hidden by VCR (matcher is [method, scheme, host, port, path, query], no body match — see test/vcr/test_async_historical_results.vcr) and tolerated by servers that ignore GET bodies, but it's still wrong. Either snapshot the original json at construction time or skip carrying it forward in _next_page when it wasn't an original input.

Spec drift

  • AGENTS.md §"Architectural shape" and specs/00-overview.md both describe the session as exposing three I/O methods including upload_logo(url, file, ctype, …). It doesn't — aio/session.py / session.py only have execute and upload_file. specs/03-endpoints.md §"Report-template logo upload" and specs/05-downstream-contract.md both correctly state there is no session.upload_logo (logo upload rides on session.execute via ReportTemplate.upload_logo). Reconcile AGENTS.md L67 and 00-overview.md L19+L89 with the rest.

  • core.py:247 docstring for parse_response says "the caller (the session) attaches .result = request to the exception before re-raising." Wrong on both counts: the attached attribute is .request (not .result), and attachment happens at exception construction time in RequestException.__init__, not in the session — exactly as documented two paragraphs up in the same docstring and in specs/01-architecture.md §"Exception model".

Minor

  • resources.py:1195def upload_logo(self, logo_file, content_tpe). content_tpe is a typo; harmless because callers in aio/api.py:1464 pass positionally, but worth fixing for IDE / inspect clarity.

Gitflow / version

Base is develop, no pyproject.toml version bump — correct per AGENTS.md §Gitflow.

sbneto added 2 commits May 26, 2026 21:50
Bot review caught a real correctness bug plus three spec/cleanup items.

Correctness — _next_page clone preserves the input send-body, not the
response body.
- ``PolyswarmRequest.json`` is overloaded: input pre-execute, parsed
  response body post-execute (legacy 3.x semantics, preserved on
  purpose so callers like sandbox_providers can read .json['result']).
  _next_page cloned ``json=request.json`` which, after the first page
  executed, was the response body. Page 2's GET shipped page 1's
  response body as its request body. VCR didn't catch it (matcher
  excludes body) and most servers tolerate ignored GET bodies, but
  it's still wrong.
- Add ``_input_json`` shadow field, set in __post_init__, untouched by
  parse_response. _next_page now clones via ``json=request._input_json``.
- New pure-unit regression test in test/core_test.py confirms the
  snapshot survives a simulated parse_response overwrite of .json.

Spec drift sweep.
- AGENTS.md §"Architectural shape" and specs/00-overview.md (the
  artefact table, the file-tree comments, the architectural-snapshot
  ASCII, and the customization callout) still listed
  session.upload_logo as a third I/O method. Removed everywhere —
  the session has only execute and upload_file.
- core.py parse_response docstring still claimed "the caller (the
  session) attaches `.result = request` to the exception before
  re-raising". Wrong on both counts: the attribute is .request, and
  attachment happens in RequestException.__init__ at construction
  time, not in the session. Rewritten.

Minor.
- resources.py:1195 ``upload_logo(self, logo_file, content_tpe)``
  param typo renamed to content_type. Callers pass positionally so
  no public-surface impact; just IDE / inspect clarity. The legacy
  spelling stays on the api-level ``report_template_logo_upload``
  keyword (explicit back-compat alias documented there).

Sync mirrors regenerated; all 119 tests pass.
Deleted every .vcr cassette and ran the full suite against the local
e2e (http://localhost:9696/v3). 22 cassettes regenerate cleanly under
the new 4.0 transport — they confirm that the new ``PolyswarmRequest``
+ ``session.execute`` pipeline produces wire output the live server
accepts and parses responses correctly. The pre-existing
``test_sandboxtask_get.vcr`` is dropped — it served a disabled test
method (``ytest_sandboxtask_get``).

25 cassettes stay pinned to their prior recordings because the local
e2e currently can't replay them: server-side 500s on metadata-query /
ioc-search / artifact-metadata-list, a 400 on the artifact-metadata
POST, and missing fixture data (ioc id=1, sandbox tasks, historical
hunts, EICAR sample, stream feed, expected ruleset count). These are
data / server-side issues, not SDK issues — the cassettes already on
disk continue to replay cleanly under the new transport. Tracked in
``specs/99-open-questions.md`` with the affected-cassette list and the
required priming work.

Result: full 119-test suite passes against the mix of fresh +
restored cassettes.
@claude
Copy link
Copy Markdown

claude Bot commented May 27, 2026

Reviewed against AGENTS.md + specs/*.md. PR follows the documented three-layer / two-transport architecture cleanly; gitflow is correct (base develop, no version bump). A few actionable items.

Commit history hygiene (AGENTS.md §Commit + PR hygiene)

Don't reference ticket IDs or internal project codes in commit messages, PR titles, or PR descriptions. This repo is public…

Internal ticket DN-8225 appears in five commit bodies:

  • 6d9c8daf refactor(client): introduce PolyswarmAPIBase…
  • 05f2c914 refactor(client): hoist metadata_field_properties_*…
  • 5594d885 docs: document PolyswarmAPIBase architecture + VCR workflow
  • c54c7b3a refactor(client): resource classmethods return unexecuted requests…
  • d9de5ff2 docs: update AGENTS.md to reflect Phase 2 completion…

Commit 5594d885 also violates two other items from the same section:

  • Leaks a contributor's local filesystem path: /home/sam/repos/api-key-management
  • Names a private companion repo (api-key-management) — only polyswarm-cli and artifact-index are listed as public companions in AGENTS.md §Companion repos.

These are baked into the merged history; if this PR is squash-merged the squash commit message can be sanitised, otherwise the existing commits should be rewritten with git rebase before merge.

Docstring drift — sandbox_providers

src/polyswarm_api/aio/api.py:1423 (mirrored to src/polyswarm_api/api.py:1737):

Returns the executed ``PolyswarmRequest`` itself so callers can
read ``.json_body`` (the providers-by-slug envelope).

There is no .json_body attribute on PolyswarmRequest — the dataclass exposes .json (the post-execute body overwrites the send-body kwarg). Spec 02 calls this out explicitly ("There is no separate `.json_body` field.") and spec 99 correctly says .json. Fix the docstring to .json.

Top-level surface vs spec 05

src/polyswarm_api/__init__.py re-exports PolyswarmSession and AsyncPolyswarmSession at the package root and adds both to __all__. Spec 05 §"polyswarm_api" lists only PolyswarmAPI / PolySwarmAsyncAPI at the top level; sessions live under polyswarm_api.session / polyswarm_api.aio. Either trim the re-exports or update the spec — the surface and the contract should agree.

Otherwise the PR is well-aligned with the specs (canonical async + unasync codegen, session-as-only-I/O, # DO NOT EDIT generated files, CI staleness gate, Authorization: None suppression, pure parse_response + core_test.py coverage). No correctness or transport-boundary issues spotted.

@claude
Copy link
Copy Markdown

claude Bot commented May 27, 2026

Review

PR is large but well-aligned with the documented 4.0 architecture (three layers + two transports + async-canonical/unasync-generated sync, per specs/01-architecture.md). Specs are thorough and breaking changes are tracked in specs/05-downstream-contract.md + specs/99-open-questions.md. Gitflow OK: base = develop, no version bump in pyproject.toml (deferred to develop → master per AGENTS.md). Only a handful of action-worthy issues:

Correctness / behaviour

  1. HttpxResponseAdapter buffers the entire response body in memory (core.py:81-86: self._content = response.content). For download, download_to_handle, download_archive, and the stream feed, large artifacts now fully load before iter_content chunks them — a regression vs. the requests-backed streaming path the cassettes were recorded against. The session also doesn't use client.stream(...). Worth fixing or at least documenting (nothing in specs/01-architecture.md §"Request / response pipeline" or 03-endpoints.md §Downloads addresses streaming semantics).

  2. check_known_hosts quietly flipped from list-returning to generator-returning (sync path now goes through _paginate; client_scan_test.py was updated to wrap the call in list(...)). This is a real breaking change on the public surface even though the signature is unchanged. Add it to the explicit-changes list in specs/05-downstream-contract.md §"Backward compatibility — what changes" so it shows up in the 4.0 release notes; the section currently calls out the PolyswarmRequest / upload_file removals but not this return-type flip.

  3. report_template_logo_delete has no respx coverage. _upload and _download got dedicated respx tests in async_client_test.py; the two-step delete flow (GET template → DELETE logo) is exercised only via VCR cassettes that aren't refreshed. If ReportTemplate.delete_logo() ever drifts (e.g. wrong URL or missing id param), nothing fails fast. A short respx test mirroring test_async_report_template_logo_upload would close the gap.

Spec drift / convention

  1. refresh_engine_cache uses an inline dict (aio/api.py:213-224) when resources.Engine.list(self) would produce an identical descriptor — Engine._list_headers already returns {'Authorization': None} (resources.py:53-55). AGENTS.md §"When adding a new resource" and specs/02-resources.md both prefer builders. Either switch to the builder, or note in specs/03-endpoints.md §"Special methods" that the inline-dict form is preserved deliberately here.

  2. specs/01-architecture.md:286 references "PR Consolidate sync + async clients via unasync codegen (httpx-only) #298" — that's this PR. The spec is meant to be a stable architecture doc, not a snapshot; the reference will rot after merge. Drop the parenthetical.

  3. Generated session.py docstring still reads "owns one httpx client (AsyncClient here; Client on the generated sync mirror)" — unasync doesn't rewrite docstring text. Cosmetic, but a reader looking at polyswarm_api/session.py will see a docstring describing the async side. Either rephrase the canonical docstring to be transport-neutral, or have regenerate_sync.py patch it.

Test infra

  1. _AsyncToSync constructs the PolySwarmAsyncAPI once in setUp and then drives every test method via asyncio.run (test/metadata_field_properties_test.py:68-93). Each asyncio.run builds a fresh event loop; the httpx.AsyncClient was built without one. respx intercepts at the transport so this works for the mocked harness, but it's not a pattern that survives swapping respx for live HTTP. Worth a one-line note in specs/04-testing.md so the next person reaching for this harness for an integration test isn't surprised.

All other invariants (canonical-async source of truth, session as the single I/O entry point, Authorization: None suppression honoured, parse_response purity, dataclass descriptor identity, _input_json snapshot for pagination cloning, exception .request attachment in parse_response) check out against the spec.

…p + respx coverage

Bot review found one code cleanup and several spec drift / coverage gaps.

Code:
- refresh_engine_cache now uses resources.Engine.list(self) instead of
  building the descriptor inline. Engine._list_headers already returns
  {'Authorization': None}, so the builder produces the same wire shape.
  Brings the method in line with AGENTS.md §"When adding a new resource"
  and specs/02-resources.md.
- Canonical docstrings in aio/session.py rephrased to be transport-
  neutral ("AsyncClient on the async transport; Client on the sync
  transport"). Reads correctly from both the canonical and generated
  files.

Specs:
- specs/01-architecture.md: dropped the "PR #298" reference (this PR;
  rots after merge). Added a note linking HttpxResponseAdapter to the
  streaming-downloads follow-up in 99-open-questions.
- specs/04-testing.md: added an explicit note that _AsyncToSync is
  respx-only — the harness builds the AsyncClient outside any event
  loop and drives every call via asyncio.run, which doesn't survive a
  live-HTTP integration test (per-call event loop vs. pooled
  AsyncClient connections).
- specs/05-downstream-contract.md: documents two new entries under
  "Backward compatibility — what changes":
    * check_known_hosts now returns a generator on sync too (was a
      list in 3.x due to the polymorphic-return trick; the docstring
      already promised "Generator of IOC resources").
    * Downloads buffer the full body in memory before chunking
      (regression vs. requests-backed 3.x streaming).
- specs/99-open-questions.md: new entry "Streaming downloads —
  HttpxResponseAdapter fully buffers" with concrete refactor proposal
  (use client.stream + async-aware adapter); rewrote the "cassettes
  that need an e2e refresh" entry to document why the 24 stale
  cassettes can't be regenerated cleanly (hard-coded primary keys,
  order-coupled state, count assertions on shared resources, missing
  fixtures, eventual-consistency assertions, surviving server bugs).

Tests:
- Added respx test test_async_report_template_logo_delete pinning the
  two-step flow (GET template → DELETE /reports/templates/logo?id=...).
  Closes the gap the bot flagged: the upload + download paths already
  had respx coverage; the delete path was VCR-only.

VCR cassettes:
- Round-2 re-record pass: deleted all cassettes, re-ran against the
  live e2e. 23 cassettes regenerated cleanly under the new transport
  + the _input_json snapshot fix. 24 stay pinned to their prior
  recordings — the corresponding tests aren't hermetic (hard-coded
  IDs, order-coupled state, etc.). See specs/99-open-questions.md
  for the full breakdown.

Sync mirrors regenerated; all 120 tests pass.
@claude
Copy link
Copy Markdown

claude Bot commented May 27, 2026

Review

Architectural shape, codegen, and test coverage all line up with the specs. Three small issues worth fixing in this PR; gitflow and version-bump policy are clean.

Spec drift — PolySwarmAsyncAPI(key=None, session=None) doesn't raise

specs/05-downstream-contract.md (Constructor signatures): "Providing neither raises InvalidValueException."

src/polyswarm_api/aio/api.py:63-77 falls through to AsyncPolyswarmSession(key=None, …), which then runs if key: hdrs['Authorization'] = key and silently builds an unauthenticated session. The API client never validates that one of key= / session= was provided. The mirror in src/polyswarm_api/api.py:68-82 has the same gap.

Either tighten the constructor (if key is None and session is None: raise InvalidValueException(...)) or relax the spec; right now the contract claim is unenforced.

sandbox_providers docstring references nonexistent .json_body

src/polyswarm_api/aio/api.py:1413-1422 (and the generated src/polyswarm_api/api.py:1727-1736) docstring says callers read .json_body. PolyswarmRequest only has .json (overwritten by the parsed body — see parse_response in core.py). specs/03-endpoints.md:189 correctly documents the access as .json['result'][slug]. Fix the docstring to match.

Spec drift — lookup_uuid URL in specs/02-resources.md

specs/02-resources.md:290 lists lookup_uuid(api, scan) as GET /instance/{id}. The actual builder in resources.py:378-384 issues GET /consumer/submission/{community}/{int(submission_id)}. Cassettes confirm the latter is what ships. Update the spec entry.


Otherwise: layering, session-as-only-transport, _input_json snapshot, Authorization-suppression sentinel + tests, codegen escape-hatch for engines, and the new test/core_test.py pure-unit tier all match the spec invariants. VCR matcher config (['method','scheme','host','port','path','query']) is correct for the async-side cassettes; sync-side default matchers are equivalent per vcrpy.

Refactor the VCR-backed integration suite so every test stands on its
own against a freshly-provisioned artifact-index (make reset-database +
docker/provision.sh). No hard-coded primary keys, no count assertions
on shared resources, no leftover-state dependencies. Each test
provisions exactly what it needs via SDK calls in its body and
captures real identifiers from API responses.

Patterns applied:

- Submit a fresh artifact and use the returned id/sha256 instead of
  pinning to a frozen instance_id from a prior recording.
- For the known-good-host suite, capture the id from add_known_good_host's
  response and feed it into update/delete; tolerate NotFoundException
  in cleanup because the ioc_cache divergence (cache returns stale ids
  the delete endpoint no longer finds) is still upstream-pending.
- For sandbox dispatch, add a small retry helper riding out the
  storage-pointer lag window (submit returns 200 before the storage
  path is committed; sandbox 422s for a few seconds after).
- For historical results / live / sandbox-latest, accept the empty-
  feed outcomes the local e2e produces without a microengine cluster
  via @pytest.mark.skip with explicit upstream reasons.
- For test_rules, switch from "assert len == 1" to a presence check
  so the test tolerates leftover rulesets across runs.

10 cassettes are now @Skip'd against four documented upstream
artifact-index issues (see specs/99-open-questions.md for the full
breakdown):

  1. GET /v3/artifact/metadata/list 500s ("Something went wrong" via
     the middleware catch-all). Affects tool_metadata sync + async.
  2. iocs_by_hash / search_by_ioc return empty even when the matching
     metadata is attached to the instance — either a stale
     get_fields_with_tag memoize cache or extract_iocs not walking the
     cape_sandbox_v2 root. Affects iocs_by_hash + search_by_ioc sync +
     async.
  3. sandbox_task_latest depends on SandboxTaskSearchHash, populated
     only on SUCCEEDED — needs cape/triage workers running. Affects
     sandboxtask_latest sync + async.
  4. live feed requires the microengine bounty pipeline running.
     Affects live sync + async.

specs/99-open-questions.md rewritten with the four issues fully
spelled out (endpoint, what it does, what it should do, where to
look in artifact-index). Cassettes for the four blocked groups are
removed from disk — they'll re-record when the upstream issues are
resolved.

Result: 110 / 110 non-skipped tests pass. 36 cassettes regenerated
cleanly against the freshly-provisioned local e2e. Codegen idempotent.
@claude
Copy link
Copy Markdown

claude Bot commented May 28, 2026

Review

Architecture lines up with specs/01-architecture.md and the contracts in specs/05-downstream-contract.md. Gitflow is correct (base = develop, no version bump — per the documented release flow). Test coverage in test/core_test.py is solid for the pure layer (HEAD / 2xx / 204 / 404 / 422 / 429 / 5xx / non-JSON 4xx&5xx, builder shapes, suppression sentinel, _input_json snapshot, etc.). Sync mirror in session.py matches the canonical async cleanly; CI gates on staleness via test-unasync-mirror.

A handful of spec/code drifts worth fixing in the same PR:

  1. specs/05-downstream-contract.md:119 declares

    def parse_response(response, request: PolyswarmRequest) -> None: ...

    but src/polyswarm_api/core.py:249 is -> PolyswarmRequest and the function returns request at lines 272/299/349. specs/02-resources.md:124 correctly shows -> PolyswarmRequest; just align spec 05.

  2. specs/02-resources.md:28 lists LocalArtifact under BaseJsonResource in the class tree, but src/polyswarm_api/resources.py:489 is class LocalArtifact(core.BaseResource, core.Hashable). This is load-bearing for the issubclass(..., BaseJsonResource) dispatch in core.py:306 (LocalArtifact intentionally takes the non-JSON branch so the response is passed straight to parse_result with iter_content). The tree should put LocalArtifact as a sibling under BaseResource, not under BaseJsonResource.

  3. specs/01-architecture.md:169 documents the pagination clone as

    next_req = dataclasses.replace(req, params={offset, limit, ...})

    but src/polyswarm_api/aio/api.py:163-194 builds a fresh PolyswarmRequest(...) instead — deliberately, because dataclasses.replace would carry over the response-state fields and parse_response has overwritten request.json with the response body. The _input_json snapshot exists exactly to make this work. Either update the spec to describe the explicit clone (and reference _input_json) or drop the dataclasses.replace shorthand.

No correctness bugs found. The pagination semantics (_next_page sending the server-returned offset back as the next-page query param, _consume_results falling through on TypeError for single-item _result, _single returning a generator when an unexpectedly-paginated envelope comes back) are preserved from the 3.x behaviour and match the platform contract documented elsewhere.

artifact-index #1877 fixed the GET /v3/artifact/metadata/list 500
(autobegin + bigint-vs-varchar). With a restart of the local e2e the
fix is live, so test_tool_metadata (sync + async) is un-skipped:

- Submits to provision a real instance.
- POSTs two tool_metadata blobs.
- Polls tool_metadata_list (the formerly-broken endpoint) until both
  rows surface from the asynchronous persist_external_metadata Celery
  task. 30s timeout is comfortable; Celery roundtrip is ~3s in
  isolation and can stretch under suite load.

For the remaining IOC tests (iocs_by_hash + search_by_ioc), the skip
reason is refined now that the memoize-cache theory has been ruled out
by direct in-process verification: get_fields_with_tag returns the 17
ip-ioc paths correctly and extract_iocs walks the matching tool_metadata
to the IP. The bug is somewhere between the HTTP view's input shape
and the extract_iocs call — most likely the search-by-hash fallback
(SandboxTaskSearchHash empty → last_scanned_instance) resolves to a
sibling instance that doesn't carry the freshly-attached
cape_sandbox_v2 metadata.

Final tally: 112 / 120 endpoint tests pass against a fresh e2e. 8
skip with explicit upstream reasons (4 unique tests × sync+async). 38
cassettes regenerated cleanly; replay run also green.
@claude
Copy link
Copy Markdown

claude Bot commented May 28, 2026

Review — clean against the documented conventions

I read AGENTS.md and the seven specs, then walked the PR. The 4.0 refactor matches the specs it ships alongside: pure core.py + resources.py, hand-written canonical async in aio/, generated sync mirrors with the # DO NOT EDIT header, single I/O entry point (session.execute / session.upload_file), session-injection customization, the engines post-processing escape hatch. Gitflow is honored (base = develop, __init__.py / pyproject.toml stay at 3.21.0 — version bump deferred to the develop → master step per AGENTS.md). The PR description correctly avoids internal repo names and ticket IDs.

A few small things worth surfacing — none blocking:

Minor / footguns

  • PolyswarmRequest.__post_init__ snapshots self._input_json = self.json by reference, not deep copy (core.py:177). If a caller mutates the original send-body dict between construction and _next_page cloning, both copies move. No current call-site triggers it, but it's the kind of thing that bites later — consider copy.copy (shallow is enough for the typical dict case) on the snapshot.
  • _to_request silently drops unknown keys from inline request-dicts (aio/api.py:113). For an internal coercion this is fine, but a typo'd key ("jsons" instead of "json") becomes a silent drop rather than a TypeError. Cheap to harden if you want.
  • The inline-dict endpoints in submit / sandbox_file / sandbox_url / metadata_field_properties_write send null for unset optionals (e.g. aliases: None), while the resource-builder convention in _params (core.py:498) drops None. Documented as "inline" in specs/03-endpoints.md but worth noting the wire shape diverges from the builder path — fine if the server is tolerant.
  • _single returns either req._result or an async generator (aio/api.py:132-136) depending on whether the response was paginated. This is preserved from 3.x for tag_link_list-style calls and documented in specs/03-endpoints.md and specs/05-downstream-contract.md, but the heterogeneous return type is the kind of contract that's hard to type-hint. Worth a future cleanup pass, not this PR.

Tracked in 99-open-questions and acknowledged

  • HttpxResponseAdapter buffers response.content eagerly (core.py:85), regressing the streaming-download memory profile. Documented.
  • Four upstream-blocked test groups (tool_metadata is now un-skipped per artifact-index #1877; iocs_by_hash/search_by_ioc, sandboxtask_latest, live remain @pytest.mark.skip with rationale). Documented.
  • ClientTestCase rollout from metadata_field_properties_test.py to the rest of the VCR suites is still pending. Documented.

Nothing in the changeset contradicts a spec invariant; no breaking change is undocumented; the codegen mechanic and the engines post-processing patch are wired with a fail-fast assertion (scripts/regenerate_sync.py:148) and CI gating (.gitlab-ci.yml::test-unasync-mirror).

LGTM.

… + slow Celery)

The skip reasons were wrong. Two non-bug behaviours combined:

1. filter_known_good_iocs correctly drops IPs that are marked
   known-good. The original test used 1.2.3.4 — that IP gets added
   and deleted by test_check_known_host, but artifact-index's
   app.ioc_cache invalidates async via long_running.reload_ioc_cache,
   so the cache keeps serving "known-good" long after the DB row is
   gone. The IOC view's filter then drops the IP from the response.
   This is correct security behaviour; the test just picked an
   unfortunate IP. Switching to fixed IPs in the 9.42.0.0/24 range
   (public IBM netblock, never touched by other tests) avoids the
   filter.

2. persist_external_metadata (Celery task) takes ~30s under suite
   load (single worker, multi-queue contention). The original poll
   was 15s. Bumped to 60s.

Both tests now self-provision and pass cleanly. Cassettes use the
fixed IPs so VCR record→replay matches deterministically.

Final tally: 116 passing / 4 skipped (sandbox_task_latest + live,
sync+async — both environment-dependent: sandbox needs cape/triage
workers running, live needs the microengine bounty pipeline). 42
cassettes regenerated and replay green.
@claude
Copy link
Copy Markdown

claude Bot commented May 28, 2026

Review against AGENTS.md + specs/

Architecture, descriptor/parse split, session-as-transport, codegen, and the public surface all line up with the specs (01-architecture.md invariants 1–8, 02-resources.md invariants 1–6, 05-downstream-contract.md invariants 1–6). Base = develop, no version bump — gitflow-compliant. Generated mirror header + ruff-formatted + engines-property post-process all match regenerate_sync.py.

One issue worth fixing before this lands.

1. Commit-message hygiene — leaks to public master on merge

AGENTS.md §"Commit + PR hygiene":

Don't reference ticket IDs or internal project codes in commit messages…
Don't name private companion repos in PR descriptions or commit messages on this repo.

Both are violated in commits that are about to enter public history:

  • Internal ticket ID leak. DN-8225 appears in 5 commit bodies (6d9c8da, 05f2c91, 5594d88, c54c7b3, d9de5ff).
  • Private path + repo leak. 5594d88 ("docs: document PolyswarmAPIBase architecture…") contains: Cross-reference the worked akm example in '/home/sam/repos/api-key-management' — both the maintainer-local absolute path and the internal repo name.

Rewrite those commit messages (interactive rebase / git filter-repo) before merge — once it's on developmaster the bodies are permanent in public log.

2. Everything else

  • Spec drift: none I can see. sandbox_url finalize-param mismatch (sandbox_task_id vs id) and sandbox_providers executed-request quirk are both already documented in 99-open-questions.md as preserved-for-back-compat.
  • Downstream contract: removals (upload_file instance methods, module-level async_upload_file, PolyswarmRequest constructor shape, polyswarm_api.core.PolyswarmSessionpolyswarm_api.session) are all listed under 05-downstream-contract.md §"Backward compatibility — what changes" with migration examples. Correctly deferred to the develop → master major bump.
  • Tests: core_test.py covers parse_response, dataclass projections, _input_json snapshot, and a builder sampling. _next_page's param-cloning (especially the list-of-tuples branch used by Metadata) and _consume_results' singleton-via-TypeError dispatch don't have unit coverage — both are foundational to pagination correctness and are pure functions of their inputs. Worth adding before the parametrised harness rollout from 99-open-questions.md lands.
  • Codegen: regenerate_sync.py REPLACEMENTS map's asyncio → time rule is now load-bearing — the inline comment at line 53 warns about asyncio.X for any X other than sleep producing nonsense; that's the right place for it.

🤖 Generated with Claude Code

…live + cold-index tests

The e2e now runs the released artifact-index image (the metadata/list
ro_session + bigint-cast fix), so tool_metadata round-trips and the
suite can be recorded against real data again.

- live (sync+async): bound the feed with since= (seconds) so the
  cassette no longer pages the entire global feed — test_live drops
  from ~3MB/1355 interactions to ~72K/43. Wrap the hunt lifecycle in
  try/finally (live_stop then ruleset_delete, both tolerant) so a
  mid-test failure can't leave an active hunt capturing every later
  EICAR submit — that leak is what accumulated 700+ stale LiveResult
  rows and bloated the cassette in the first place.
- metadata_search (sync+async): was a one-shot list() that assumed
  instant ES indexing; now polls (the ES metadata write indexes in ~1s
  idle but lags under full-suite Celery load, so a generous window).
- one-shot searches gained a shared _poll_results helper to ride out
  cold-index latency on a freshly-provisioned e2e.
- stream + search_by_ioc (sync+async): skipped with precise reasons —
  genuine e2e-capability gaps, not SDK bugs. The archiver service that
  feeds /stream and the reverse-IOC search index behind /v3/search/ioc
  don't run in the local container stack (forward iocs_by_hash indexes
  in ~28s; the reverse never populates past 90s). Same class as the
  pre-existing sandbox_task_latest skip (needs cape/triage workers).

Result: 115 passed, 5 skipped; replay green; total cassettes
5.7MB -> 652KB; codegen mirror unchanged.
@claude
Copy link
Copy Markdown

claude Bot commented May 29, 2026

Review against AGENTS.md / specs/

Major architectural shape lines up cleanly with specs/01-architecture.md — three layers, single descriptor, session-as-only-I/O, canonical async + generated sync mirror. Public surface in __init__.py, the [async] extras, exceptions hierarchy, and resource shapes are all preserved per specs/05-downstream-contract.md. Base = develop, no version bump in this PR — matches gitflow.

A handful of action items, ordered by severity.

Correctness

  1. Constructor accepts neither key= nor session= silently. aio/api.py:62-77 (and the sync mirror): when both are None, the code constructs AsyncPolyswarmSession(None, ...) without an Authorization header. specs/05-downstream-contract.md:200 says "Providing neither raises InvalidValueException." Either add the guard or update the spec — at minimum, decide which is the contract.

  2. _single is polymorphic on return type. aio/api.py:132-136 returns req._result for non-paginated and an async-generator (_consume_results(req)) when req._paginated is True. parse_response sets _paginated=True for any GET whose body carries has_more (core.py:309-310), so a method that uses _single will quietly start returning a generator the moment the server adds has_more to a payload. This is exactly the kind of footgun specs/01-architecture.md:286-293 claims the 4.0 redesign retired. Either gate _single on a non-paginated assertion (raise if _paginated), or document the polymorphism explicitly.

  3. Empty paginated result yields a None. aio/api.py:151-158: when request._result is None (server returns {"result": null, "has_more": false}), for item in request._result raises TypeError, falls into the except and yield request._result — callers iterating get a single None instead of zero items. Worth either guarding with if request._result is None: return or filtering in the _paginate wrapper.

Spec drift

  1. specs/01-architecture.md:169 / :249-252 says _next_page uses dataclasses.replace(req, ...), but the implementation at aio/api.py:163-198 does explicit construction so it can substitute request._input_json and reset the response-state slots. The implementation is correct (cloning via replace would carry response-state forward); the spec wording should follow.

Style / minor

  1. exists() uses str(result) == "200" (aio/api.py:1576). The HEAD branch in parse_response sets _result = response.status_code (an int), so result == 200 is the cleaner comparison.

  2. historical_update docstring (aio/api.py:542-549) says "Cancel a historical hunt" / "The deleted HistoricalHunt resource" — but it is an UPDATE call. Pre-existing copy from historical_delete; worth fixing while the file is open.

Strengths

  • test/core_test.py is exactly the pure-unit tier specs/04-testing.md:14 calls for — 37 builder/parser tests, no httpx, no fixtures.
  • test_async_upload_helper_strips_session_authorization and test_async_report_template_logo_upload pin both upload paths (off-domain S3 vs authenticated PolySwarm endpoint) per specs/03-endpoints.md:241-243.
  • scripts/regenerate_sync.py fails fast (patch_engines_property) if the canonical engines block drifts — good safety net for the one escape hatch.
  • Header suppression sentinel (None-valued header → strip), _input_json snapshot, and the HttpxResponseAdapter are consistent with the descriptor-purity invariant.

Nothing blocking — the constructor mismatch (1) and the _single polymorphism (2) are worth a follow-up before this lands.

…threshold

The stream feed wasn't empty because the archiver is missing; it's
threshold-triggered. The e2e sets ARTIFACT_ARCHIVES_INSTANCE_COUNT=3
with ARCHIVES_CREATION_DELAY=30s, so an archive is created only once
the accumulated instance count *exceeds* 3. The earlier single submit
never crossed it (and the 4-min timeout path hadn't elapsed), so the
feed looked empty.

Submit 5 EICAR to cross the >3 threshold, then poll the stream feed
(generous window to cover the ~30s creation delay) and download +
verify the archive. Consume only the first archive via next(iter(...))
rather than list(api.stream()) so paging the growing archive feed
doesn't bloat the cassette (44K, one stream page).

116 passed, 4 skipped (search_by_ioc x2 — reverse IOC index not backed
locally; sandbox_task_latest x2 — needs cape/triage workers).
@claude
Copy link
Copy Markdown

claude Bot commented May 29, 2026

Reviewed against AGENTS.md + specs/. Architecturally clean — gitflow respected (base develop, no version bump), three-layer split matches specs/01-architecture.md, codegen pipeline and CI staleness check are in place, breaking-change inventory in specs/05-downstream-contract.md covers the surface deltas. Findings below are minor spec/code drift or pre-existing quirks worth either fixing or surfacing in the open-questions list before this lands.

CORRECTNESS

  • PolyswarmAPI() with neither key= nor session= does not raise (src/polyswarm_api/aio/api.py:71-77). The session constructor accepts key=None and just omits the Authorization header. specs/05-downstream-contract.md:200 explicitly says: 'Providing neither raises InvalidValueException.' Either enforce the spec in the constructor (raise when both are None) or relax the spec. My read: enforce, because the silent-no-auth client is a footgun.
  • _to_request silently drops result_parser= / parser_kwargs= when handed a pre-built PolyswarmRequest (src/polyswarm_api/aio/api.py:107-130). The dict path consumes them; the descriptor path returns the input unchanged. Either honour the override (dataclasses.replace(request, result_parser=..., parser_kwargs=...) when caller passed them) or assert that both are None in the descriptor branch. As written, self._single(resources.X.get(...), result_parser=Y) will not override Y, which is surprising.
  • HEAD short-circuits before the non-2xx branch (src/polyswarm_api/core.py:264-272). A HEAD 500 becomes request._result = 500, no exception, and exists() returns False (api.py:1576). Documented in specs/01-architecture.md:212 but worth surfacing: server errors on exists are silently coerced to 'absent'. Pre-existing semantics from 3.x — flag in 99-open-questions.md rather than silently inherit.

SPEC DRIFT

  • specs/05-downstream-contract.md Public-exports table (lines 35-42) omits PolyswarmSession and AsyncPolyswarmSession, but src/polyswarm_api/init.py:11-17 puts both in all. Either add them to the spec table or trim all. Spec also lists 'api: module' / 'exceptions: module' as exports; those work via submodule access but are not in all — minor, but worth aligning either direction.
  • specs/01-architecture.md:169 / 252 references dataclasses.replace for pagination cloning, but _next_page constructs a fresh PolyswarmRequest explicitly (aio/api.py:184-197). Both work; the spec is wrong on the mechanic. The reason explicit construction was chosen (probably to control _input_json re-snapshotting) is non-obvious — capture in the spec or switch to dataclasses.replace.
  • metadata_field_properties_* methods use inline dict requests (aio/api.py:255-295) instead of the resources.MetadataFieldProperties.get / .update / .delete / .list builders that already exist and would produce identical descriptors. AGENTS.md:80-86 describes the builder-based shape as preferred. Either rewrite the canonical methods or document the inline form as the deliberate sample in specs/03-endpoints.md. As-is the file mixes the two conventions side-by-side without explanation.

DOWNSTREAM CONTRACT

  • The 4.0 breakage list is well-captured (resource-instance upload methods removed, module-level upload callables removed, PolyswarmRequest dataclass shape change, check_known_hosts returns generator on sync). One subtle item missing from specs/05-downstream-contract.md Backward-compatibility section: engines is now a property that raises AttributeError on the async client. hasattr(async_api, 'engines') flips from True to False. Should be in the migration notes alongside the rest.

TEST COVERAGE

  • session.upload_file retry exhaustion path is not exercised. test/core_test.py has 37 unit tests but none drive the session at all (intentional — pure unit). The transient-error retry loop in aio/session.py:139-155 (catches HTTPStatusError / TransportError, re-raises last after attempts) has no test that asserts the last exception is re-raised, or that all attempts iterations seek-and-re-read the artifact. A targeted respx test for 'always-503 raises last HTTPStatusError, body re-read N times' would cover the retry contract.
  • to_request dict-path has no shape assertion. test/core_test.py::TestResourceBuilders covers the resource-builder side; the inline-dict side (used by metadata_mapping, metadata_field_properties*, submit, sandbox_file, sandbox_url) has no equivalent pure-unit coverage. Cheap to add.
  • engines AttributeError on async is not asserted anywhere. TestAsyncEngineCache exercises refresh_engine_cache + reading _engines but does not pin the contract that async_api.engines raises. One-line test guards against accidentally regressing the escape-hatch the codegen depends on.

GITFLOW

Clean. Base develop, version untouched (init.py + pyproject.toml stay at 3.21.0), version bump deferred to the develop → master step as AGENTS.md Gitflow section requires.

Both the forward (iocs_by_hash) and reverse (search_by_ioc) IOC tests
attach an artificial cape_sandbox_v2 blob via tool_metadata_create
rather than waiting on a real sandbox run — the end-to-end stack has no
cape sandbox worker, so the metadata pipeline never produced one. With
the stack's Elasticsearch flush interval now at 5s, the reverse search
(which reads Elasticsearch) resolves in seconds and no longer needs to
be skipped.

- Forward: keep the shared EICAR fixture. The forward view resolves a
  hash via the sandbox-task search index (seeded only for EICAR) and
  reads that instance's metadata, so the mock blob surfaces directly.
- Reverse: submit a unique, deterministic artifact. The ES document is
  built from the search row's last_scanned_instance, and a real sandbox
  task would overwrite cape_sandbox_v2 in it — so a fresh sha (sole
  instance, no sandbox task) is required for the mock blob to survive.
  The submit response returns sha256=None for a brand-new artifact, so
  hash the bytes locally, and poll until our sha resolves (not merely
  any result, which an ip reused across artifacts can transiently
  return).

Re-recorded the four IOC cassettes against the live stack.
@claude
Copy link
Copy Markdown

claude Bot commented May 29, 2026

Review

Scoped against AGENTS.md + specs/. Architecture, codegen wiring, and the spec set itself all line up. Base is develop, no version bump, generated header on sync mirrors, engines property patched in by regenerate_sync.py — gitflow + downstream-contract rules are respected. Findings below are correctness / spec-drift items worth resolving in-PR.

Correctness

  1. PolyswarmSession.upload_file(attempts=0) raises TypeError. aio/session.py:139 initialises last_exc = None; if a subclass / caller passes attempts=0 the loop never runs and the final raise last_exc is raise None. Guard attempts < 1 or raise a typed exception so subclasses get a sane error.

  2. _single can silently return a generator. aio/api.py:132-136 returns self._consume_results(req) whenever req._paginated — and parse_response sets _paginated=True for any GET whose body contains has_more (core.py:309-310). A server response that accidentally grows a has_more key flips a documented "single resource" endpoint into a generator at the caller. Either gate paginated detection to _paginate callers or strip the auto-promotion in _single. Contradicts the _single vs _paginate invariant in specs/03-endpoints.md.

  3. Duplicate state-checks across resource + api. BundleTask.download_zip / ReportTask.download_report / ReportLLMPostProcessing.download_report raise on state in ('PENDING','FAILED') (resources.py:1083-1086, 1115-1118, 1146-1149), AND the api methods report_download / sample_bundle_download re-check the same states (aio/api.py:1472-1479, 1493-1500). specs/02-resources.md §"Resource-instance methods that issue HTTP" documents the resource-side check as the contract; the api-side duplicate is dead code (and a custom caller calling task.download_zip(...) directly already gets the right error). Drop the duplicate from the api methods or vice-versa.

  4. _to_request silently drops result_parser / parser_kwargs for pre-built descriptors. aio/api.py:107-112 short-circuits if isinstance(request, PolyswarmRequest), but _single / _paginate callers may also pass result_parser= / extra kwargs. Today nothing hits the combo, but it's a footgun for downstream subclasses that wrap a builder. Either error or override.

Spec drift

  1. key=None, session=None does not raise. specs/05-downstream-contract.md:200 says "Providing neither raises InvalidValueException." Code (aio/api.py:71-77, sync mirror at api.py:76-82) just constructs AsyncPolyswarmSession(None, …), producing an unauthenticated client. Either soften the spec (auth-less calls are intentionally supported) or add the guard.

  2. engines private-attribute access path is part of the user surface but the name is _engines. specs/03-endpoints.md:188 instructs async users to "call refresh_engine_cache() then read self._engines." The leading-underscore is a public-API smell; either rename to engines_cache (4.0 is the cycle to do this) or hoist the convention into the contract spec explicitly so downstream code can rely on it without lint flags.

Test coverage

  1. No test pins PolySwarmAsyncAPI.engines raising AttributeError. The codegen ENGINES_ASYNC-block match in scripts/regenerate_sync.py:147-152 guards drift in the canonical block text, but no pure-unit test asserts the public-surface behaviour ("property raises on async, returns cached list on sync"). A 3-line test/core_test.py addition covers it.

  2. No test exercises _single falling through to _consume_results when a server response carries has_more. Pairs with finding updated kwargs to match main args and point to correct consumer. READ… #2 — would have caught the auto-promotion.

Housekeeping

  1. test/client_scan_test.py:643 ytest_sandboxtask_get typo still present (pre-existing on develop). This PR deletes the corresponding test/vcr/test_sandboxtask_get.vcr, so the dead test is now also missing its cassette. Either fix the name and re-record, or delete the test body.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants