From cbe2eefa09c8bacfd17b358dc5e0e7e7cb1d9a0c Mon Sep 17 00:00:00 2001 From: michael Date: Tue, 26 May 2026 08:15:35 -0700 Subject: [PATCH 01/16] send community when creating report --- src/polyswarm_api/api.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/polyswarm_api/api.py b/src/polyswarm_api/api.py index 7f780708..d95061f0 100644 --- a/src/polyswarm_api/api.py +++ b/src/polyswarm_api/api.py @@ -1068,7 +1068,8 @@ def llm_report_create(self, instance_id=None, cape_sandbox_task_id=None, triage_ report_task = resources.ReportLLMPostProcessing.create(self, instance_id=instance_id, cape_sandbox_task_id=cape_sandbox_task_id, - triage_sandbox_task_id=triage_sandbox_task_id).result() + triage_sandbox_task_id=triage_sandbox_task_id, + communtiy=self.community).result() return report_task def llm_report_get(self, report_task_id): From 39894985b382e12fe3d813a3eadfa14aae2afec4 Mon Sep 17 00:00:00 2001 From: michael Date: Tue, 26 May 2026 08:21:49 -0700 Subject: [PATCH 02/16] typo --- src/polyswarm_api/api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/polyswarm_api/api.py b/src/polyswarm_api/api.py index d95061f0..11497f2c 100644 --- a/src/polyswarm_api/api.py +++ b/src/polyswarm_api/api.py @@ -1069,7 +1069,7 @@ def llm_report_create(self, instance_id=None, cape_sandbox_task_id=None, triage_ instance_id=instance_id, cape_sandbox_task_id=cape_sandbox_task_id, triage_sandbox_task_id=triage_sandbox_task_id, - communtiy=self.community).result() + community=self.community).result() return report_task def llm_report_get(self, report_task_id): From 5c46f5485e8b46f17d98d85e9d2232507b3b3f47 Mon Sep 17 00:00:00 2001 From: michael Date: Tue, 26 May 2026 08:28:37 -0700 Subject: [PATCH 03/16] apply fix to async also --- src/polyswarm_api/aio/__init__.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/polyswarm_api/aio/__init__.py b/src/polyswarm_api/aio/__init__.py index 6ff520b9..92f52467 100644 --- a/src/polyswarm_api/aio/__init__.py +++ b/src/polyswarm_api/aio/__init__.py @@ -1729,6 +1729,8 @@ async def llm_report_create( json_params["cape_sandbox_task_id"] = str(int(cape_sandbox_task_id)) if triage_sandbox_task_id is not None: json_params["triage_sandbox_task_id"] = str(int(triage_sandbox_task_id)) + if self.community: + json_params["community"] = self.community return await self._single( { "method": "POST", @@ -1757,6 +1759,7 @@ async def llm_report_download(self, report_task_id, folder): "method": "GET", "url": report.download_url, "headers": {"Authorization": None}, + "params": {"community": self.community}, }, result_parser=resources.LocalArtifact, folder=folder, From 2d7fb41a8a91d92598c8a1c28bc937b0b7329fac Mon Sep 17 00:00:00 2001 From: michael Date: Tue, 26 May 2026 08:46:10 -0700 Subject: [PATCH 04/16] add tests --- src/polyswarm_api/aio/__init__.py | 15 ++-- test/async_client_test.py | 111 +++++++++++++++++++++++++++++- 2 files changed, 121 insertions(+), 5 deletions(-) diff --git a/src/polyswarm_api/aio/__init__.py b/src/polyswarm_api/aio/__init__.py index 92f52467..744eeea6 100644 --- a/src/polyswarm_api/aio/__init__.py +++ b/src/polyswarm_api/aio/__init__.py @@ -1729,8 +1729,7 @@ async def llm_report_create( json_params["cape_sandbox_task_id"] = str(int(cape_sandbox_task_id)) if triage_sandbox_task_id is not None: json_params["triage_sandbox_task_id"] = str(int(triage_sandbox_task_id)) - if self.community: - json_params["community"] = self.community + json_params["community"] = self.community return await self._single( { "method": "POST", @@ -1752,14 +1751,22 @@ async def llm_report_get(self, report_task_id): ) async def llm_report_download(self, report_task_id, folder): - """Download a completed LLM report.""" + """Download a completed LLM report. + + NOTE (issue 4): ReportLLMPostProcessing sets self.url, not self.download_url. + This call will raise AttributeError until that resource is fixed to expose + download_url (alias self.download_url = content['url'] in its __init__), or + this call site is changed to report.url. + """ report = await self.llm_report_get(report_task_id) + # Presigned S3 URL: omit Authorization header and do NOT append extra query + # params — community was already sent on the metadata GET above, and adding + # params after SigV4 signing breaks X-Amz-Signature (SignatureDoesNotMatch). req = await self._exec( { "method": "GET", "url": report.download_url, "headers": {"Authorization": None}, - "params": {"community": self.community}, }, result_parser=resources.LocalArtifact, folder=folder, diff --git a/test/async_client_test.py b/test/async_client_test.py index 29a17efc..44fc0f37 100644 --- a/test/async_client_test.py +++ b/test/async_client_test.py @@ -13,13 +13,18 @@ pip install -e ".[async,tests]" pytest test/async_client_test.py -v """ +import json as _json +import tempfile +from unittest.mock import patch +from urllib.parse import urlparse, parse_qs + import pytest import httpx import respx import vcr as vcr_ from polyswarm_api.aio import PolySwarmAsyncAPI -from polyswarm_api import exceptions +from polyswarm_api import exceptions, resources # ── VCR setup (mirrors client_scan_test.py, adds match_on for httpx compat) ── @@ -452,3 +457,107 @@ async def test_async_context_manager(): async with PolySwarmAsyncAPI(API_KEY, uri=BASE_URL, community='gamma') as api: result = [r async for r in api.search(SHA256)] assert result[0].sha256 == SHA256 + + +# ── LLM Report regression tests (fixes 1 & 2) ─────────────────────────────── + +_LLM_REPORT_RESULT = { + 'id': 99, + 'community': 'gamma', + 'created': '2024-01-01T00:00:00', + 'state': 'SUCCEEDED', + 'url': 'https://s3.amazonaws.com/bucket/report.pdf?X-Amz-Signature=abc123', + 'report': {}, + 'instance_id': '12345678901234567', + 'cape_sandbox_task_id': None, + 'triage_sandbox_task_id': None, +} + + +@respx.mock +async def test_llm_report_create_includes_community(): + """Regression for Fix 2: community must always be present in the + llm_report_create POST body, unconditionally. + + The prior code gated it on ``if self.community:`` — a dead branch because + ``__init__`` always falls back to ``settings.DEFAULT_COMMUNITY``. If a + future refactor re-introduces the guard, the server-side community routing + silently breaks for any consumer that relies on community-scoped reports. + """ + INSTANCE_ID = '12345678901234567' + + post_route = respx.post(f'{BASE_URL}/reports/llm').mock( + return_value=httpx.Response(200, json={ + 'status': 'OK', + 'result': _LLM_REPORT_RESULT, + }) + ) + + async with PolySwarmAsyncAPI(API_KEY, uri=BASE_URL, community='gamma') as api: + await api.llm_report_create(instance_id=INSTANCE_ID) + + sent_body = _json.loads(post_route.calls[0].request.content) + assert 'community' in sent_body, ( + 'community key is missing from llm_report_create POST body' + ) + assert sent_body['community'] == 'gamma', ( + f"Expected community='gamma', got {sent_body.get('community')!r}" + ) + + +@respx.mock +async def test_llm_report_download_no_community_on_s3_url(): + """Regression for Fix 1: presigned-S3 download must NOT append extra query + params (e.g. ``community``) to the URL. + + Appending query parameters after SigV4 signing invalidates the + ``X-Amz-Signature`` and causes ``SignatureDoesNotMatch`` from S3. + The community is already supplied on the metadata GET (``llm_report_get``); + the second hop to S3 must be a clean GET with no additional params. + + Note: this test patches ``ReportLLMPostProcessing.download_url`` to work + around issue 4 (the resource exposes ``self.url`` but the call site reads + ``report.download_url``). Remove the patch once issue 4 is resolved. + """ + PRESIGNED_URL = 'https://s3.amazonaws.com/bucket/report.pdf?X-Amz-Signature=abc123' + REPORT_TASK_ID = '99' + + # Mock the metadata GET (llm_report_get) + respx.get(f'{BASE_URL}/reports/llm').mock( + return_value=httpx.Response(200, json={ + 'status': 'OK', + 'result': _LLM_REPORT_RESULT, + }) + ) + + # Mock the presigned S3 download — capture the request for inspection + s3_route = respx.get(PRESIGNED_URL).mock( + return_value=httpx.Response( + 200, + content=b'%PDF-1.4 test', + headers={'content-disposition': 'attachment; filename=report.pdf'}, + ) + ) + + with tempfile.TemporaryDirectory() as tmp_dir: + # Patch download_url onto the resource class to work around issue 4 + # (ReportLLMPostProcessing only sets self.url, not self.download_url). + # create=True is required because the attribute doesn't exist yet. + with patch.object( + resources.ReportLLMPostProcessing, + 'download_url', + new=property(lambda self: self.url), + create=True, + ): + async with PolySwarmAsyncAPI(API_KEY, uri=BASE_URL, community='gamma') as api: + await api.llm_report_download(REPORT_TASK_ID, tmp_dir) + + assert s3_route.called, 'S3 presigned URL was never requested' + + s3_request = s3_route.calls[0].request + parsed = urlparse(str(s3_request.url)) + query_params = parse_qs(parsed.query) + assert 'community' not in query_params, ( + f'community must not be appended to presigned S3 URL after SigV4 signing; ' + f'got query params: {dict(query_params)}' + ) From 6cdffa49ce5b6bd4d5a14241d65820f3426cdc7f Mon Sep 17 00:00:00 2001 From: michael Date: Tue, 26 May 2026 08:54:48 -0700 Subject: [PATCH 05/16] fix download bug --- src/polyswarm_api/aio/__init__.py | 10 ++------ test/async_client_test.py | 25 ++++---------------- test/client_scan_test.py | 38 +++++++++++++++++++++++++++++++ 3 files changed, 45 insertions(+), 28 deletions(-) diff --git a/src/polyswarm_api/aio/__init__.py b/src/polyswarm_api/aio/__init__.py index 744eeea6..8fd44177 100644 --- a/src/polyswarm_api/aio/__init__.py +++ b/src/polyswarm_api/aio/__init__.py @@ -1751,13 +1751,7 @@ async def llm_report_get(self, report_task_id): ) async def llm_report_download(self, report_task_id, folder): - """Download a completed LLM report. - - NOTE (issue 4): ReportLLMPostProcessing sets self.url, not self.download_url. - This call will raise AttributeError until that resource is fixed to expose - download_url (alias self.download_url = content['url'] in its __init__), or - this call site is changed to report.url. - """ + """Download a completed LLM report.""" report = await self.llm_report_get(report_task_id) # Presigned S3 URL: omit Authorization header and do NOT append extra query # params — community was already sent on the metadata GET above, and adding @@ -1765,7 +1759,7 @@ async def llm_report_download(self, report_task_id, folder): req = await self._exec( { "method": "GET", - "url": report.download_url, + "url": report.url, "headers": {"Authorization": None}, }, result_parser=resources.LocalArtifact, diff --git a/test/async_client_test.py b/test/async_client_test.py index 44fc0f37..6cce7e54 100644 --- a/test/async_client_test.py +++ b/test/async_client_test.py @@ -15,7 +15,6 @@ """ import json as _json import tempfile -from unittest.mock import patch from urllib.parse import urlparse, parse_qs import pytest @@ -24,7 +23,7 @@ import vcr as vcr_ from polyswarm_api.aio import PolySwarmAsyncAPI -from polyswarm_api import exceptions, resources +from polyswarm_api import exceptions # ── VCR setup (mirrors client_scan_test.py, adds match_on for httpx compat) ── @@ -479,9 +478,8 @@ async def test_llm_report_create_includes_community(): """Regression for Fix 2: community must always be present in the llm_report_create POST body, unconditionally. - The prior code gated it on ``if self.community:`` — a dead branch because - ``__init__`` always falls back to ``settings.DEFAULT_COMMUNITY``. If a - future refactor re-introduces the guard, the server-side community routing + The prior async code omitted community from the POST body entirely. + If a future change drops the field again, server-side community routing silently breaks for any consumer that relies on community-scoped reports. """ INSTANCE_ID = '12345678901234567' @@ -514,10 +512,6 @@ async def test_llm_report_download_no_community_on_s3_url(): ``X-Amz-Signature`` and causes ``SignatureDoesNotMatch`` from S3. The community is already supplied on the metadata GET (``llm_report_get``); the second hop to S3 must be a clean GET with no additional params. - - Note: this test patches ``ReportLLMPostProcessing.download_url`` to work - around issue 4 (the resource exposes ``self.url`` but the call site reads - ``report.download_url``). Remove the patch once issue 4 is resolved. """ PRESIGNED_URL = 'https://s3.amazonaws.com/bucket/report.pdf?X-Amz-Signature=abc123' REPORT_TASK_ID = '99' @@ -540,17 +534,8 @@ async def test_llm_report_download_no_community_on_s3_url(): ) with tempfile.TemporaryDirectory() as tmp_dir: - # Patch download_url onto the resource class to work around issue 4 - # (ReportLLMPostProcessing only sets self.url, not self.download_url). - # create=True is required because the attribute doesn't exist yet. - with patch.object( - resources.ReportLLMPostProcessing, - 'download_url', - new=property(lambda self: self.url), - create=True, - ): - async with PolySwarmAsyncAPI(API_KEY, uri=BASE_URL, community='gamma') as api: - await api.llm_report_download(REPORT_TASK_ID, tmp_dir) + async with PolySwarmAsyncAPI(API_KEY, uri=BASE_URL, community='gamma') as api: + await api.llm_report_download(REPORT_TASK_ID, tmp_dir) assert s3_route.called, 'S3 presigned URL was never requested' diff --git a/test/client_scan_test.py b/test/client_scan_test.py index 1b2ccc32..631f705f 100644 --- a/test/client_scan_test.py +++ b/test/client_scan_test.py @@ -1,3 +1,4 @@ +import json as _json import os import shutil import tempfile @@ -413,3 +414,40 @@ def test_sample(self): assert {'cape', 'triage'} <= set(result.sandbox.keys()) assert isinstance(result.tasks, dict) assert {'artifact_instance', 'llm_report', 'sandbox_cape', 'sandbox_triage'} <= set(result.tasks.keys()) + + @responses.activate + def test_llm_report_create_includes_community(self): + """Regression: community must always be present in the llm_report_create POST body. + + The prior async code omitted community from the POST body entirely; this test + covers the sync path to ensure the same field is present there and that a future + refactor on either side cannot silently drop it. + """ + responses.add( + responses.POST, + f'http://localhost:9696/{self.api_version}/reports/llm', + json={ + 'status': 'OK', + 'result': { + 'id': 99, + 'community': 'gamma', + 'created': '2024-01-01T00:00:00', + 'state': 'SUCCEEDED', + 'url': 'https://s3.amazonaws.com/bucket/report.pdf', + 'report': {}, + 'instance_id': '12345678901234567', + 'cape_sandbox_task_id': None, + 'triage_sandbox_task_id': None, + }, + }, + ) + + api = PolyswarmAPI(self.test_api_key, uri=f'http://localhost:9696/{self.api_version}', community='gamma') + api.llm_report_create(instance_id='12345678901234567') + + assert len(responses.calls) == 1 + sent_body = _json.loads(responses.calls[0].request.body) + assert 'community' in sent_body, 'community key is missing from llm_report_create POST body' + assert sent_body['community'] == 'gamma', ( + f"Expected community='gamma', got {sent_body.get('community')!r}" + ) From 7a94414fdd36742d7f3779aef457aa3491c1c6f0 Mon Sep 17 00:00:00 2001 From: michael Date: Wed, 24 Jun 2026 09:34:54 -0700 Subject: [PATCH 06/16] fix: test spec compliant --- test/async_client_test.py | 92 --------------------------------------- test/client_scan_test.py | 42 ------------------ test/core_test.py | 53 ++++++++++++++++++++++ 3 files changed, 53 insertions(+), 134 deletions(-) diff --git a/test/async_client_test.py b/test/async_client_test.py index 6aafa221..e57a7435 100644 --- a/test/async_client_test.py +++ b/test/async_client_test.py @@ -20,8 +20,6 @@ import os import tempfile from contextlib import contextmanager -from urllib.parse import urlparse, parse_qs - import pytest import httpx import respx @@ -1287,96 +1285,6 @@ async def test_async_context_manager(): assert result[0].sha256 == SHA256 -# ── LLM Report regression tests (fixes 1 & 2) ─────────────────────────────── - -_LLM_REPORT_RESULT = { - 'id': 99, - 'community': 'gamma', - 'created': '2024-01-01T00:00:00', - 'state': 'SUCCEEDED', - 'url': 'https://s3.amazonaws.com/bucket/report.pdf?X-Amz-Signature=abc123', - 'report': {}, - 'instance_id': '12345678901234567', - 'cape_sandbox_task_id': None, - 'triage_sandbox_task_id': None, -} - - -@respx.mock -async def test_llm_report_create_includes_community(): - """Regression for Fix 2: community must always be present in the - llm_report_create POST body, unconditionally. - - The prior async code omitted community from the POST body entirely. - If a future change drops the field again, server-side community routing - silently breaks for any consumer that relies on community-scoped reports. - """ - INSTANCE_ID = '12345678901234567' - - post_route = respx.post(f'{BASE_URL}/reports/llm').mock( - return_value=httpx.Response(200, json={ - 'status': 'OK', - 'result': _LLM_REPORT_RESULT, - }) - ) - - async with PolySwarmAsyncAPI(API_KEY, uri=BASE_URL, community='gamma') as api: - await api.llm_report_create(instance_id=INSTANCE_ID) - - sent_body = json.loads(post_route.calls[0].request.content) - assert 'community' in sent_body, ( - 'community key is missing from llm_report_create POST body' - ) - assert sent_body['community'] == 'gamma', ( - f"Expected community='gamma', got {sent_body.get('community')!r}" - ) - - -@respx.mock -async def test_llm_report_download_no_community_on_s3_url(): - """Regression for Fix 1: presigned-S3 download must NOT append extra query - params (e.g. ``community``) to the URL. - - Appending query parameters after SigV4 signing invalidates the - ``X-Amz-Signature`` and causes ``SignatureDoesNotMatch`` from S3. - The community is already supplied on the metadata GET (``llm_report_get``); - the second hop to S3 must be a clean GET with no additional params. - """ - PRESIGNED_URL = 'https://s3.amazonaws.com/bucket/report.pdf?X-Amz-Signature=abc123' - REPORT_TASK_ID = '99' - - # Mock the metadata GET (llm_report_get) - respx.get(f'{BASE_URL}/reports/llm').mock( - return_value=httpx.Response(200, json={ - 'status': 'OK', - 'result': _LLM_REPORT_RESULT, - }) - ) - - # Mock the presigned S3 download — capture the request for inspection - s3_route = respx.get(PRESIGNED_URL).mock( - return_value=httpx.Response( - 200, - content=b'%PDF-1.4 test', - headers={'content-disposition': 'attachment; filename=report.pdf'}, - ) - ) - - with tempfile.TemporaryDirectory() as tmp_dir: - async with PolySwarmAsyncAPI(API_KEY, uri=BASE_URL, community='gamma') as api: - await api.llm_report_download(REPORT_TASK_ID, tmp_dir) - - assert s3_route.called, 'S3 presigned URL was never requested' - - s3_request = s3_route.calls[0].request - parsed = urlparse(str(s3_request.url)) - query_params = parse_qs(parsed.query) - assert 'community' not in query_params, ( - f'community must not be appended to presigned S3 URL after SigV4 signing; ' - f'got query params: {dict(query_params)}' - ) - - # ── Pagination (no cassette) — multi-page walk + runaway safety bound ────────── def _instance(sha): diff --git a/test/client_scan_test.py b/test/client_scan_test.py index 5ccfedd3..d6fa7fb2 100644 --- a/test/client_scan_test.py +++ b/test/client_scan_test.py @@ -904,45 +904,3 @@ def test_sample(self): # e2e (no LLM), so assert it was triggered, not finished. assert result.tasks['llm_report']['requested_status'] not in _PRE_TRIGGER - @respx.mock - def test_llm_report_create_includes_community(self): - """Regression: community must always be present in the llm_report_create POST body. - - The prior async code omitted community from the POST body entirely; this test - covers the sync path to ensure the same field is present there and that a future - refactor on either side cannot silently drop it. - """ - post_route = respx.post( - f'http://localhost:9696/{self.api_version}/reports/llm' - ).mock( - return_value=httpx.Response(200, json={ - 'status': 'OK', - 'result': { - 'id': 99, - 'community': 'gamma', - 'created': '2024-01-01T00:00:00', - 'state': 'SUCCEEDED', - 'url': 'https://s3.amazonaws.com/bucket/report.pdf', - 'report': {}, - 'instance_id': '12345678901234567', - 'cape_sandbox_task_id': None, - 'triage_sandbox_task_id': None, - }, - }) - ) - - api = PolyswarmAPI( - self.test_api_key, - uri=f'http://localhost:9696/{self.api_version}', - community='gamma', - ) - api.llm_report_create(instance_id='12345678901234567') - - assert post_route.called, 'llm_report_create POST was never sent' - sent_body = json.loads(post_route.calls[0].request.content) - assert 'community' in sent_body, ( - 'community key is missing from llm_report_create POST body' - ) - assert sent_body['community'] == 'gamma', ( - f"Expected community='gamma', got {sent_body.get('community')!r}" - ) diff --git a/test/core_test.py b/test/core_test.py index 4fbcb133..0e397d9c 100644 --- a/test/core_test.py +++ b/test/core_test.py @@ -398,6 +398,59 @@ def test_basejsonresource_get_delete_list_create_update(self): assert resources.LLMPromptConfig.update(api, id='1', name='n').method == 'PUT' assert resources.LLMPromptConfig.delete(api, id='1').method == 'DELETE' + def test_llm_report_create_community_in_post_body(self): + """community must be present in the llm_report_create POST body. + + This is a pure-unit builder test: ``ReportLLMPostProcessing.create`` + is a shared, transport-agnostic builder so a single assertion here + covers both the sync and async transports simultaneously. The respx + round-trip (removed from client_scan_test / async_client_test) added + nothing that this builder assertion doesn't express — and without the + fabricated 200 response. + """ + api = _FakeApi() + req = resources.ReportLLMPostProcessing.create( + api, + instance_id='12345678901234567', + community='gamma', + ) + assert req.method == 'POST' + assert req.result_parser is resources.ReportLLMPostProcessing + # community must land in the JSON body (POST), not the query string + assert req.input_json.get('community') == 'gamma' + assert req.params is None or 'community' not in (req.params or {}) + + def test_llm_report_download_strips_auth_and_sends_raw_url(self): + """download_report() must use the presigned URL verbatim with no + extra params, and must strip Authorization. + + Appending params (e.g. ``community``) after SigV4 signing would + invalidate ``X-Amz-Signature``. This is a pure-unit shape test on + the resource-level builder, covering the request without any HTTP I/O. + """ + api = _FakeApi() + presigned = 'https://s3.amazonaws.com/bucket/report.pdf?X-Amz-Signature=abc' + task = resources.ReportLLMPostProcessing( + { + 'id': 99, + 'community': 'gamma', + 'created': '2024-01-01T00:00:00', + 'state': 'SUCCEEDED', + 'url': presigned, + 'report': {}, + 'instance_id': '12345678901234567', + 'cape_sandbox_task_id': None, + 'triage_sandbox_task_id': None, + }, + api=api, + ) + req = task.download_report(folder='/tmp') + assert req.method == 'GET' + assert req.url == presigned # exact URL, nothing appended + assert req.params is None # community must NOT be in the query + assert req.headers == {'Authorization': None} + assert req.suppressed_headers() == {'Authorization'} + # ── Helpers ──────────────────────────────────────────────────────── From 44894eb7bdaabd83f25c4f37b7312b37116aaf5b Mon Sep 17 00:00:00 2001 From: michael Date: Thu, 25 Jun 2026 16:39:22 -0700 Subject: [PATCH 07/16] update feed names --- specs/02-resources.md | 2 +- test/async_client_test.py | 6 +++--- test/client_scan_test.py | 6 +++--- test/known_good_test.py | 8 ++++---- test/vcr/test_async_known_good_lifecycle.vcr | 6 +++--- test/vcr/test_known_good_lifecycle.vcr | 6 +++--- 6 files changed, 17 insertions(+), 17 deletions(-) diff --git a/specs/02-resources.md b/specs/02-resources.md index b7780b61..a9817f9f 100644 --- a/specs/02-resources.md +++ b/specs/02-resources.md @@ -288,7 +288,7 @@ Wraps a single scan instance. Carries `id`, `sha256`, `upload_url`, the assertio **`known_good` / `known_good_sources`.** When the server flags this sha256 as a known-good binary, the response carries a `known_good` array — one `{tool, tool_metadata, created, updated}` entry per flagging feed (`nsrl`, -`winbindex`, `winget`). `ArtifactInstance.known_good` is that raw list (or `None` +`microsoft`, `commercial`). `ArtifactInstance.known_good` is that raw list (or `None` for a normal artifact / a server too old to emit the field — parsed with `.get()`, so older recorded responses parse to `None` with no behaviour change), and `known_good_sources` is the sorted, de-duplicated list of feed names derived from diff --git a/test/async_client_test.py b/test/async_client_test.py index 026b3fc3..a278cab3 100644 --- a/test/async_client_test.py +++ b/test/async_client_test.py @@ -410,12 +410,12 @@ async def test_async_known_good_lifecycle(self, uid): assert created.sources == ['nsrl'] assert created.artifact_instance_id # A second feed flagging the same sha extends the same entry (no new row). - extended = await api.known_good_create(sha256=sha, source='winget') + extended = await api.known_good_create(sha256=sha, source='commercial') assert extended.id == created.id - assert extended.sources == ['nsrl', 'winget'] + assert extended.sources == ['nsrl', 'commercial'] got = await api.known_good_get(sha256=sha) assert got.sha256 == sha - assert got.sources == ['nsrl', 'winget'] + assert got.sources == ['nsrl', 'commercial'] deleted = await api.known_good_delete(sha256=sha) assert deleted.sha256 == sha with pytest.raises(exceptions.NotFoundException): diff --git a/test/client_scan_test.py b/test/client_scan_test.py index 86b0aae4..f7183628 100644 --- a/test/client_scan_test.py +++ b/test/client_scan_test.py @@ -775,12 +775,12 @@ def test_known_good_lifecycle(self): assert created.sources == ['nsrl'] assert created.artifact_instance_id # A second feed flagging the same sha extends the same entry (no new row). - extended = v3api.known_good_create(sha256=sha, source='winget') + extended = v3api.known_good_create(sha256=sha, source='commercial') assert extended.id == created.id - assert extended.sources == ['nsrl', 'winget'] + assert extended.sources == ['nsrl', 'commercial'] got = v3api.known_good_get(sha256=sha) assert got.sha256 == sha - assert got.sources == ['nsrl', 'winget'] + assert got.sources == ['nsrl', 'commercial'] deleted = v3api.known_good_delete(sha256=sha) assert deleted.sha256 == sha with pytest.raises(exceptions.NotFoundException): diff --git a/test/known_good_test.py b/test/known_good_test.py index dbe196ab..6c8ae4ab 100644 --- a/test/known_good_test.py +++ b/test/known_good_test.py @@ -68,12 +68,12 @@ class TestKnownGoodParsing: def test_parses_full_row(self): row = {'id': '12345678901234567', 'sha256': SHA, 'artifact_instance_id': '98765432109876543', - 'sources': ['nsrl', 'winget'], 'created': '2026-06-11T00:00:00'} + 'sources': ['nsrl', 'commercial'], 'created': '2026-06-11T00:00:00'} kg = resources.KnownGood(row) assert kg.id == '12345678901234567' assert kg.sha256 == SHA assert kg.artifact_instance_id == '98765432109876543' - assert kg.sources == ['nsrl', 'winget'] + assert kg.sources == ['nsrl', 'commercial'] assert kg.created.year == 2026 def test_parses_minimal_delete_row(self): @@ -103,7 +103,7 @@ class TestArtifactInstanceKnownGoodField: def test_known_good_feeds_are_parsed_and_sources_derived(self): feeds = [ - {'tool': 'winget', 'tool_metadata': {'product': 'Example'}, + {'tool': 'commercial', 'tool_metadata': {'product': 'Example'}, 'created': '2026-06-11T00:00:00', 'updated': '2026-06-11T00:00:00'}, {'tool': 'nsrl', 'tool_metadata': {}, 'created': '2026-06-11T00:00:00', 'updated': '2026-06-11T00:00:00'}, @@ -112,7 +112,7 @@ def test_known_good_feeds_are_parsed_and_sources_derived(self): # The raw feed list is preserved verbatim... assert inst.known_good == feeds # ...and the feed (source) names are exposed sorted + de-duplicated. - assert inst.known_good_sources == ['nsrl', 'winget'] + assert inst.known_good_sources == ['nsrl', 'commercial'] def test_absent_known_good_parses_to_none(self): # Older servers omit the field entirely (additive, backward-compatible): diff --git a/test/vcr/test_async_known_good_lifecycle.vcr b/test/vcr/test_async_known_good_lifecycle.vcr index 5455fc09..36eb448b 100644 --- a/test/vcr/test_async_known_good_lifecycle.vcr +++ b/test/vcr/test_async_known_good_lifecycle.vcr @@ -46,7 +46,7 @@ interactions: code: 200 message: OK - request: - body: '{"sha256":"d474e08e835f8e538f8aeb6944b4bc73a31db2be96625b0bdfe0ab8bf239226b","source":"winget","community":"gamma"}' + body: '{"sha256":"d474e08e835f8e538f8aeb6944b4bc73a31db2be96625b0bdfe0ab8bf239226b","source":"commercial","community":"gamma"}' headers: accept: - '*/*' @@ -68,7 +68,7 @@ interactions: uri: http://artifact-index-e2e:9696/v3/known-good response: body: - string: '{"result":{"artifact_instance_id":"71168226319048067","created":"2026-06-12T22:01:46.708413+00:00","id":"86356499935604495","sha256":"d474e08e835f8e538f8aeb6944b4bc73a31db2be96625b0bdfe0ab8bf239226b","sources":["nsrl","winget"]},"status":"OK"} + string: '{"result":{"artifact_instance_id":"71168226319048067","created":"2026-06-12T22:01:46.708413+00:00","id":"86356499935604495","sha256":"d474e08e835f8e538f8aeb6944b4bc73a31db2be96625b0bdfe0ab8bf239226b","sources":["nsrl","commercial"]},"status":"OK"} ' headers: @@ -110,7 +110,7 @@ interactions: uri: http://artifact-index-e2e:9696/v3/known-good?sha256=d474e08e835f8e538f8aeb6944b4bc73a31db2be96625b0bdfe0ab8bf239226b&community=gamma response: body: - string: '{"result":{"artifact_instance_id":"71168226319048067","created":"2026-06-12T22:01:46.708413+00:00","id":"86356499935604495","sha256":"d474e08e835f8e538f8aeb6944b4bc73a31db2be96625b0bdfe0ab8bf239226b","sources":["nsrl","winget"]},"status":"OK"} + string: '{"result":{"artifact_instance_id":"71168226319048067","created":"2026-06-12T22:01:46.708413+00:00","id":"86356499935604495","sha256":"d474e08e835f8e538f8aeb6944b4bc73a31db2be96625b0bdfe0ab8bf239226b","sources":["nsrl","commercial"]},"status":"OK"} ' headers: diff --git a/test/vcr/test_known_good_lifecycle.vcr b/test/vcr/test_known_good_lifecycle.vcr index 771a893c..fad07af1 100644 --- a/test/vcr/test_known_good_lifecycle.vcr +++ b/test/vcr/test_known_good_lifecycle.vcr @@ -46,7 +46,7 @@ interactions: code: 200 message: OK - request: - body: '{"sha256":"9c0258ed4cc98056773bcf0c57fe4bc79618802357f0703c503b4cb172a263df","source":"winget","community":"gamma"}' + body: '{"sha256":"9c0258ed4cc98056773bcf0c57fe4bc79618802357f0703c503b4cb172a263df","source":"commercial","community":"gamma"}' headers: accept: - '*/*' @@ -68,7 +68,7 @@ interactions: uri: http://artifact-index-e2e:9696/v3/known-good response: body: - string: '{"result":{"artifact_instance_id":"90917448071940878","created":"2026-06-12T22:01:46.572994+00:00","id":"18577785917417703","sha256":"9c0258ed4cc98056773bcf0c57fe4bc79618802357f0703c503b4cb172a263df","sources":["nsrl","winget"]},"status":"OK"} + string: '{"result":{"artifact_instance_id":"90917448071940878","created":"2026-06-12T22:01:46.572994+00:00","id":"18577785917417703","sha256":"9c0258ed4cc98056773bcf0c57fe4bc79618802357f0703c503b4cb172a263df","sources":["nsrl","commercial"]},"status":"OK"} ' headers: @@ -110,7 +110,7 @@ interactions: uri: http://artifact-index-e2e:9696/v3/known-good?sha256=9c0258ed4cc98056773bcf0c57fe4bc79618802357f0703c503b4cb172a263df&community=gamma response: body: - string: '{"result":{"artifact_instance_id":"90917448071940878","created":"2026-06-12T22:01:46.572994+00:00","id":"18577785917417703","sha256":"9c0258ed4cc98056773bcf0c57fe4bc79618802357f0703c503b4cb172a263df","sources":["nsrl","winget"]},"status":"OK"} + string: '{"result":{"artifact_instance_id":"90917448071940878","created":"2026-06-12T22:01:46.572994+00:00","id":"18577785917417703","sha256":"9c0258ed4cc98056773bcf0c57fe4bc79618802357f0703c503b4cb172a263df","sources":["nsrl","commercial"]},"status":"OK"} ' headers: From 7838334b4744615847346a32b26daa1c566c612a Mon Sep 17 00:00:00 2001 From: michael Date: Thu, 25 Jun 2026 17:01:48 -0700 Subject: [PATCH 08/16] missed --- test/known_good_test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/known_good_test.py b/test/known_good_test.py index 6c8ae4ab..ba42a5c2 100644 --- a/test/known_good_test.py +++ b/test/known_good_test.py @@ -112,7 +112,7 @@ def test_known_good_feeds_are_parsed_and_sources_derived(self): # The raw feed list is preserved verbatim... assert inst.known_good == feeds # ...and the feed (source) names are exposed sorted + de-duplicated. - assert inst.known_good_sources == ['nsrl', 'commercial'] + assert inst.known_good_sources == ['commercial', 'nsrl'] def test_absent_known_good_parses_to_none(self): # Older servers omit the field entirely (additive, backward-compatible): From 614825bde9985e8de81459f5ef1e32fe6d6a6058 Mon Sep 17 00:00:00 2001 From: michael Date: Fri, 26 Jun 2026 09:18:34 -0700 Subject: [PATCH 09/16] fix --- test/async_client_test.py | 4 ++-- test/client_scan_test.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/test/async_client_test.py b/test/async_client_test.py index a278cab3..66a245df 100644 --- a/test/async_client_test.py +++ b/test/async_client_test.py @@ -412,10 +412,10 @@ async def test_async_known_good_lifecycle(self, uid): # A second feed flagging the same sha extends the same entry (no new row). extended = await api.known_good_create(sha256=sha, source='commercial') assert extended.id == created.id - assert extended.sources == ['nsrl', 'commercial'] + assert extended.sources == ['commercial', 'nsrl'] got = await api.known_good_get(sha256=sha) assert got.sha256 == sha - assert got.sources == ['nsrl', 'commercial'] + assert got.sources == ['commercial', 'nsrl'] deleted = await api.known_good_delete(sha256=sha) assert deleted.sha256 == sha with pytest.raises(exceptions.NotFoundException): diff --git a/test/client_scan_test.py b/test/client_scan_test.py index f7183628..bfe7a620 100644 --- a/test/client_scan_test.py +++ b/test/client_scan_test.py @@ -777,10 +777,10 @@ def test_known_good_lifecycle(self): # A second feed flagging the same sha extends the same entry (no new row). extended = v3api.known_good_create(sha256=sha, source='commercial') assert extended.id == created.id - assert extended.sources == ['nsrl', 'commercial'] + assert extended.sources == ['commercial', 'nsrl'] got = v3api.known_good_get(sha256=sha) assert got.sha256 == sha - assert got.sources == ['nsrl', 'commercial'] + assert got.sources == ['commercial', 'nsrl'] deleted = v3api.known_good_delete(sha256=sha) assert deleted.sha256 == sha with pytest.raises(exceptions.NotFoundException): From efea1badc4b99025aa42231388f9fad219070e10 Mon Sep 17 00:00:00 2001 From: michael Date: Fri, 26 Jun 2026 09:57:45 -0700 Subject: [PATCH 10/16] fix tests --- test/async_client_test.py | 4 ++-- test/client_scan_test.py | 4 ++-- test/known_good_test.py | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/test/async_client_test.py b/test/async_client_test.py index 66a245df..a0a8bbf3 100644 --- a/test/async_client_test.py +++ b/test/async_client_test.py @@ -412,10 +412,10 @@ async def test_async_known_good_lifecycle(self, uid): # A second feed flagging the same sha extends the same entry (no new row). extended = await api.known_good_create(sha256=sha, source='commercial') assert extended.id == created.id - assert extended.sources == ['commercial', 'nsrl'] + assert extended.sources.sort() == ['commercial', 'nsrl'] got = await api.known_good_get(sha256=sha) assert got.sha256 == sha - assert got.sources == ['commercial', 'nsrl'] + assert got.sources.sort() == ['commercial', 'nsrl'] deleted = await api.known_good_delete(sha256=sha) assert deleted.sha256 == sha with pytest.raises(exceptions.NotFoundException): diff --git a/test/client_scan_test.py b/test/client_scan_test.py index bfe7a620..02d00cf0 100644 --- a/test/client_scan_test.py +++ b/test/client_scan_test.py @@ -777,10 +777,10 @@ def test_known_good_lifecycle(self): # A second feed flagging the same sha extends the same entry (no new row). extended = v3api.known_good_create(sha256=sha, source='commercial') assert extended.id == created.id - assert extended.sources == ['commercial', 'nsrl'] + assert extended.sources.sort() == ['commercial', 'nsrl'] got = v3api.known_good_get(sha256=sha) assert got.sha256 == sha - assert got.sources == ['commercial', 'nsrl'] + assert got.sources.sort() == ['commercial', 'nsrl'] deleted = v3api.known_good_delete(sha256=sha) assert deleted.sha256 == sha with pytest.raises(exceptions.NotFoundException): diff --git a/test/known_good_test.py b/test/known_good_test.py index ba42a5c2..80eaa020 100644 --- a/test/known_good_test.py +++ b/test/known_good_test.py @@ -73,7 +73,7 @@ def test_parses_full_row(self): assert kg.id == '12345678901234567' assert kg.sha256 == SHA assert kg.artifact_instance_id == '98765432109876543' - assert kg.sources == ['nsrl', 'commercial'] + assert kg.sources.sort() == ['commercial', 'nsrl'] assert kg.created.year == 2026 def test_parses_minimal_delete_row(self): @@ -112,7 +112,7 @@ def test_known_good_feeds_are_parsed_and_sources_derived(self): # The raw feed list is preserved verbatim... assert inst.known_good == feeds # ...and the feed (source) names are exposed sorted + de-duplicated. - assert inst.known_good_sources == ['commercial', 'nsrl'] + assert inst.known_good_sources.sort() == ['commercial', 'nsrl'] def test_absent_known_good_parses_to_none(self): # Older servers omit the field entirely (additive, backward-compatible): From d42ecf73292073c18d6cc9473ba2dffe6b922ce9 Mon Sep 17 00:00:00 2001 From: michael Date: Fri, 26 Jun 2026 10:24:03 -0700 Subject: [PATCH 11/16] sorted --- test/async_client_test.py | 4 ++-- test/client_scan_test.py | 4 ++-- test/known_good_test.py | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/test/async_client_test.py b/test/async_client_test.py index a0a8bbf3..6c67b753 100644 --- a/test/async_client_test.py +++ b/test/async_client_test.py @@ -412,10 +412,10 @@ async def test_async_known_good_lifecycle(self, uid): # A second feed flagging the same sha extends the same entry (no new row). extended = await api.known_good_create(sha256=sha, source='commercial') assert extended.id == created.id - assert extended.sources.sort() == ['commercial', 'nsrl'] + assert sorted(extended.sources) == ['commercial', 'nsrl'] got = await api.known_good_get(sha256=sha) assert got.sha256 == sha - assert got.sources.sort() == ['commercial', 'nsrl'] + assert sorted(got.sources) == ['commercial', 'nsrl'] deleted = await api.known_good_delete(sha256=sha) assert deleted.sha256 == sha with pytest.raises(exceptions.NotFoundException): diff --git a/test/client_scan_test.py b/test/client_scan_test.py index 02d00cf0..225a12a4 100644 --- a/test/client_scan_test.py +++ b/test/client_scan_test.py @@ -777,10 +777,10 @@ def test_known_good_lifecycle(self): # A second feed flagging the same sha extends the same entry (no new row). extended = v3api.known_good_create(sha256=sha, source='commercial') assert extended.id == created.id - assert extended.sources.sort() == ['commercial', 'nsrl'] + assert sorted(extended.sources) == ['commercial', 'nsrl'] got = v3api.known_good_get(sha256=sha) assert got.sha256 == sha - assert got.sources.sort() == ['commercial', 'nsrl'] + assert sorted(got.sources) == ['commercial', 'nsrl'] deleted = v3api.known_good_delete(sha256=sha) assert deleted.sha256 == sha with pytest.raises(exceptions.NotFoundException): diff --git a/test/known_good_test.py b/test/known_good_test.py index 80eaa020..2e690083 100644 --- a/test/known_good_test.py +++ b/test/known_good_test.py @@ -73,7 +73,7 @@ def test_parses_full_row(self): assert kg.id == '12345678901234567' assert kg.sha256 == SHA assert kg.artifact_instance_id == '98765432109876543' - assert kg.sources.sort() == ['commercial', 'nsrl'] + assert sorted(kg.sources) == ['commercial', 'nsrl'] assert kg.created.year == 2026 def test_parses_minimal_delete_row(self): @@ -112,7 +112,7 @@ def test_known_good_feeds_are_parsed_and_sources_derived(self): # The raw feed list is preserved verbatim... assert inst.known_good == feeds # ...and the feed (source) names are exposed sorted + de-duplicated. - assert inst.known_good_sources.sort() == ['commercial', 'nsrl'] + assert inst.known_good_sources == ['commercial', 'nsrl'] def test_absent_known_good_parses_to_none(self): # Older servers omit the field entirely (additive, backward-compatible): From a087d92a66352e82cd8d163bd04cd5fac20e1d2b Mon Sep 17 00:00:00 2001 From: michael Date: Mon, 29 Jun 2026 11:30:16 -0700 Subject: [PATCH 12/16] regenerate vcrs --- test/vcr/test_async_known_good_lifecycle.vcr | 34 ++++++++++---------- test/vcr/test_known_good_lifecycle.vcr | 32 +++++++++--------- 2 files changed, 33 insertions(+), 33 deletions(-) diff --git a/test/vcr/test_async_known_good_lifecycle.vcr b/test/vcr/test_async_known_good_lifecycle.vcr index 36eb448b..f9f28cf2 100644 --- a/test/vcr/test_async_known_good_lifecycle.vcr +++ b/test/vcr/test_async_known_good_lifecycle.vcr @@ -17,12 +17,12 @@ interactions: host: - artifact-index-e2e:9696 user-agent: - - polyswarm_api/4.0.0 (x86_64-Linux-CPython-3.14.4) + - polyswarm_api/4.1.0 (x86_64-Linux-CPython-3.12.3) method: POST uri: http://artifact-index-e2e:9696/v3/known-good response: body: - string: '{"result":{"artifact_instance_id":"71168226319048067","created":"2026-06-12T22:01:46.708413+00:00","id":"86356499935604495","sha256":"d474e08e835f8e538f8aeb6944b4bc73a31db2be96625b0bdfe0ab8bf239226b","sources":["nsrl"]},"status":"OK"} + string: '{"result":{"artifact_instance_id":"24417037636304243","created":"2026-06-29T18:29:53.872924+00:00","id":"7418765811658834","sha256":"d474e08e835f8e538f8aeb6944b4bc73a31db2be96625b0bdfe0ab8bf239226b","sources":["nsrl"]},"status":"OK"} ' headers: @@ -33,11 +33,11 @@ interactions: Connection: - keep-alive Content-Length: - - '235' + - '234' Content-Type: - application/json Date: - - Fri, 12 Jun 2026 22:01:46 GMT + - Mon, 29 Jun 2026 18:29:53 GMT Server: - gunicorn X-Billing-ID: @@ -57,18 +57,18 @@ interactions: connection: - keep-alive content-length: - - '115' + - '119' content-type: - application/json host: - artifact-index-e2e:9696 user-agent: - - polyswarm_api/4.0.0 (x86_64-Linux-CPython-3.14.4) + - polyswarm_api/4.1.0 (x86_64-Linux-CPython-3.12.3) method: POST uri: http://artifact-index-e2e:9696/v3/known-good response: body: - string: '{"result":{"artifact_instance_id":"71168226319048067","created":"2026-06-12T22:01:46.708413+00:00","id":"86356499935604495","sha256":"d474e08e835f8e538f8aeb6944b4bc73a31db2be96625b0bdfe0ab8bf239226b","sources":["nsrl","commercial"]},"status":"OK"} + string: '{"result":{"artifact_instance_id":"24417037636304243","created":"2026-06-29T18:29:53.872924+00:00","id":"7418765811658834","sha256":"d474e08e835f8e538f8aeb6944b4bc73a31db2be96625b0bdfe0ab8bf239226b","sources":["commercial","nsrl"]},"status":"OK"} ' headers: @@ -79,11 +79,11 @@ interactions: Connection: - keep-alive Content-Length: - - '244' + - '247' Content-Type: - application/json Date: - - Fri, 12 Jun 2026 22:01:46 GMT + - Mon, 29 Jun 2026 18:29:54 GMT Server: - gunicorn X-Billing-ID: @@ -105,12 +105,12 @@ interactions: host: - artifact-index-e2e:9696 user-agent: - - polyswarm_api/4.0.0 (x86_64-Linux-CPython-3.14.4) + - polyswarm_api/4.1.0 (x86_64-Linux-CPython-3.12.3) method: GET uri: http://artifact-index-e2e:9696/v3/known-good?sha256=d474e08e835f8e538f8aeb6944b4bc73a31db2be96625b0bdfe0ab8bf239226b&community=gamma response: body: - string: '{"result":{"artifact_instance_id":"71168226319048067","created":"2026-06-12T22:01:46.708413+00:00","id":"86356499935604495","sha256":"d474e08e835f8e538f8aeb6944b4bc73a31db2be96625b0bdfe0ab8bf239226b","sources":["nsrl","commercial"]},"status":"OK"} + string: '{"result":{"artifact_instance_id":"24417037636304243","created":"2026-06-29T18:29:53.872924+00:00","id":"7418765811658834","sha256":"d474e08e835f8e538f8aeb6944b4bc73a31db2be96625b0bdfe0ab8bf239226b","sources":["commercial","nsrl"]},"status":"OK"} ' headers: @@ -121,11 +121,11 @@ interactions: Connection: - keep-alive Content-Length: - - '244' + - '247' Content-Type: - application/json Date: - - Fri, 12 Jun 2026 22:01:46 GMT + - Mon, 29 Jun 2026 18:29:54 GMT Server: - gunicorn X-Billing-ID: @@ -147,7 +147,7 @@ interactions: host: - artifact-index-e2e:9696 user-agent: - - polyswarm_api/4.0.0 (x86_64-Linux-CPython-3.14.4) + - polyswarm_api/4.1.0 (x86_64-Linux-CPython-3.12.3) method: DELETE uri: http://artifact-index-e2e:9696/v3/known-good?sha256=d474e08e835f8e538f8aeb6944b4bc73a31db2be96625b0bdfe0ab8bf239226b response: @@ -167,7 +167,7 @@ interactions: Content-Type: - application/json Date: - - Fri, 12 Jun 2026 22:01:46 GMT + - Mon, 29 Jun 2026 18:29:54 GMT Server: - gunicorn X-Billing-ID: @@ -189,7 +189,7 @@ interactions: host: - artifact-index-e2e:9696 user-agent: - - polyswarm_api/4.0.0 (x86_64-Linux-CPython-3.14.4) + - polyswarm_api/4.1.0 (x86_64-Linux-CPython-3.12.3) method: GET uri: http://artifact-index-e2e:9696/v3/known-good?sha256=d474e08e835f8e538f8aeb6944b4bc73a31db2be96625b0bdfe0ab8bf239226b&community=gamma response: @@ -209,7 +209,7 @@ interactions: Content-Type: - application/json Date: - - Fri, 12 Jun 2026 22:01:46 GMT + - Mon, 29 Jun 2026 18:29:54 GMT Server: - gunicorn status: diff --git a/test/vcr/test_known_good_lifecycle.vcr b/test/vcr/test_known_good_lifecycle.vcr index fad07af1..e1e93780 100644 --- a/test/vcr/test_known_good_lifecycle.vcr +++ b/test/vcr/test_known_good_lifecycle.vcr @@ -17,12 +17,12 @@ interactions: host: - artifact-index-e2e:9696 user-agent: - - polyswarm_api/4.0.0 (x86_64-Linux-CPython-3.14.4) + - polyswarm_api/4.1.0 (x86_64-Linux-CPython-3.12.3) method: POST uri: http://artifact-index-e2e:9696/v3/known-good response: body: - string: '{"result":{"artifact_instance_id":"90917448071940878","created":"2026-06-12T22:01:46.572994+00:00","id":"18577785917417703","sha256":"9c0258ed4cc98056773bcf0c57fe4bc79618802357f0703c503b4cb172a263df","sources":["nsrl"]},"status":"OK"} + string: '{"result":{"artifact_instance_id":"34363762993756881","created":"2026-06-29T18:30:01.431502+00:00","id":"25565696800926378","sha256":"9c0258ed4cc98056773bcf0c57fe4bc79618802357f0703c503b4cb172a263df","sources":["nsrl"]},"status":"OK"} ' headers: @@ -37,7 +37,7 @@ interactions: Content-Type: - application/json Date: - - Fri, 12 Jun 2026 22:01:46 GMT + - Mon, 29 Jun 2026 18:30:01 GMT Server: - gunicorn X-Billing-ID: @@ -57,18 +57,18 @@ interactions: connection: - keep-alive content-length: - - '115' + - '119' content-type: - application/json host: - artifact-index-e2e:9696 user-agent: - - polyswarm_api/4.0.0 (x86_64-Linux-CPython-3.14.4) + - polyswarm_api/4.1.0 (x86_64-Linux-CPython-3.12.3) method: POST uri: http://artifact-index-e2e:9696/v3/known-good response: body: - string: '{"result":{"artifact_instance_id":"90917448071940878","created":"2026-06-12T22:01:46.572994+00:00","id":"18577785917417703","sha256":"9c0258ed4cc98056773bcf0c57fe4bc79618802357f0703c503b4cb172a263df","sources":["nsrl","commercial"]},"status":"OK"} + string: '{"result":{"artifact_instance_id":"34363762993756881","created":"2026-06-29T18:30:01.431502+00:00","id":"25565696800926378","sha256":"9c0258ed4cc98056773bcf0c57fe4bc79618802357f0703c503b4cb172a263df","sources":["commercial","nsrl"]},"status":"OK"} ' headers: @@ -79,11 +79,11 @@ interactions: Connection: - keep-alive Content-Length: - - '244' + - '248' Content-Type: - application/json Date: - - Fri, 12 Jun 2026 22:01:46 GMT + - Mon, 29 Jun 2026 18:30:01 GMT Server: - gunicorn X-Billing-ID: @@ -105,12 +105,12 @@ interactions: host: - artifact-index-e2e:9696 user-agent: - - polyswarm_api/4.0.0 (x86_64-Linux-CPython-3.14.4) + - polyswarm_api/4.1.0 (x86_64-Linux-CPython-3.12.3) method: GET uri: http://artifact-index-e2e:9696/v3/known-good?sha256=9c0258ed4cc98056773bcf0c57fe4bc79618802357f0703c503b4cb172a263df&community=gamma response: body: - string: '{"result":{"artifact_instance_id":"90917448071940878","created":"2026-06-12T22:01:46.572994+00:00","id":"18577785917417703","sha256":"9c0258ed4cc98056773bcf0c57fe4bc79618802357f0703c503b4cb172a263df","sources":["nsrl","commercial"]},"status":"OK"} + string: '{"result":{"artifact_instance_id":"34363762993756881","created":"2026-06-29T18:30:01.431502+00:00","id":"25565696800926378","sha256":"9c0258ed4cc98056773bcf0c57fe4bc79618802357f0703c503b4cb172a263df","sources":["commercial","nsrl"]},"status":"OK"} ' headers: @@ -121,11 +121,11 @@ interactions: Connection: - keep-alive Content-Length: - - '244' + - '248' Content-Type: - application/json Date: - - Fri, 12 Jun 2026 22:01:46 GMT + - Mon, 29 Jun 2026 18:30:01 GMT Server: - gunicorn X-Billing-ID: @@ -147,7 +147,7 @@ interactions: host: - artifact-index-e2e:9696 user-agent: - - polyswarm_api/4.0.0 (x86_64-Linux-CPython-3.14.4) + - polyswarm_api/4.1.0 (x86_64-Linux-CPython-3.12.3) method: DELETE uri: http://artifact-index-e2e:9696/v3/known-good?sha256=9c0258ed4cc98056773bcf0c57fe4bc79618802357f0703c503b4cb172a263df response: @@ -167,7 +167,7 @@ interactions: Content-Type: - application/json Date: - - Fri, 12 Jun 2026 22:01:46 GMT + - Mon, 29 Jun 2026 18:30:01 GMT Server: - gunicorn X-Billing-ID: @@ -189,7 +189,7 @@ interactions: host: - artifact-index-e2e:9696 user-agent: - - polyswarm_api/4.0.0 (x86_64-Linux-CPython-3.14.4) + - polyswarm_api/4.1.0 (x86_64-Linux-CPython-3.12.3) method: GET uri: http://artifact-index-e2e:9696/v3/known-good?sha256=9c0258ed4cc98056773bcf0c57fe4bc79618802357f0703c503b4cb172a263df&community=gamma response: @@ -209,7 +209,7 @@ interactions: Content-Type: - application/json Date: - - Fri, 12 Jun 2026 22:01:46 GMT + - Mon, 29 Jun 2026 18:30:01 GMT Server: - gunicorn status: From f9f01ec6eab88aab246ec86328be9255781cc015 Mon Sep 17 00:00:00 2001 From: sam Date: Thu, 2 Jul 2026 11:05:08 -0300 Subject: [PATCH 13/16] ci: parametrize CI template include via $CI_TEMPLATE --- .gitlab-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index ef2c0f54..7bb2494c 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,5 +1,5 @@ include: - - project: 'externalci/ci-image' + - project: '$CI_TEMPLATE' ref: master file: '.gitlab-ci-default.yaml' From 1108aa025babc9205a5b22f904cf4107dbee54b1 Mon Sep 17 00:00:00 2001 From: Samuel Date: Fri, 10 Jul 2026 14:43:45 -0300 Subject: [PATCH 14/16] test: wait on sandbox+report postconditions in test_sample to fix e2e race MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit test_sample (sync + async) polled the aggregated sample until the LLM report left its pre-trigger states, then asserted the sandbox tasks read COMPLETED. The report auto-triggers on any one completed dependency, and the sample's per-task requested_status fields don't update atomically, so the report could read triggered in the same response where sandbox_cape still projected NOT_TRIGGERED — an intermittent AssertionError against a live stack. Poll on the exact postconditions asserted instead: break once both sandbox deps read COMPLETED and the report is triggered. The test already drives both sandboxes to SUCCEEDED, so this only closes the projection-lag window; the committed cassettes replay unchanged. --- test/async_client_test.py | 16 +++++++++++++++- test/client_scan_test.py | 33 +++++++++++++++++++++++++-------- 2 files changed, 40 insertions(+), 9 deletions(-) diff --git a/test/async_client_test.py b/test/async_client_test.py index 218e9da0..c7036fe5 100644 --- a/test/async_client_test.py +++ b/test/async_client_test.py @@ -510,11 +510,25 @@ async def test_async_sample(self, uid): await _complete_sandbox_task(cape.id, 'cape') await _complete_sandbox_task(triage.id, 'triage') + # Poll until BOTH sandbox deps read COMPLETED *and* the LLM report was + # auto-triggered — the exact postconditions asserted below, not a proxy. + # Keying off llm_report alone raced: the report auto-triggers on *any + # one* completed dep (the scan or a single sandbox), so a response can + # show it triggered while sandbox_cape still projects NOT_TRIGGERED (the + # per-task projection doesn't update atomically). This test drives both + # sandboxes to SUCCEEDED, so both projections reach COMPLETED; waiting on + # them directly closes the window. See sync test_sample. _PRE_TRIGGER = {None, 'NOT_TRIGGERED', 'WAITING_FOR_OTHER_TASKS'} result = await api.sample(sha) for _ in range(90): result = await api.sample(sha) - if result.tasks.get('llm_report', {}).get('requested_status') not in _PRE_TRIGGER: + tasks = result.tasks or {} + sandboxes_completed = all( + tasks.get(f'sandbox_{s}', {}).get('requested_status') == 'COMPLETED' + for s in ('cape', 'triage') + ) + llm_triggered = tasks.get('llm_report', {}).get('requested_status') not in _PRE_TRIGGER + if sandboxes_completed and llm_triggered: break await asyncio.sleep(1) assert isinstance(result.artifact_instance, dict) diff --git a/test/client_scan_test.py b/test/client_scan_test.py index a099797c..44d7dc0f 100644 --- a/test/client_scan_test.py +++ b/test/client_scan_test.py @@ -880,18 +880,35 @@ def test_sample(self): _complete_sandbox_task(cape.id, 'cape') _complete_sandbox_task(triage.id, 'triage') - # Poll the sample until the LLM report has been auto-triggered. The view - # triggers it once a sandbox dep is COMPLETED, so the report's status - # moves NOT_TRIGGERED/WAITING_FOR_OTHER_TASKS -> PENDING (then FAILED here, - # since e2e has no OPENAI_API_KEY). We key off requested_status: the - # requested_id stays null until a report actually renders, which can't - # happen without an LLM. Reaching a triggered status also confirms the - # sandbox deps completed. + # Poll the sample until BOTH sandbox deps read COMPLETED *and* the LLM + # report has been auto-triggered — i.e. wait on the exact postconditions + # asserted below, not a proxy for them. The report's requested_status moves + # NOT_TRIGGERED/WAITING_FOR_OTHER_TASKS -> PENDING (then FAILED here, since + # e2e has no OPENAI_API_KEY); requested_id stays null until a report + # actually renders (impossible without an LLM), so requested_status is the + # trigger signal. + # + # Keying the loop off llm_report alone raced: the report auto-triggers as + # soon as *any one* dependency completes (the scan or a single sandbox), so + # a response can show it triggered while sandbox_cape still projects + # NOT_TRIGGERED — the delayed COLLECTING_DATA->SUCCEEDED transition (see + # _complete_sandbox_task) not yet folded into the per-task projection, which + # doesn't update atomically. That mismatch was the flake. This test drives + # BOTH sandboxes to SUCCEEDED before polling, so both projections do reach + # COMPLETED; waiting on them directly (not on the report as a proxy) closes + # the window. A 90x1s timeout here therefore means a dependency never + # settled — the scan's bounty window or a sandbox — not this loop. _PRE_TRIGGER = {None, 'NOT_TRIGGERED', 'WAITING_FOR_OTHER_TASKS'} result = api.sample(sha) for _ in range(90): result = api.sample(sha) - if result.tasks.get('llm_report', {}).get('requested_status') not in _PRE_TRIGGER: + tasks = result.tasks or {} + sandboxes_completed = all( + tasks.get(f'sandbox_{s}', {}).get('requested_status') == 'COMPLETED' + for s in ('cape', 'triage') + ) + llm_triggered = tasks.get('llm_report', {}).get('requested_status') not in _PRE_TRIGGER + if sandboxes_completed and llm_triggered: break time.sleep(1) assert isinstance(result.artifact_instance, dict) From 1fd0b2c0385b8c599ea0755fd1a516d8478f0d68 Mon Sep 17 00:00:00 2001 From: sam Date: Thu, 23 Jul 2026 15:25:31 -0300 Subject: [PATCH 15/16] fix: restore pre-4.0 204 ("no results") handling for exists() and downloads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 4.0 client widened two status-code checks and, in doing so, broke the handling of HTTP 204 — a successful response meaning "the request worked, but there was no matching data, so nothing was returned". - exists() checked `int(result) // 100 == 2` (any 2xx is "present"), so a HEAD /search/hash returning 204 for an absent artifact was reported as existing. The endpoint returns 200 when the artifact is present and 204 when it is absent, so only a 200 means it exists. Restore the pre-4.0 `== 200` check (matches the reference client's `str(result) == '200'`); 204 and 404 both map to False. - The streaming download path (_execute_download) only branched on non-2xx, so a 204 fell through, opened the destination, streamed the empty body and returned a successful *empty* artifact. Pre-4.0, a 204 on a parser-backed request raised NoResultsException via the shared parse path. Restore that: a 204 download now raises NoResultsException instead of silently writing an empty file. Both fixes edit the canonical async sources (aio/api.py, aio/session.py); the sync mirrors (api.py, session.py) are regenerated via scripts/regenerate_sync.py. Adds regression tests — exists() 200->True / 204->False / 404->False, a HEAD-204 parse-layer guard, and a 204 download raising NoResultsException — and pins the 200-only present / 204-absent contract in the specs. --- specs/01-architecture.md | 2 +- specs/03-endpoints.md | 2 +- specs/99-open-questions.md | 1 + src/polyswarm_api/aio/api.py | 11 ++++++---- src/polyswarm_api/aio/session.py | 13 ++++++++++++ src/polyswarm_api/api.py | 11 ++++++---- src/polyswarm_api/session.py | 14 +++++++++++++ test/async_client_test.py | 35 ++++++++++++++++++++++++++------ test/core_test.py | 11 ++++++++++ 9 files changed, 84 insertions(+), 16 deletions(-) diff --git a/specs/01-architecture.md b/specs/01-architecture.md index 9ebbc778..9fe511cf 100644 --- a/specs/01-architecture.md +++ b/specs/01-architecture.md @@ -270,7 +270,7 @@ Every HTTP-level error maps to a subclass of `PolyswarmException`: - 404 → `NotFoundException` - 422 → `FailedInstanceException` - 429 → `UsageLimitsExceededException` -- 204 + JSON parser expected → `NoResultsException` +- 204 on a request that expects data (JSON-parser GET **or** streaming download) → `NoResultsException` — the server did the work but matched nothing. **HEAD is exempt**: it returns the raw status code as the result (so `exists()` reads a 204 as "known-absent" rather than raising). - Other non-2xx → `RequestException` - Client-side validation failures (bad hash, missing kwarg) → `InvalidValueException` - Polling timeouts → `TimeoutException` diff --git a/specs/03-endpoints.md b/specs/03-endpoints.md index dd321268..d187837e 100644 --- a/specs/03-endpoints.md +++ b/specs/03-endpoints.md @@ -25,7 +25,7 @@ The full catalogue of methods on the public client surface and which transport h | Method | Resource builder | Notes | |---|---|---| -| `exists(hash_, hash_type=None, require_scan=False)` | `ArtifactInstance.exists_hash` | HEAD; returns `bool` from status code. | +| `exists(hash_, hash_type=None, require_scan=False)` | `ArtifactInstance.exists_hash` | HEAD; `bool` from status code — `True` **only** for `200` (present). `204` means "absent" (the request succeeded but matched no artifact) and `404` also maps to absent, so both are `False`. Do **not** treat this as a generic `2xx` check: `204` is a successful status that means the opposite of "exists". | | `lookup(scan)` | `ArtifactInstance.lookup_uuid` | | | `rescan(hash_, hash_type=None, scan_config=None)` | `ArtifactInstance.rescan` | | | `rescan_id(scan, scan_config=None)` | `ArtifactInstance.rescan_id` | | diff --git a/specs/99-open-questions.md b/specs/99-open-questions.md index a9a5058b..22b2469c 100644 --- a/specs/99-open-questions.md +++ b/specs/99-open-questions.md @@ -114,6 +114,7 @@ The 4.0 transport originally buffered (the adapter read `response.content` whole - detects a streaming parser (`request.result_parser is not None and not issubclass(request.result_parser, BaseJsonResource)`) and routes to `_execute_download`; - opens the response with `self._client.send(req, stream=True)` (status/headers available, body not read) — which also covers the auth-stripped off-domain S3 case (`download_archive`), since header suppression already goes through `build_request` + pop; - maps non-2xx via the shared `core._raise_for_status` (identical typed exceptions to the JSON path) after `aread()`-ing the small error body; +- treats a **204 as "no matching artifact"** (the request succeeded but returned nothing) and raises `NoResultsException` — mirroring the shared `parse_response` 204 rule that the streaming path otherwise bypasses, so an absent download surfaces "no results" instead of silently writing a successful empty file (regression-guarded by `test_async_download_204_raises_no_results`); - has the parser class resolve a destination handle (`LocalArtifact.open_destination`), streams the body in chunk by chunk (`response.aiter_bytes(DOWNLOAD_CHUNK_SIZE)`), wraps the written handle (`LocalArtifact.from_written`), and removes a partially-written file it created; - closes the response in a `finally` (`aclose`). diff --git a/src/polyswarm_api/aio/api.py b/src/polyswarm_api/aio/api.py index dd10e421..0c9d69e9 100644 --- a/src/polyswarm_api/aio/api.py +++ b/src/polyswarm_api/aio/api.py @@ -1652,8 +1652,11 @@ async def exists(self, hash_, hash_type=None, require_scan=False): self, hash_.hash, hash_.hash_type, require_scan=require_scan, ), ) - # exists_hash is a HEAD; ``result`` is the status code. Any 2xx means the - # artifact is known — the endpoint returns 200 for present / 404 for absent, - # so a 2xx check is correct and not brittle to a non-200 success code. - return int(result) // 100 == 2 + # exists_hash is a HEAD; ``result`` is the status code. The endpoint returns + # 200 when the artifact is present and 204 when it is absent ("the request + # worked, but there was no matching artifact, so nothing was returned"). Only + # a 200 means the artifact exists — 204 (and 404) both mean "absent" → False. + # NB: this is deliberately ``== 200``, not a ``// 100 == 2`` 2xx check, because + # 204 is a successful 2xx that means the opposite of "exists". + return int(result) == 200 diff --git a/src/polyswarm_api/aio/session.py b/src/polyswarm_api/aio/session.py index 6392375c..b11861e0 100644 --- a/src/polyswarm_api/aio/session.py +++ b/src/polyswarm_api/aio/session.py @@ -148,6 +148,19 @@ async def _execute_download(self, request): await response.aread() _raise_for_status(response, request) + if response.status_code == 204: + # A 204 is the "no data / absent" signal on a download: the request + # succeeded but there is nothing to write ("the request worked, but + # there was no matching artifact, so nothing was returned"). The + # shared ``parse_response`` maps a parser-backed 204 to + # ``NoResultsException``; the streaming path bypasses ``parse_response``, + # so mirror that rule explicitly here. Without it, an absent artifact + # would be written out as a successful *empty* file instead of + # surfacing "no results". + raise exceptions.NoResultsException( + request, 'The request returned no results.', + ) + pk = request.parser_kwargs or {} handle, name, created = request.result_parser.open_destination( pk.get('folder'), pk.get('handle'), pk.get('artifact_name'), response, diff --git a/src/polyswarm_api/api.py b/src/polyswarm_api/api.py index 22a49265..fde4ea59 100644 --- a/src/polyswarm_api/api.py +++ b/src/polyswarm_api/api.py @@ -2002,7 +2002,10 @@ def exists(self, hash_, hash_type=None, require_scan=False): require_scan=require_scan, ), ) - # exists_hash is a HEAD; ``result`` is the status code. Any 2xx means the - # artifact is known — the endpoint returns 200 for present / 404 for absent, - # so a 2xx check is correct and not brittle to a non-200 success code. - return int(result) // 100 == 2 + # exists_hash is a HEAD; ``result`` is the status code. The endpoint returns + # 200 when the artifact is present and 204 when it is absent ("the request + # worked, but there was no matching artifact, so nothing was returned"). Only + # a 200 means the artifact exists — 204 (and 404) both mean "absent" → False. + # NB: this is deliberately ``== 200``, not a ``// 100 == 2`` 2xx check, because + # 204 is a successful 2xx that means the opposite of "exists". + return int(result) == 200 diff --git a/src/polyswarm_api/session.py b/src/polyswarm_api/session.py index 7a5d34fc..8e813057 100644 --- a/src/polyswarm_api/session.py +++ b/src/polyswarm_api/session.py @@ -151,6 +151,20 @@ def _execute_download(self, request): response.read() _raise_for_status(response, request) + if response.status_code == 204: + # A 204 is the "no data / absent" signal on a download: the request + # succeeded but there is nothing to write ("the request worked, but + # there was no matching artifact, so nothing was returned"). The + # shared ``parse_response`` maps a parser-backed 204 to + # ``NoResultsException``; the streaming path bypasses ``parse_response``, + # so mirror that rule explicitly here. Without it, an absent artifact + # would be written out as a successful *empty* file instead of + # surfacing "no results". + raise exceptions.NoResultsException( + request, + "The request returned no results.", + ) + pk = request.parser_kwargs or {} handle, name, created = request.result_parser.open_destination( pk.get("folder"), diff --git a/test/async_client_test.py b/test/async_client_test.py index c7036fe5..266afa32 100644 --- a/test/async_client_test.py +++ b/test/async_client_test.py @@ -992,6 +992,28 @@ async def test_async_download(): await api.aclose() +@respx.mock +async def test_async_download_204_raises_no_results(): + """A 204 on a download means "no matching artifact" (the request worked but + returned nothing), so the streaming path must raise ``NoResultsException`` + rather than write out a successful *empty* file — mirroring the shared + ``parse_response`` 204 rule that the streaming path otherwise bypasses.""" + import tempfile, os + + respx.get(f'{BASE_URL}/consumer/download/sha256/{SHA256}').mock( + return_value=httpx.Response(204)) + + api = PolySwarmAsyncAPI(API_KEY, uri=BASE_URL, community='gamma') + try: + with tempfile.TemporaryDirectory() as tmp_dir: + with pytest.raises(exceptions.NoResultsException): + await api.download(tmp_dir, SHA256) + # No empty artifact file should have been left behind. + assert os.listdir(tmp_dir) == [] + finally: + await api.aclose() + + @respx.mock async def test_async_download_streams_in_chunks(monkeypatch): """Regression guard for the streaming-download fix: the body is consumed via @@ -1028,17 +1050,18 @@ def write(self, b): @respx.mock -async def test_async_exists_maps_2xx_true_404_false(): - """End-to-end ``exists`` (the path the bot flagged as untested): the HEAD - status drives the result — any 2xx is True, 404 is False. Also locks the 2xx - generalisation (not a brittle ``== 200``).""" +async def test_async_exists_maps_200_true_204_and_404_false(): + """End-to-end ``exists``: the HEAD status drives the result. The endpoint + returns 200 when the artifact is present and 204 when it is absent ("request + worked, no matching artifact"), so only a 200 is True — a 204 is a successful + 2xx that means the *opposite* of "exists" and must be False, as must a 404.""" route = respx.head(f'{BASE_URL}/search/hash/sha256') api = PolySwarmAsyncAPI(API_KEY, uri=BASE_URL, community='gamma') try: route.mock(return_value=httpx.Response(200)) assert await api.exists(SHA256) is True - route.mock(return_value=httpx.Response(204)) # 2xx-but-not-200 still "exists" - assert await api.exists(SHA256) is True + route.mock(return_value=httpx.Response(204)) # absent: "worked, nothing found" + assert await api.exists(SHA256) is False route.mock(return_value=httpx.Response(404)) assert await api.exists(SHA256) is False finally: diff --git a/test/core_test.py b/test/core_test.py index 0e397d9c..3371da90 100644 --- a/test/core_test.py +++ b/test/core_test.py @@ -181,6 +181,17 @@ def test_head_404_does_not_raise(self): parse_response(_FakeResponse(status_code=404), req) assert req._result == 404 + def test_head_204_does_not_raise(self): + # A 204 on a HEAD is the "absent" signal for ``exists_hash`` (the + # search/hash endpoint returns 200 present / 204 absent). The HEAD + # branch short-circuits before the 204 -> NoResultsException mapping + # that applies to parser-backed GETs, so ``exists`` receives the raw + # 204 as the result and reads it as "not present" (see + # ``PolySwarmAsyncAPI.exists``: only 200 is True). + req = PolyswarmRequest(api=_FakeApi(), method='HEAD', url='u') + parse_response(_FakeResponse(status_code=204), req) + assert req._result == 204 + def test_2xx_without_parser_is_fire_and_forget(self): # No result_parser → body intentionally discarded (e.g. Webhook.test) req = PolyswarmRequest(api=_FakeApi(), method='POST', url='u') From 96acff4230a499257fda732a4c6e1098ed4e55b8 Mon Sep 17 00:00:00 2001 From: sam Date: Thu, 23 Jul 2026 15:33:28 -0300 Subject: [PATCH 16/16] =?UTF-8?q?Bump=20version:=204.1.0=20=E2=86=92=204.2?= =?UTF-8?q?.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pyproject.toml | 4 ++-- src/polyswarm_api/__init__.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index f8e95f24..4dd93bb4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "polyswarm_api" -version = "4.1.0" +version = "4.2.0" description = "Client library to simplify interacting with the PolySwarm consumer API" readme = "README.md" requires-python = ">=3.10,<4" @@ -55,7 +55,7 @@ package-dir = { "" = "src" } where = ["src"] [tool.bumpversion] -current_version = "4.1.0" +current_version = "4.2.0" commit = true tag = false sign_tags = true diff --git a/src/polyswarm_api/__init__.py b/src/polyswarm_api/__init__.py index c6c807b9..775cb442 100644 --- a/src/polyswarm_api/__init__.py +++ b/src/polyswarm_api/__init__.py @@ -1,5 +1,5 @@ # https://www.python.org/dev/peps/pep-0008/#module-level-dunder-names -__version__ = '4.1.0' +__version__ = '4.2.0' __release_url__ = 'https://api.github.com/repos/polyswarm/polyswarm-api/releases/latest' from . import api