From b164e6be95adf8839145b0826aa583806519c6c1 Mon Sep 17 00:00:00 2001 From: Claude Code Date: Tue, 20 Oct 2026 17:52:59 -0300 Subject: [PATCH 1/7] chore: raise the SDK floor to polyswarm_api>=4.8.0 for historical_create(window_days=) CI installs the SDK from the same-named branch archive, so the floor names the version the paired SDK change declares: 4.8.0, the next version above the 4.7.0 the SDK's develop already declares. It is mergeable once that change is on the SDK's develop, and releasable once 4.8.0 is on PyPI. Co-Authored-By: Alan Justino --- pyproject.toml | 2 +- specs/05-sdk-contract.md | 10 +++++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 6c17d74..f68deb9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,7 +22,7 @@ classifiers = [ ] dependencies = [ - "polyswarm_api>=4.7.0,<5.0.0", + "polyswarm_api>=4.8.0,<5.0.0", "click>=7.1", "colorama>=0.4.6", "click-log>=0.4.0", diff --git a/specs/05-sdk-contract.md b/specs/05-sdk-contract.md index 8bd2170..cf3fe3b 100644 --- a/specs/05-sdk-contract.md +++ b/specs/05-sdk-contract.md @@ -108,7 +108,7 @@ When a CLI feature needs an SDK surface that doesn't exist yet: **Read the declared version off the archive's own tree, and mind pre-release suffixes.** PEP 440 orders `4.2.0.dev1 < 4.2.0`, so a `develop` head carrying a dev suffix (the SDK's `pyproject.toml` has a `[tool.bumpversion.parts.dev]`) would *not* satisfy a `>=4.2.0` floor even though it looks like 4.2.0 — and the archive build would be silently replaced from PyPI. Check the version string in the SDK branch's `pyproject.toml` / `__init__.py`, not the last release tag. When the floor was last verified this way both were read from `origin/develop` as `4.2.0`, no suffix; the pin has since moved on (§Current floor is the one authoritative statement of its value), and every bump should be re-checked the same way. -### Current floor — `polyswarm_api>=4.7.0` +### Current floor — `polyswarm_api>=4.8.0` The floor is whatever `pyproject.toml` pins; this header follows it. It lives in ONE authoritative place for a reason — a copy here drifted behind the pin once already. The 4.2.0 rationale below still holds transitively; on 4.1.0 both behaviours fail *silently*, which is why the floor is a hard requirement rather than a preference: @@ -138,8 +138,12 @@ the refang inside the SDK's endpoint methods, which `--refang/--no-refang` relie `refang_iocs=` and every command fails). `search ioc … --with-artifacts` forwards `search_by_ioc(with_artifacts=True)`, a keyword 4.7.0 adds, and that is what moved the floor to 4.7.0 (the autospec assertions in `tests/search_test.py` are its signature -check). Both are paired changes: the SDK branch of the same name declares the floor. -Code and tests use them directly. +check). `historical start --window-days` forwards `historical_create(window_days=)`, a +keyword 4.8.0 adds together with the `HistoricalHunt.window_days` attribute the text +formatter reads, and that is what moved the floor to 4.8.0 (the autospec assertions in +`tests/historical_window_test.py` are its signature check, and on 4.7.0 every hunt render +would raise on the missing attribute). Each is a paired change: the SDK branch of the +same name declares the floor. Code and tests use them directly. **Raising the floor is the whole procedure** when this repo needs something new from the SDK: From dbd1746a10708c9dd461352c76b28b957cc69283 Mon Sep 17 00:00:00 2001 From: Claude Code Date: Tue, 20 Oct 2026 17:54:40 -0300 Subject: [PATCH 2/7] feat(historical): start takes --window-days -w/--window-days picks how many days back a historical hunt reaches: a closed choice of 30, 60, 90 or 180, mirroring the server's enum so a bad value is a usage error before any request. It is forwarded to historical_create as an int, and only when passed, so a script that omits it makes the same SDK call as before and the hunt reaches the platform maximum. The help does not name a day count for that maximum, because the server owns it. Tests drive the command through autospec'd SDK mocks, which also check the signature against the installed SDK. Co-Authored-By: Alan Justino --- specs/02-commands.md | 2 +- src/polyswarm/client/historical.py | 6 ++- tests/historical_window_test.py | 74 ++++++++++++++++++++++++++++++ 3 files changed, 80 insertions(+), 2 deletions(-) create mode 100644 tests/historical_window_test.py diff --git a/specs/02-commands.md b/specs/02-commands.md index f085ecd..438d6a6 100644 --- a/specs/02-commands.md +++ b/specs/02-commands.md @@ -26,7 +26,7 @@ The top-level command groups, what each is for, and the primary `polyswarm-api` | `report-template` (`report_template.py`) | Manage report templates + logos | `report_template_{create,update,get,list}`, `report_template_logo_{download,upload}` | | `engine` → `votes` / `assertions` (`engine.py`) | Consolidated votes/assertions bundles per engine | `votes_{create,get,delete,list}`, `assertions_{create,get,delete,list}` | | `live` (`live.py`) | Live YARA hunts: start/stop, feed, results. `feed` takes `--since` in **SECONDS** (default 86400 — 24h; `0` means no time filter at all, and a negative is refused at parse time rather than forwarded). **`0` is the API's contract, not a CLI convention:** the endpoint applies the window only when `since` is truthy, so `0` and an absent parameter behave identically. The CLI's own test can only pin that it forwards `0` rather than dropping it — which is the half that can regress here, plus `--livescan-id` (the drill-down for the per-ruleset new-results badge `rules list` renders — the detail view deliberately does not carry the badge; the badge counts the hunt across **every** community it runs in, public and private, while the feed shows one at a time, so a multi-community hunt lists fewer rows than the badge reports) and `--max-results` (stop after N; unset means every page, as before). Both are guaranteed by the pin (see [05-sdk-contract.md](./05-sdk-contract.md) §Current floor) and called directly; they are forwarded only when passed purely so a pre-existing invocation's call shape is unchanged — the request is identical either way | `live_start`, `live_stop`, `live_feed`, `live_result`, `live_feed_delete` | -| `historical` (`historical.py`) | Historical hunts: CRUD + results | `historical_{get,create,update,list}`, `historical_delete_multiple`, `historical_delete_list`, `historical_results_multiple`, `historical_result`, `historical_results_delete` | +| `historical` (`historical.py`) | Historical hunts: CRUD + results. `start` takes `-w/--window-days {30,60,90,180}`, how many days back the hunt reaches (SDK 4.8.0, guaranteed by the pin — see [05-sdk-contract.md](./05-sdk-contract.md) §Current floor). The closed choice mirrors the server's enum, so a bad value is a usage error (exit 2) before any request; the value is forwarded as an int and only when passed, so a script that omits it makes the SDK call it always made and the hunt reaches the platform maximum, which the help does not put a day count on because the server owns it | `historical_{get,create,update,list}`, `historical_delete_multiple`, `historical_delete_list`, `historical_results_multiple`, `historical_result`, `historical_results_delete` | | `tag` (`tags.py`) | Tag CRUD | `tag_{create,delete,get,list}` | | `link` (`links.py`) | Tag/family links on artifacts | `tag_link_multiple`, `tag_link_get`, `tag_link_list` | | `family` (`families.py`) | Malware-family CRUD | `family_{create,update,delete,get,list}` | diff --git a/src/polyswarm/client/historical.py b/src/polyswarm/client/historical.py index 337c676..6247481 100644 --- a/src/polyswarm/client/historical.py +++ b/src/polyswarm/client/historical.py @@ -26,9 +26,11 @@ def historical_view(ctx, hunt_id): @click.argument('rule_file', type=click.File('r'), required=False) @click.option('-r', '--rule-id', type=click.INT, help='If provided, create the historical hunt from the existing ruleset.') @click.option('-n', '--name', help='Explicitly set the ruleset name for this hunt.') +@click.option('-w', '--window-days', type=click.Choice(['30', '60', '90', '180']), + help='How many days back the hunt reaches. Omit it for the platform maximum.') @click.pass_context @utils.any_provided('rule_file', 'rule_id') -def historical_start(ctx, rule_file, rule_id, name): +def historical_start(ctx, rule_file, rule_id, name, window_days): api = ctx.obj['api'] output = ctx.obj['output'] params = {} @@ -39,6 +41,8 @@ def historical_start(ctx, rule_file, rule_id, name): rule = rule_id if name: params['ruleset_name'] = name + if window_days is not None: + params['window_days'] = int(window_days) result = api.historical_create(rule, **params) output.hunt(result) diff --git a/tests/historical_window_test.py b/tests/historical_window_test.py new file mode 100644 index 0000000..d6f206f --- /dev/null +++ b/tests/historical_window_test.py @@ -0,0 +1,74 @@ +"""``historical start --window-days``: what reaches ``historical_create``. + +autospec'd SDK-boundary mocks (specs/04-testing.md, Style 1), so every call is also a +signature check against the installed SDK: one without ``window_days`` fails at the mock. +""" +from pathlib import Path +from unittest import TestCase, mock + +from click.testing import CliRunner + +from polyswarm.client import polyswarm as client +from polyswarm_api import resources + +_RULE_FILE = Path(__file__).resolve().parent / 'eicar.yara' +_GLOBALS = ['-a', '1' * 32, '-u', 'http://ai:9696/v3', '-c', 'gamma'] + + +def _hunt(**overrides): + content = dict(id='9', status='PENDING', progress=0.0, active=None, + created='2026-10-01T12:00:00+00:00', summary=None, + results_csv_uri=None, ruleset_name='n', yara=None) + content.update(overrides) + return resources.HistoricalHunt(content, api=None) + + +class HistoricalStartWindowTest(TestCase): + def _start(self, *args): + with mock.patch('polyswarm_api.api.PolyswarmAPI.historical_create', + autospec=True, return_value=_hunt()) as historical_create: + result = CliRunner().invoke( + client.polyswarm_cli, _GLOBALS + ['historical', 'start', *args], + catch_exceptions=False) + return result, historical_create + + def test_each_window_is_forwarded_as_an_int(self): + for days in (30, 60, 90, 180): + with self.subTest(days=days): + result, historical_create = self._start(str(_RULE_FILE), '-w', str(days)) + assert result.exit_code == 0, result.output + historical_create.assert_called_once_with( + mock.ANY, _RULE_FILE.read_text(), ruleset_name='eicar.yara', + window_days=days) + # 30 == '30' is False, but only the type says it is not a float or a bool. + assert type(historical_create.call_args.kwargs['window_days']) is int + + def test_no_flag_forwards_no_window_at_all(self): + # A script written before the flag keeps making the SDK call it always made. + result, historical_create = self._start(str(_RULE_FILE)) + assert result.exit_code == 0, result.output + historical_create.assert_called_once_with( + mock.ANY, _RULE_FILE.read_text(), ruleset_name='eicar.yara') + assert 'window_days' not in historical_create.call_args.kwargs + + def test_window_composes_with_a_stored_rule(self): + result, historical_create = self._start('--rule-id', '5', '--window-days', '90') + assert result.exit_code == 0, result.output + historical_create.assert_called_once_with(mock.ANY, 5, window_days=90) + + def test_a_window_outside_the_choices_is_a_usage_error(self): + result, historical_create = self._start(str(_RULE_FILE), '-w', '45') + assert result.exit_code == 2, result.output + # Names the refused value and what is allowed; click's wording varies by version. + for text in ('45', '30', '60', '90', '180'): + assert text in result.output, result.output + historical_create.assert_not_called() + + def test_help_documents_the_choices_and_the_default(self): + result = CliRunner().invoke( + client.polyswarm_cli, _GLOBALS + ['historical', 'start', '--help'], + catch_exceptions=False) + assert result.exit_code == 0, result.output + helptext = ' '.join(result.output.split()) + assert '-w, --window-days [30|60|90|180]' in helptext, helptext + assert 'platform maximum' in helptext, helptext From 0c2e9be828bef28dff5c39d002c96db5a7714417 Mon Sep 17 00:00:00 2001 From: Claude Code Date: Tue, 20 Oct 2026 17:56:40 -0300 Subject: [PATCH 3/7] feat(historical): text output shows the hunt's time range The hunt block prints a Time Range line right after Created at: "Last N days" for a hunt created with a window, "Platform maximum" for one created without (the key is null, or a server that predates it never sent it). The web UI uses the same two labels. The JSON formatter already dumps the resource's own JSON, so it carries the raw key and needed no change. The three text snapshots that render a historical hunt gain the one new line, because their recorded responses carry no window: regenerated by deleting each .click and re-running its test, with every .vcr untouched. The JSON snapshots and every other text snapshot are unchanged. Co-Authored-By: Alan Justino --- specs/03-formatters.md | 8 +++ src/polyswarm/formatters/text.py | 5 ++ tests/historical_window_test.py | 68 ++++++++++++++++++- .../test_historical_hunt_create_text.click | 27 ++++---- .../test_historical_hunt_delete_text.click | 8 +-- .../vcr/test_historical_hunt_list_text.click | 6 ++ 6 files changed, 102 insertions(+), 20 deletions(-) diff --git a/specs/03-formatters.md b/specs/03-formatters.md index 997ff60..33bb902 100644 --- a/specs/03-formatters.md +++ b/specs/03-formatters.md @@ -173,6 +173,14 @@ the attributes directly: - `source_rule_changed` is tri-state: `None` means UNKNOWN, not "unchanged", and prints nothing; the label names its reference point — "changed since this hunt froze it" — so it cannot read as "edited recently". +- `window_days` renders as `Time Range:` right after `Created at:`, on every + historical hunt (`output.hunt` is only ever called for those): `Last N days` + for an int, `Platform maximum` for `None`. Unlike the fields above, `None` is + an answer rather than an absence — a hunt created without a window reaches + the platform maximum — so it prints a line instead of omitting one, and the + label names no day count because the server owns that maximum. The web UI + uses the same two labels. `JSONOutput` needs no change: it dumps the + resource's `.json`, which already carries the raw key (an int or `null`). ## Matched strings on hunt results diff --git a/src/polyswarm/formatters/text.py b/src/polyswarm/formatters/text.py index 3dfcfd6..961a7c0 100644 --- a/src/polyswarm/formatters/text.py +++ b/src/polyswarm/formatters/text.py @@ -230,6 +230,11 @@ def hunt(self, result, write=True): if result.active is not None: output.append(self._white(f'Active: {result.active}')) output.append(self._white(f'Created at: {result.created}')) + # None: no window was requested, so the hunt reaches the platform maximum. + if result.window_days is None: + output.append(self._white('Time Range: Platform maximum')) + else: + output.append(self._white(f'Time Range: Last {result.window_days} days')) if result.summary: output.append(self._white(f'Total count: {result.summary["count"]}')) self._open_group() diff --git a/tests/historical_window_test.py b/tests/historical_window_test.py index d6f206f..e18f50b 100644 --- a/tests/historical_window_test.py +++ b/tests/historical_window_test.py @@ -1,14 +1,18 @@ -"""``historical start --window-days``: what reaches ``historical_create``. +"""``historical start --window-days`` and the ``Time Range`` line of the hunt text block. -autospec'd SDK-boundary mocks (specs/04-testing.md, Style 1), so every call is also a -signature check against the installed SDK: one without ``window_days`` fails at the mock. +The command is driven through autospec'd SDK-boundary mocks (specs/04-testing.md, Style 1), +so every call is also a signature check against the installed SDK: one without +``window_days`` fails at the mock. The line itself is a Style 3 formatter test on real SDK +resources built from dicts. """ +import json from pathlib import Path from unittest import TestCase, mock from click.testing import CliRunner from polyswarm.client import polyswarm as client +from polyswarm.formatters import text from polyswarm_api import resources _RULE_FILE = Path(__file__).resolve().parent / 'eicar.yara' @@ -23,6 +27,11 @@ def _hunt(**overrides): return resources.HistoricalHunt(content, api=None) +def _echo_window(_api, _rule, **kwargs): + """Stand-in for the server: the created hunt carries the window it was asked for.""" + return _hunt(window_days=kwargs.get('window_days')) + + class HistoricalStartWindowTest(TestCase): def _start(self, *args): with mock.patch('polyswarm_api.api.PolyswarmAPI.historical_create', @@ -72,3 +81,56 @@ def test_help_documents_the_choices_and_the_default(self): helptext = ' '.join(result.output.split()) assert '-w, --window-days [30|60|90|180]' in helptext, helptext assert 'platform maximum' in helptext, helptext + + +class HuntTimeRangeFormatterTest(TestCase): + def _lines(self, hunt, method='hunt'): + return getattr(text.TextOutput(color=False), method)(hunt, write=False) + + def test_a_window_renders_as_the_last_n_days(self): + assert 'Time Range: Last 30 days' in self._lines(_hunt(window_days=30)) + + def test_no_window_renders_as_the_platform_maximum(self): + # An explicit null and a key the server never sent mean the same thing. + for hunt in (_hunt(window_days=None), _hunt()): + lines = self._lines(hunt) + assert 'Time Range: Platform maximum' in lines + assert not [line for line in lines if line.startswith('Time Range: Last')] + + def test_the_line_follows_created_at(self): + lines = self._lines(_hunt(window_days=90)) + created = next(i for i, line in enumerate(lines) if line.startswith('Created at:')) + assert lines[created + 1] == 'Time Range: Last 90 days' + + def test_a_deleted_hunt_shows_it_too(self): + # hunt_deletion embeds the hunt block. + assert 'Time Range: Last 60 days' in self._lines(_hunt(window_days=60), 'hunt_deletion') + + +class HistoricalStartRendersTimeRangeTest(TestCase): + """The command reaches the formatter: what the user asked for is what the hunt shows.""" + + def _start(self, *args, fmt='text', **patch): + with mock.patch('polyswarm_api.api.PolyswarmAPI.historical_create', + autospec=True, **patch): + return CliRunner().invoke( + client.polyswarm_cli, + _GLOBALS + ['--output-format', fmt, 'historical', 'start', '-r', '5', *args], + catch_exceptions=False) + + def test_the_started_hunt_shows_the_window_it_was_started_with(self): + result = self._start('-w', '60', side_effect=_echo_window) + assert result.exit_code == 0, result.output + assert 'Time Range: Last 60 days' in result.output + + def test_a_start_without_the_flag_shows_the_platform_maximum(self): + result = self._start(side_effect=_echo_window) + assert result.exit_code == 0, result.output + assert 'Time Range: Platform maximum' in result.output + + def test_json_carries_the_raw_key_and_no_label(self): + # The JSON formatter dumps the resource's own JSON, so it needed no change. + result = self._start(fmt='json', return_value=_hunt(window_days=30)) + assert result.exit_code == 0, result.output + assert json.loads(result.output)['window_days'] == 30 + assert 'Time Range' not in result.output diff --git a/tests/vcr/test_historical_hunt_create_text.click b/tests/vcr/test_historical_hunt_create_text.click index cebc53b..7f71b57 100644 --- a/tests/vcr/test_historical_hunt_create_text.click +++ b/tests/vcr/test_historical_hunt_create_text.click @@ -1,15 +1,16 @@ result: "Hunt Id: 67346782704448208\nStatus: PENDING\nCreated at: 2026-08-25 18:27:00.531607+00:00\n\ - Ruleset Name: eicar.yara\nRuleset Contents:\nrule eicar_av_test : eicar match {\n\ - \ /*\n Per standard, match only if entire file is EICAR string plus optional\ - \ trailing whitespace.\n The raw EICAR string to be matched is:\n X5O!P%@AP[4\\\ - PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*\n */\n\n meta:\n\ - \ description = \"This is a standard AV test, intended to verify that BinaryAlert\ - \ is working correctly.\"\n author = \"Austin Byers | Airbnb CSIRT\"\n \ - \ reference = \"http://www.eicar.org/86-0-Intended-use.html\"\n\n strings:\n\ - \ $eicar_regex = /^X5O!P%@AP\\[4\\\\PZX54\\(P\\^\\)7CC\\)7\\}\\$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!\\\ - $H\\+H\\*\\s*$/\n\n condition:\n all of them\n}\n\nrule eicar_substring_test\ - \ : eicar substring {\n /*\n More generic - match just the embedded EICAR\ - \ string (e.g. in packed executables, PDFs, etc)\n */\n\n meta:\n description\ - \ = \"Standard AV test, checking for an EICAR substring\"\n author = \"Austin\ - \ Byers | Airbnb CSIRT\"\n\n strings:\n $eicar_substring = \"$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!\"\ + Time Range: Platform maximum\nRuleset Name: eicar.yara\nRuleset Contents:\nrule\ + \ eicar_av_test : eicar match {\n /*\n Per standard, match only if entire\ + \ file is EICAR string plus optional trailing whitespace.\n The raw EICAR\ + \ string to be matched is:\n X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*\n\ + \ */\n\n meta:\n description = \"This is a standard AV test, intended\ + \ to verify that BinaryAlert is working correctly.\"\n author = \"Austin\ + \ Byers | Airbnb CSIRT\"\n reference = \"http://www.eicar.org/86-0-Intended-use.html\"\ + \n\n strings:\n $eicar_regex = /^X5O!P%@AP\\[4\\\\PZX54\\(P\\^\\)7CC\\\ + )7\\}\\$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!\\$H\\+H\\*\\s*$/\n\n condition:\n\ + \ all of them\n}\n\nrule eicar_substring_test : eicar substring {\n /*\n\ + \ More generic - match just the embedded EICAR string (e.g. in packed executables,\ + \ PDFs, etc)\n */\n\n meta:\n description = \"Standard AV test, checking\ + \ for an EICAR substring\"\n author = \"Austin Byers | Airbnb CSIRT\"\n\n\ + \ strings:\n $eicar_substring = \"$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!\"\ \n\n condition:\n all of them\n}\n\n" diff --git a/tests/vcr/test_historical_hunt_delete_text.click b/tests/vcr/test_historical_hunt_delete_text.click index d491392..1ff9edb 100644 --- a/tests/vcr/test_historical_hunt_delete_text.click +++ b/tests/vcr/test_historical_hunt_delete_text.click @@ -1,8 +1,8 @@ result: "Successfully deleted Hunt:\nHunt Id: 3220090199138422\nStatus: DELETING\n\ - Created at: 2026-08-25 18:25:52.550248+00:00\nRuleset Contents:\nrule eicar_av_test\ - \ : eicar match {\n /*\n Per standard, match only if entire file is EICAR\ - \ string plus optional trailing whitespace.\n The raw EICAR string to be matched\ - \ is:\n X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*\n\ + Created at: 2026-08-25 18:25:52.550248+00:00\nTime Range: Platform maximum\nRuleset\ + \ Contents:\nrule eicar_av_test : eicar match {\n /*\n Per standard, match\ + \ only if entire file is EICAR string plus optional trailing whitespace.\n \ + \ The raw EICAR string to be matched is:\n X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*\n\ \ */\n\n meta:\n description = \"This is a standard AV test, intended\ \ to verify that BinaryAlert is working correctly.\"\n author = \"Austin\ \ Byers | Airbnb CSIRT\"\n reference = \"http://www.eicar.org/86-0-Intended-use.html\"\ diff --git a/tests/vcr/test_historical_hunt_list_text.click b/tests/vcr/test_historical_hunt_list_text.click index b768fd9..0b2676a 100644 --- a/tests/vcr/test_historical_hunt_list_text.click +++ b/tests/vcr/test_historical_hunt_list_text.click @@ -4,6 +4,8 @@ result: 'Hunt Id: 67346782704448208 Created at: 2026-08-25 18:27:00.531607+00:00 + Time Range: Platform maximum + Ruleset Name: eicar.yara @@ -13,6 +15,8 @@ result: 'Hunt Id: 67346782704448208 Created at: 2026-08-25 18:27:00.017968+00:00 + Time Range: Platform maximum + Ruleset Name: eicar.yara @@ -22,5 +26,7 @@ result: 'Hunt Id: 67346782704448208 Created at: 2026-08-25 18:25:52.550248+00:00 + Time Range: Platform maximum + ' From 97577f6d871488de5e8ba7ebffd19e1d87403237 Mon Sep 17 00:00:00 2001 From: Claude Code Date: Tue, 20 Oct 2026 18:49:45 -0300 Subject: [PATCH 4/7] refactor(historical): drop the -w alias of --window-days The option keeps only its long name. It has not been released, so no invocation depends on the short form. Co-Authored-By: Alan Justino --- specs/02-commands.md | 2 +- src/polyswarm/client/historical.py | 2 +- tests/historical_window_test.py | 8 ++++---- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/specs/02-commands.md b/specs/02-commands.md index 438d6a6..df8af5a 100644 --- a/specs/02-commands.md +++ b/specs/02-commands.md @@ -26,7 +26,7 @@ The top-level command groups, what each is for, and the primary `polyswarm-api` | `report-template` (`report_template.py`) | Manage report templates + logos | `report_template_{create,update,get,list}`, `report_template_logo_{download,upload}` | | `engine` → `votes` / `assertions` (`engine.py`) | Consolidated votes/assertions bundles per engine | `votes_{create,get,delete,list}`, `assertions_{create,get,delete,list}` | | `live` (`live.py`) | Live YARA hunts: start/stop, feed, results. `feed` takes `--since` in **SECONDS** (default 86400 — 24h; `0` means no time filter at all, and a negative is refused at parse time rather than forwarded). **`0` is the API's contract, not a CLI convention:** the endpoint applies the window only when `since` is truthy, so `0` and an absent parameter behave identically. The CLI's own test can only pin that it forwards `0` rather than dropping it — which is the half that can regress here, plus `--livescan-id` (the drill-down for the per-ruleset new-results badge `rules list` renders — the detail view deliberately does not carry the badge; the badge counts the hunt across **every** community it runs in, public and private, while the feed shows one at a time, so a multi-community hunt lists fewer rows than the badge reports) and `--max-results` (stop after N; unset means every page, as before). Both are guaranteed by the pin (see [05-sdk-contract.md](./05-sdk-contract.md) §Current floor) and called directly; they are forwarded only when passed purely so a pre-existing invocation's call shape is unchanged — the request is identical either way | `live_start`, `live_stop`, `live_feed`, `live_result`, `live_feed_delete` | -| `historical` (`historical.py`) | Historical hunts: CRUD + results. `start` takes `-w/--window-days {30,60,90,180}`, how many days back the hunt reaches (SDK 4.8.0, guaranteed by the pin — see [05-sdk-contract.md](./05-sdk-contract.md) §Current floor). The closed choice mirrors the server's enum, so a bad value is a usage error (exit 2) before any request; the value is forwarded as an int and only when passed, so a script that omits it makes the SDK call it always made and the hunt reaches the platform maximum, which the help does not put a day count on because the server owns it | `historical_{get,create,update,list}`, `historical_delete_multiple`, `historical_delete_list`, `historical_results_multiple`, `historical_result`, `historical_results_delete` | +| `historical` (`historical.py`) | Historical hunts: CRUD + results. `start` takes `--window-days {30,60,90,180}`, how many days back the hunt reaches (SDK 4.8.0, guaranteed by the pin — see [05-sdk-contract.md](./05-sdk-contract.md) §Current floor). The closed choice mirrors the server's enum, so a bad value is a usage error (exit 2) before any request; the value is forwarded as an int and only when passed, so a script that omits it makes the SDK call it always made and the hunt reaches the platform maximum, which the help does not put a day count on because the server owns it | `historical_{get,create,update,list}`, `historical_delete_multiple`, `historical_delete_list`, `historical_results_multiple`, `historical_result`, `historical_results_delete` | | `tag` (`tags.py`) | Tag CRUD | `tag_{create,delete,get,list}` | | `link` (`links.py`) | Tag/family links on artifacts | `tag_link_multiple`, `tag_link_get`, `tag_link_list` | | `family` (`families.py`) | Malware-family CRUD | `family_{create,update,delete,get,list}` | diff --git a/src/polyswarm/client/historical.py b/src/polyswarm/client/historical.py index 6247481..862a413 100644 --- a/src/polyswarm/client/historical.py +++ b/src/polyswarm/client/historical.py @@ -26,7 +26,7 @@ def historical_view(ctx, hunt_id): @click.argument('rule_file', type=click.File('r'), required=False) @click.option('-r', '--rule-id', type=click.INT, help='If provided, create the historical hunt from the existing ruleset.') @click.option('-n', '--name', help='Explicitly set the ruleset name for this hunt.') -@click.option('-w', '--window-days', type=click.Choice(['30', '60', '90', '180']), +@click.option('--window-days', type=click.Choice(['30', '60', '90', '180']), help='How many days back the hunt reaches. Omit it for the platform maximum.') @click.pass_context @utils.any_provided('rule_file', 'rule_id') diff --git a/tests/historical_window_test.py b/tests/historical_window_test.py index e18f50b..f307aaf 100644 --- a/tests/historical_window_test.py +++ b/tests/historical_window_test.py @@ -44,7 +44,7 @@ def _start(self, *args): def test_each_window_is_forwarded_as_an_int(self): for days in (30, 60, 90, 180): with self.subTest(days=days): - result, historical_create = self._start(str(_RULE_FILE), '-w', str(days)) + result, historical_create = self._start(str(_RULE_FILE), '--window-days', str(days)) assert result.exit_code == 0, result.output historical_create.assert_called_once_with( mock.ANY, _RULE_FILE.read_text(), ruleset_name='eicar.yara', @@ -66,7 +66,7 @@ def test_window_composes_with_a_stored_rule(self): historical_create.assert_called_once_with(mock.ANY, 5, window_days=90) def test_a_window_outside_the_choices_is_a_usage_error(self): - result, historical_create = self._start(str(_RULE_FILE), '-w', '45') + result, historical_create = self._start(str(_RULE_FILE), '--window-days', '45') assert result.exit_code == 2, result.output # Names the refused value and what is allowed; click's wording varies by version. for text in ('45', '30', '60', '90', '180'): @@ -79,7 +79,7 @@ def test_help_documents_the_choices_and_the_default(self): catch_exceptions=False) assert result.exit_code == 0, result.output helptext = ' '.join(result.output.split()) - assert '-w, --window-days [30|60|90|180]' in helptext, helptext + assert '--window-days [30|60|90|180]' in helptext, helptext assert 'platform maximum' in helptext, helptext @@ -119,7 +119,7 @@ def _start(self, *args, fmt='text', **patch): catch_exceptions=False) def test_the_started_hunt_shows_the_window_it_was_started_with(self): - result = self._start('-w', '60', side_effect=_echo_window) + result = self._start('--window-days', '60', side_effect=_echo_window) assert result.exit_code == 0, result.output assert 'Time Range: Last 60 days' in result.output From 7d844349148438c00146671ef6131723b30e3320 Mon Sep 17 00:00:00 2001 From: Claude Code Date: Wed, 21 Oct 2026 10:56:12 -0300 Subject: [PATCH 5/7] docs(historical): shorten the --window-days help Applies the review suggestion; the help test now checks the new wording. Co-Authored-By: Alan Justino --- src/polyswarm/client/historical.py | 2 +- tests/historical_window_test.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/polyswarm/client/historical.py b/src/polyswarm/client/historical.py index 862a413..6701f97 100644 --- a/src/polyswarm/client/historical.py +++ b/src/polyswarm/client/historical.py @@ -27,7 +27,7 @@ def historical_view(ctx, hunt_id): @click.option('-r', '--rule-id', type=click.INT, help='If provided, create the historical hunt from the existing ruleset.') @click.option('-n', '--name', help='Explicitly set the ruleset name for this hunt.') @click.option('--window-days', type=click.Choice(['30', '60', '90', '180']), - help='How many days back the hunt reaches. Omit it for the platform maximum.') + help='How many days back the hunt reaches. Defaults to max') @click.pass_context @utils.any_provided('rule_file', 'rule_id') def historical_start(ctx, rule_file, rule_id, name, window_days): diff --git a/tests/historical_window_test.py b/tests/historical_window_test.py index f307aaf..b9727c7 100644 --- a/tests/historical_window_test.py +++ b/tests/historical_window_test.py @@ -80,7 +80,7 @@ def test_help_documents_the_choices_and_the_default(self): assert result.exit_code == 0, result.output helptext = ' '.join(result.output.split()) assert '--window-days [30|60|90|180]' in helptext, helptext - assert 'platform maximum' in helptext, helptext + assert 'Defaults to max' in helptext, helptext class HuntTimeRangeFormatterTest(TestCase): From 4e190196957ba6d385c77cb3985f8bcb2d37b246 Mon Sep 17 00:00:00 2001 From: Claude Code Date: Wed, 21 Oct 2026 10:58:25 -0300 Subject: [PATCH 6/7] test(historical): record the --window-days exchange `historical start --window-days 30`, in text and JSON, recorded against a live e2e stack: the request carries window_days 30 and the server echoes it, which renders as `Time Range: Last 30 days`. The suite's API host was mapped to the stack for the run, so these cassettes pin the same host as every other one. Co-Authored-By: Alan Justino --- tests/cli_test.py | 14 ++++ ...t_historical_hunt_create_window_json.click | 18 +++++ ...est_historical_hunt_create_window_json.vcr | 73 +++++++++++++++++++ ...t_historical_hunt_create_window_text.click | 16 ++++ ...est_historical_hunt_create_window_text.vcr | 73 +++++++++++++++++++ 5 files changed, 194 insertions(+) create mode 100644 tests/vcr/test_historical_hunt_create_window_json.click create mode 100644 tests/vcr/test_historical_hunt_create_window_json.vcr create mode 100644 tests/vcr/test_historical_hunt_create_window_text.click create mode 100644 tests/vcr/test_historical_hunt_create_window_text.vcr diff --git a/tests/cli_test.py b/tests/cli_test.py index 2acb3bc..4a3cca0 100644 --- a/tests/cli_test.py +++ b/tests/cli_test.py @@ -207,6 +207,20 @@ def test_historical_hunt_create_text(self): '--output-format', 'text', 'historical', 'start', self.resource('eicar.yara')]) self._assert_text_result(result, self.click_vcr(result)) + @vcr.use_cassette() + def test_historical_hunt_create_window_json(self): + result = self._run_cli([ + '--output-format', 'json', 'historical', 'start', self.resource('eicar.yara'), + '--window-days', '30']) + self._assert_json_result(result, self.click_vcr(result)) + + @vcr.use_cassette() + def test_historical_hunt_create_window_text(self): + result = self._run_cli([ + '--output-format', 'text', 'historical', 'start', self.resource('eicar.yara'), + '--window-days', '30']) + self._assert_text_result(result, self.click_vcr(result)) + @vcr.use_cassette() def test_historical_hunt_delete_json(self): result = self._run_cli([ diff --git a/tests/vcr/test_historical_hunt_create_window_json.click b/tests/vcr/test_historical_hunt_create_window_json.click new file mode 100644 index 0000000..44006a9 --- /dev/null +++ b/tests/vcr/test_historical_hunt_create_window_json.click @@ -0,0 +1,18 @@ +result: '{"account_number": "111", "archives_in_flight": 0, "archives_scanned": 0, + "archives_total": 0, "communities": ["gamma"], "created": "2026-10-21T13:56:33.796254+00:00", + "failed_max_retries": 0, "failed_other": 0, "id": "31091646183416323", "progress": + null, "results_csv_uri": null, "rule_id": null, "rule_modified": null, "ruleset_name": + "eicar.yara", "source_rule_changed": null, "status": "PENDING", "summary": null, + "user_account_number": "111", "window_days": 30, "yara": "rule eicar_av_test : eicar + match {\n /*\n Per standard, match only if entire file is EICAR string + plus optional trailing whitespace.\n The raw EICAR string to be matched is:\n X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*\n */\n\n meta:\n description + = \"This is a standard AV test, intended to verify that BinaryAlert is working correctly.\"\n author + = \"Austin Byers | Airbnb CSIRT\"\n reference = \"http://www.eicar.org/86-0-Intended-use.html\"\n\n strings:\n $eicar_regex + = /^X5O!P%@AP\\[4\\\\PZX54\\(P\\^\\)7CC\\)7\\}\\$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!\\$H\\+H\\*\\s*$/\n\n condition:\n all + of them\n}\n\nrule eicar_substring_test : eicar substring {\n /*\n More + generic - match just the embedded EICAR string (e.g. in packed executables, PDFs, + etc)\n */\n\n meta:\n description = \"Standard AV test, checking for + an EICAR substring\"\n author = \"Austin Byers | Airbnb CSIRT\"\n\n strings:\n $eicar_substring + = \"$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!\"\n\n condition:\n all of them\n}"} + + ' diff --git a/tests/vcr/test_historical_hunt_create_window_json.vcr b/tests/vcr/test_historical_hunt_create_window_json.vcr new file mode 100644 index 0000000..d02fd16 --- /dev/null +++ b/tests/vcr/test_historical_hunt_create_window_json.vcr @@ -0,0 +1,73 @@ +interactions: +- request: + body: '{"yara":"rule eicar_av_test : eicar match {\n /*\n Per standard, + match only if entire file is EICAR string plus optional trailing whitespace.\n The + raw EICAR string to be matched is:\n X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*\n */\n\n meta:\n description + = \"This is a standard AV test, intended to verify that BinaryAlert is working + correctly.\"\n author = \"Austin Byers | Airbnb CSIRT\"\n reference + = \"http://www.eicar.org/86-0-Intended-use.html\"\n\n strings:\n $eicar_regex + = /^X5O!P%@AP\\[4\\\\PZX54\\(P\\^\\)7CC\\)7\\}\\$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!\\$H\\+H\\*\\s*$/\n\n condition:\n all + of them\n}\n\nrule eicar_substring_test : eicar substring {\n /*\n More + generic - match just the embedded EICAR string (e.g. in packed executables, + PDFs, etc)\n */\n\n meta:\n description = \"Standard AV test, checking + for an EICAR substring\"\n author = \"Austin Byers | Airbnb CSIRT\"\n\n strings:\n $eicar_substring + = \"$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!\"\n\n condition:\n all of + them\n}","ruleset_name":"eicar.yara","window_days":30,"community":"gamma"}' + headers: + accept: + - '*/*' + accept-encoding: + - gzip, deflate + authorization: + - '11111111111111111111111111111111' + connection: + - keep-alive + content-length: + - '1209' + content-type: + - application/json + host: + - artifact-index-e2e:9696 + user-agent: + - polyswarm_api/4.8.0 (x86_64-Linux-CPython-3.12.15) + method: POST + uri: http://artifact-index-e2e:9696/v3/hunt/historical + response: + body: + string: '{"result":{"account_number":"111","archives_in_flight":0,"archives_scanned":0,"archives_total":0,"communities":["gamma"],"created":"2026-10-21T13:56:33.796254+00:00","failed_max_retries":0,"failed_other":0,"id":"31091646183416323","progress":null,"results_csv_uri":null,"rule_id":null,"rule_modified":null,"ruleset_name":"eicar.yara","source_rule_changed":null,"status":"PENDING","summary":null,"user_account_number":"111","window_days":30,"yara":"rule + eicar_av_test : eicar match {\n /*\n Per standard, match only if + entire file is EICAR string plus optional trailing whitespace.\n The + raw EICAR string to be matched is:\n X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*\n */\n\n meta:\n description + = \"This is a standard AV test, intended to verify that BinaryAlert is working + correctly.\"\n author = \"Austin Byers | Airbnb CSIRT\"\n reference + = \"http://www.eicar.org/86-0-Intended-use.html\"\n\n strings:\n $eicar_regex + = /^X5O!P%@AP\\[4\\\\PZX54\\(P\\^\\)7CC\\)7\\}\\$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!\\$H\\+H\\*\\s*$/\n\n condition:\n all + of them\n}\n\nrule eicar_substring_test : eicar substring {\n /*\n More + generic - match just the embedded EICAR string (e.g. in packed executables, + PDFs, etc)\n */\n\n meta:\n description = \"Standard AV test, + checking for an EICAR substring\"\n author = \"Austin Byers | Airbnb + CSIRT\"\n\n strings:\n $eicar_substring = \"$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!\"\n\n condition:\n all + of them\n}"},"status":"OK"} + + ' + headers: + Access-Control-Allow-Origin: + - '*' + Access-Control-Expose-Headers: + - Authorization + Connection: + - keep-alive + Content-Length: + - '1599' + Content-Type: + - application/json + Date: + - Wed, 21 Oct 2026 13:56:33 GMT + Server: + - gunicorn + X-Billing-ID: + - '111' + status: + code: 200 + message: OK +version: 1 diff --git a/tests/vcr/test_historical_hunt_create_window_text.click b/tests/vcr/test_historical_hunt_create_window_text.click new file mode 100644 index 0000000..ee003ed --- /dev/null +++ b/tests/vcr/test_historical_hunt_create_window_text.click @@ -0,0 +1,16 @@ +result: "Hunt Id: 64911267626296886\nStatus: PENDING\nCreated at: 2026-10-21 13:56:33.858814+00:00\n\ + Time Range: Last 30 days\nRuleset Name: eicar.yara\nRuleset Contents:\nrule eicar_av_test\ + \ : eicar match {\n /*\n Per standard, match only if entire file is EICAR\ + \ string plus optional trailing whitespace.\n The raw EICAR string to be matched\ + \ is:\n X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*\n\ + \ */\n\n meta:\n description = \"This is a standard AV test, intended\ + \ to verify that BinaryAlert is working correctly.\"\n author = \"Austin\ + \ Byers | Airbnb CSIRT\"\n reference = \"http://www.eicar.org/86-0-Intended-use.html\"\ + \n\n strings:\n $eicar_regex = /^X5O!P%@AP\\[4\\\\PZX54\\(P\\^\\)7CC\\\ + )7\\}\\$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!\\$H\\+H\\*\\s*$/\n\n condition:\n\ + \ all of them\n}\n\nrule eicar_substring_test : eicar substring {\n /*\n\ + \ More generic - match just the embedded EICAR string (e.g. in packed executables,\ + \ PDFs, etc)\n */\n\n meta:\n description = \"Standard AV test, checking\ + \ for an EICAR substring\"\n author = \"Austin Byers | Airbnb CSIRT\"\n\n\ + \ strings:\n $eicar_substring = \"$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!\"\ + \n\n condition:\n all of them\n}\n\n" diff --git a/tests/vcr/test_historical_hunt_create_window_text.vcr b/tests/vcr/test_historical_hunt_create_window_text.vcr new file mode 100644 index 0000000..49eb0f9 --- /dev/null +++ b/tests/vcr/test_historical_hunt_create_window_text.vcr @@ -0,0 +1,73 @@ +interactions: +- request: + body: '{"yara":"rule eicar_av_test : eicar match {\n /*\n Per standard, + match only if entire file is EICAR string plus optional trailing whitespace.\n The + raw EICAR string to be matched is:\n X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*\n */\n\n meta:\n description + = \"This is a standard AV test, intended to verify that BinaryAlert is working + correctly.\"\n author = \"Austin Byers | Airbnb CSIRT\"\n reference + = \"http://www.eicar.org/86-0-Intended-use.html\"\n\n strings:\n $eicar_regex + = /^X5O!P%@AP\\[4\\\\PZX54\\(P\\^\\)7CC\\)7\\}\\$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!\\$H\\+H\\*\\s*$/\n\n condition:\n all + of them\n}\n\nrule eicar_substring_test : eicar substring {\n /*\n More + generic - match just the embedded EICAR string (e.g. in packed executables, + PDFs, etc)\n */\n\n meta:\n description = \"Standard AV test, checking + for an EICAR substring\"\n author = \"Austin Byers | Airbnb CSIRT\"\n\n strings:\n $eicar_substring + = \"$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!\"\n\n condition:\n all of + them\n}","ruleset_name":"eicar.yara","window_days":30,"community":"gamma"}' + headers: + accept: + - '*/*' + accept-encoding: + - gzip, deflate + authorization: + - '11111111111111111111111111111111' + connection: + - keep-alive + content-length: + - '1209' + content-type: + - application/json + host: + - artifact-index-e2e:9696 + user-agent: + - polyswarm_api/4.8.0 (x86_64-Linux-CPython-3.12.15) + method: POST + uri: http://artifact-index-e2e:9696/v3/hunt/historical + response: + body: + string: '{"result":{"account_number":"111","archives_in_flight":0,"archives_scanned":0,"archives_total":0,"communities":["gamma"],"created":"2026-10-21T13:56:33.858814+00:00","failed_max_retries":0,"failed_other":0,"id":"64911267626296886","progress":null,"results_csv_uri":null,"rule_id":null,"rule_modified":null,"ruleset_name":"eicar.yara","source_rule_changed":null,"status":"PENDING","summary":null,"user_account_number":"111","window_days":30,"yara":"rule + eicar_av_test : eicar match {\n /*\n Per standard, match only if + entire file is EICAR string plus optional trailing whitespace.\n The + raw EICAR string to be matched is:\n X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*\n */\n\n meta:\n description + = \"This is a standard AV test, intended to verify that BinaryAlert is working + correctly.\"\n author = \"Austin Byers | Airbnb CSIRT\"\n reference + = \"http://www.eicar.org/86-0-Intended-use.html\"\n\n strings:\n $eicar_regex + = /^X5O!P%@AP\\[4\\\\PZX54\\(P\\^\\)7CC\\)7\\}\\$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!\\$H\\+H\\*\\s*$/\n\n condition:\n all + of them\n}\n\nrule eicar_substring_test : eicar substring {\n /*\n More + generic - match just the embedded EICAR string (e.g. in packed executables, + PDFs, etc)\n */\n\n meta:\n description = \"Standard AV test, + checking for an EICAR substring\"\n author = \"Austin Byers | Airbnb + CSIRT\"\n\n strings:\n $eicar_substring = \"$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!\"\n\n condition:\n all + of them\n}"},"status":"OK"} + + ' + headers: + Access-Control-Allow-Origin: + - '*' + Access-Control-Expose-Headers: + - Authorization + Connection: + - keep-alive + Content-Length: + - '1599' + Content-Type: + - application/json + Date: + - Wed, 21 Oct 2026 13:56:33 GMT + Server: + - gunicorn + X-Billing-ID: + - '111' + status: + code: 200 + message: OK +version: 1 From abb92ce30aac60b72bb6c0fd8caa5700fa297e3b Mon Sep 17 00:00:00 2001 From: Claude Code Date: Thu, 22 Oct 2026 13:07:48 -0300 Subject: [PATCH 7/7] =?UTF-8?q?Bump=20version:=204.6.0=20=E2=86=92=204.7.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Release bump for 4.7.0: `historical start --window-days` (#279). The SDK floor `polyswarm_api>=4.8.0` is already pinned, and polyswarm-api 4.8.0 is on PyPI, so the develop → master release can resolve it. Co-Authored-By: Alan Justino --- pyproject.toml | 4 ++-- src/polyswarm/__init__.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index f68deb9..dc11974 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta" [project] name = "polyswarm" -version = "4.6.0" +version = "4.7.0" description = "CLI for using the PolySwarm Customer APIs" readme = "README.md" authors = [{ name = "PolySwarm Developers", email = "info@polyswarm.io" }] @@ -51,7 +51,7 @@ include-package-data = true where = ["src"] [tool.bumpversion] -current_version = "4.6.0" +current_version = "4.7.0" commit = true tag = false sign_tags = true diff --git a/src/polyswarm/__init__.py b/src/polyswarm/__init__.py index 52fde38..372a507 100644 --- a/src/polyswarm/__init__.py +++ b/src/polyswarm/__init__.py @@ -1 +1 @@ -__version__ = '4.6.0' +__version__ = '4.7.0'