From 5e79f5f0795910ed9bf4f5ef6347920c84f86330 Mon Sep 17 00:00:00 2001 From: Sam Whyte Date: Thu, 14 May 2026 08:51:31 -0600 Subject: [PATCH 1/9] Prepare release 0.19.0 - Switch build tooling to pyproject.toml + python -m build (drop setup.py) - Add MANIFEST.in to exclude tools/ symlink from sdist - Remove .bumpversion.cfg (version now static in pyproject.toml) - Update unit-test CI to actions/checkout@v6 and setup-python@v5 - Update tox build-dists env to use python -m build - Add tox to uv dev dependencies, add uv.lock to .gitignore --- .bumpversion.cfg | 9 ----- .github/workflows/unit-test.yml | 10 ++++-- .gitignore | 6 +++- MANIFEST.in | 1 + Makefile | 4 +-- gremlinapi/__init__.py | 19 ++++++----- gremlinapi/attacks.py | 55 ++++++++++++++++++++++--------- gremlinapi/companies.py | 24 ++++++++++---- gremlinapi/reports.py | 28 +++++++++++----- gremlinapi/scenarios.py | 58 +++++++++++++++++++++++---------- gremlinapi/users.py | 49 +++++++++++++++++++++------- gremlinapi/util.py | 13 +++++++- pyproject.toml | 42 ++++++++++++++++++++---- setup.py | 39 ---------------------- tests/test_attacks.py | 10 +++--- tests/test_companies.py | 6 ++-- tests/test_httpclient.py | 4 +++ tests/test_reports.py | 6 ++-- tests/test_scenarios.py | 10 +++--- tests/test_users.py | 11 ++++--- tests/util.py | 7 ++++ tox.ini | 9 ++--- 22 files changed, 267 insertions(+), 153 deletions(-) delete mode 100644 .bumpversion.cfg create mode 100644 MANIFEST.in delete mode 100644 setup.py diff --git a/.bumpversion.cfg b/.bumpversion.cfg deleted file mode 100644 index 00fa960..0000000 --- a/.bumpversion.cfg +++ /dev/null @@ -1,9 +0,0 @@ -[bumpversion] -current_version = 0.18.3 -commit = True -tag = False -sign_tags = True -parse = (?P\d+)\.(?P\d+)\.(?P\d+)(-(?P\d+))? -serialize = {major}.{minor}.{patch}-{build} - -[bumpversion:file:gremlinapi/util.py] diff --git a/.github/workflows/unit-test.yml b/.github/workflows/unit-test.yml index 207de1f..b01cbef 100644 --- a/.github/workflows/unit-test.yml +++ b/.github/workflows/unit-test.yml @@ -17,14 +17,20 @@ jobs: matrix: python-version: - 3.8 + - 3.9 + - 3.10 + - 3.11 + - 3.12 + - 3.13 + - 3.14 os: - ubuntu-latest # Steps represent a sequence of tasks that will be executed as part of the job steps: - - uses: actions/checkout@master + - uses: actions/checkout@v6 - name: Setup Python - uses: actions/setup-python@v1 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: Install modules diff --git a/.gitignore b/.gitignore index a1e621d..622a358 100644 --- a/.gitignore +++ b/.gitignore @@ -91,6 +91,9 @@ ipython_config.py # install all needed dependencies. #Pipfile.lock +# uv +uv.lock + # PEP 582; used by e.g. github.com/David-OConnor/pyflow __pypackages__/ @@ -134,4 +137,5 @@ dmypy.json gremlin-python.iml _*.json -_t.py \ No newline at end of file +_t.py +ENDPOINTS.md \ No newline at end of file diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..4c3d1b7 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1 @@ +prune tools diff --git a/Makefile b/Makefile index d0f4196..ba4b55a 100644 --- a/Makefile +++ b/Makefile @@ -8,10 +8,10 @@ ENV_VARS=.env all: docker-build install: - python3 setup.py install + pip install . package: - python3 setup.py sdist bdist_wheel + python3 -m build test: python3 -m tests.test_all diff --git a/gremlinapi/__init__.py b/gremlinapi/__init__.py index de30b20..7b2cd30 100644 --- a/gremlinapi/__init__.py +++ b/gremlinapi/__init__.py @@ -91,21 +91,24 @@ class SecretsFilter(logging.Filter): def filter(self, record): secret_length = 5 - if len(GremlinAPIConfig.api_key) >= secret_length: + api_key = GremlinAPIConfig.api_key or "" + bearer_token = GremlinAPIConfig.bearer_token or "" + password = GremlinAPIConfig.password or "" + if len(api_key) >= secret_length: record.msg = re.sub( - rf"{GremlinAPIConfig.api_key}[\'\s]?", - "..." + GremlinAPIConfig.api_key[-4:], + rf"{api_key}[\'\s]?", + "..." + api_key[-4:], record.msg, ) - if len(GremlinAPIConfig.bearer_token) >= secret_length: + if len(bearer_token) >= secret_length: record.msg = re.sub( - rf"{GremlinAPIConfig.bearer_token}[\'\s]?", - "..." + GremlinAPIConfig.bearer_token[-4:], + rf"{bearer_token}[\'\s]?", + "..." + bearer_token[-4:], record.msg, ) - if len(GremlinAPIConfig.password) >= secret_length: + if len(password) >= secret_length: record.msg = re.sub( - rf"{GremlinAPIConfig.password}[\'\s]?", + rf"{password}[\'\s]?", "[PASSWORD REDACTED]", record.msg, ) diff --git a/gremlinapi/attacks.py b/gremlinapi/attacks.py index 587c2b3..50b2628 100644 --- a/gremlinapi/attacks.py +++ b/gremlinapi/attacks.py @@ -75,12 +75,27 @@ def list_active_attacks( https_client: Type[GremlinAPIHttpClient] = get_gremlin_httpclient(), *args: tuple, **kwargs: dict, - ) -> dict: + ) -> list: method: str = "GET" - endpoint: str = cls._list_endpoint("/attacks/active", **kwargs) - payload: dict = cls._payload(**{"headers": https_client.header()}) - (resp, body) = https_client.api_call(method, endpoint, **payload) - return body + source: str = kwargs.get("source", None) + page_size = kwargs.get("pageSize", None) + page_token: str = None + all_items: list = [] + while True: + endpoint = cls._optional_team_endpoint("/attacks/active/paged", **kwargs) + if source and source.lower() in ("adhoc", "scenario"): + endpoint = cls._add_query_param(endpoint, "source", source) + if page_size: + endpoint = cls._add_query_param(endpoint, "pageSize", str(page_size)) + if page_token: + endpoint = cls._add_query_param(endpoint, "pageToken", page_token) + payload: dict = cls._payload(**{"headers": https_client.header()}) + (resp, body) = https_client.api_call(method, endpoint, **payload) + all_items.extend(body.get("items", [])) + page_token = body.get("pageToken") or None + if not page_token: + break + return all_items @classmethod @register_cli_action("list_attacks", ("",), ("source", "pageSize", "teamId")) @@ -110,17 +125,27 @@ def list_completed_attacks( https_client: Type[GremlinAPIHttpClient] = get_gremlin_httpclient(), *args: tuple, **kwargs: dict, - ) -> dict: - """ - :param https_client: - :param kwargs: { source(adhoc or scenario, query), pageSize(int32, query), teamId(string, query) } - :return: - """ + ) -> list: method: str = "GET" - endpoint: str = cls._list_endpoint("/attacks/completed", **kwargs) - payload: dict = cls._payload(**{"headers": https_client.header()}) - (resp, body) = https_client.api_call(method, endpoint, **payload) - return body + source: str = kwargs.get("source", None) + page_size = kwargs.get("pageSize", None) + page_token: str = None + all_items: list = [] + while True: + endpoint = cls._optional_team_endpoint("/attacks/completed/paged", **kwargs) + if source and source.lower() in ("adhoc", "scenario"): + endpoint = cls._add_query_param(endpoint, "source", source) + if page_size: + endpoint = cls._add_query_param(endpoint, "pageSize", str(page_size)) + if page_token: + endpoint = cls._add_query_param(endpoint, "pageToken", page_token) + payload: dict = cls._payload(**{"headers": https_client.header()}) + (resp, body) = https_client.api_call(method, endpoint, **payload) + all_items.extend(body.get("items", [])) + page_token = body.get("pageToken") or None + if not page_token: + break + return all_items @classmethod @register_cli_action("get_attack", ("guid",), ("teamId",)) diff --git a/gremlinapi/companies.py b/gremlinapi/companies.py index cf86901..d4dcb9e 100644 --- a/gremlinapi/companies.py +++ b/gremlinapi/companies.py @@ -157,18 +157,30 @@ def update_company_saml_props( return body @classmethod - @register_cli_action("list_company_users", ("identifier",), ("",)) + @register_cli_action("list_company_users", ("identifier",), ("pageSize",)) def list_company_users( cls, https_client: Type[GremlinAPIHttpClient] = get_gremlin_httpclient(), **kwargs: dict, - ) -> dict: + ) -> list: method: str = "GET" identifier: str = cls._error_if_not_param("identifier", **kwargs) - endpoint: str = f"/companies/{identifier}/users" - payload: dict = cls._payload(**{"headers": https_client.header()}) - (resp, body) = https_client.api_call(method, endpoint, **payload) - return body + page_size = kwargs.get("pageSize", None) + page_token: str = None + all_items: list = [] + while True: + endpoint: str = f"/companies/{identifier}/users/paged" + if page_size: + endpoint = cls._add_query_param(endpoint, "pageSize", str(page_size)) + if page_token: + endpoint = cls._add_query_param(endpoint, "pageToken", page_token) + payload: dict = cls._payload(**{"headers": https_client.header()}) + (resp, body) = https_client.api_call(method, endpoint, **payload) + all_items.extend(body.get("items", [])) + page_token = body.get("page_token") or None + if not page_token: + break + return all_items @classmethod @register_cli_action( diff --git a/gremlinapi/reports.py b/gremlinapi/reports.py index cc2b086..4447d32 100644 --- a/gremlinapi/reports.py +++ b/gremlinapi/reports.py @@ -104,15 +104,27 @@ def report_teams( https_client: Type[GremlinAPIHttpClient] = get_gremlin_httpclient(), *args: tuple, **kwargs: dict, - ) -> dict: + ) -> list: method: str = "GET" - params: list = ["startDate", "endDate"] - endpoint: str = cls._build_query_string_option_team_endpoint( - "/reports/teams", params, **kwargs - ) - payload: dict = cls._payload(**{"headers": https_client.header()}) - (resp, body) = https_client.api_call(method, endpoint, **payload) - return body + start_date: str = cls._error_if_not_param("startDate", **kwargs) + end_date: str = cls._error_if_not_param("endDate", **kwargs) + page_size = kwargs.get("pageSize", None) + page_token: str = None + all_items: list = [] + while True: + endpoint = cls._add_query_param("/reports/teams/paged", "startDate", start_date) + endpoint = cls._add_query_param(endpoint, "endDate", end_date) + if page_size: + endpoint = cls._add_query_param(endpoint, "pageSize", str(page_size)) + if page_token: + endpoint = cls._add_query_param(endpoint, "pageToken", page_token) + payload: dict = cls._payload(**{"headers": https_client.header()}) + (resp, body) = https_client.api_call(method, endpoint, **payload) + all_items.extend(body.get("items", [])) + page_token = body.get("pageToken") or None + if not page_token: + break + return all_items @classmethod @register_cli_action("report_users", ("",), ("start", "end", "period", "teamId")) diff --git a/gremlinapi/scenarios.py b/gremlinapi/scenarios.py index 967ef47..283ee84 100644 --- a/gremlinapi/scenarios.py +++ b/gremlinapi/scenarios.py @@ -151,22 +151,34 @@ def list_scenario_runs( https_client: Type[GremlinAPIHttpClient] = get_gremlin_httpclient(), *args: tuple, **kwargs: dict, - ) -> dict: + ) -> list: method: str = "GET" guid: str = cls._error_if_not_param("guid", **kwargs) - timeset: str = "" start: str = cls._info_if_not_param("startDate", **kwargs) end: str = cls._info_if_not_param("endDate", **kwargs) - if start: - timeset += f"startDate={start}&" - if end: - timeset += f"endDate={end}" - endpoint: str = cls._optional_team_endpoint( - f"/scenarios/{guid}/runs/?{timeset}", **kwargs - ) - payload: dict = cls._payload(**{"headers": https_client.header()}) - (resp, body) = https_client.api_call(method, endpoint, **payload) - return body + limit = kwargs.get("pageSize", 50) + run_number: str = None + all_items: list = [] + while True: + endpoint = cls._optional_team_endpoint(f"/scenarios/{guid}/runs/paged", **kwargs) + if start: + endpoint = cls._add_query_param(endpoint, "startDate", start) + if end: + endpoint = cls._add_query_param(endpoint, "endDate", end) + endpoint = cls._add_query_param(endpoint, "limit", str(limit)) + if run_number: + endpoint = cls._add_query_param(endpoint, "runNumber", run_number) + payload: dict = cls._payload(**{"headers": https_client.header()}) + log.debug(f"list_scenario_runs: fetching page runNumber={run_number or 1} endpoint={endpoint}") + (resp, body) = https_client.api_call(method, endpoint, **payload) + page_items = body.get("items", []) + all_items.extend(page_items) + next_run_number = body.get("pageToken") or None + log.debug(f"list_scenario_runs: got {len(page_items)} items, total={len(all_items)}, next pageToken={next_run_number}") + if not next_run_number or not page_items or next_run_number == run_number: + break + run_number = next_run_number + return all_items @classmethod @register_cli_action( @@ -331,12 +343,24 @@ def list_active_scenarios( https_client: Type[GremlinAPIHttpClient] = get_gremlin_httpclient(), *args: tuple, **kwargs: dict, - ) -> dict: + ) -> list: method: str = "GET" - endpoint: str = cls._optional_team_endpoint(f"/scenarios/active", **kwargs) - payload: dict = cls._payload(**{"headers": https_client.header()}) - (resp, body) = https_client.api_call(method, endpoint, **payload) - return body + page_size = kwargs.get("pageSize", None) + page_token: str = None + all_items: list = [] + while True: + endpoint = cls._optional_team_endpoint("/scenarios/active/paged", **kwargs) + if page_size: + endpoint = cls._add_query_param(endpoint, "pageSize", str(page_size)) + if page_token: + endpoint = cls._add_query_param(endpoint, "pageToken", page_token) + payload: dict = cls._payload(**{"headers": https_client.header()}) + (resp, body) = https_client.api_call(method, endpoint, **payload) + all_items.extend(body.get("items", [])) + page_token = body.get("pageToken") or None + if not page_token: + break + return all_items @classmethod @register_cli_action("list_archived_scenarios", ("",), ("teamId",)) diff --git a/gremlinapi/users.py b/gremlinapi/users.py index 47972f5..80e4480 100644 --- a/gremlinapi/users.py +++ b/gremlinapi/users.py @@ -38,7 +38,7 @@ def _error_if_not_valid_role_statement(cls, **kwargs) -> str: return role @classmethod - @register_cli_action("list_user", ("",), ("teamId",)) + @register_cli_action("list_user", ("",), ("teamId", "pageSize")) def list_users( cls, https_client: Type[GremlinAPIHttpClient] = get_gremlin_httpclient(), @@ -46,10 +46,25 @@ def list_users( **kwargs: dict, ) -> dict: method: str = "GET" - endpoint: str = cls._optional_team_endpoint(f"/users", **kwargs) - payload: dict = cls._payload(**{"headers": https_client.header()}) - (resp, body) = https_client.api_call(method, endpoint, **payload) - return body + page_size = kwargs.get("pageSize", None) + page_token: str = None + result: dict = {"active": [], "invited": [], "revoked": []} + state_map: dict = {"ACTIVE": "active", "INVITED": "invited", "REVOKED": "revoked"} + while True: + endpoint: str = cls._optional_team_endpoint("/users/paged", **kwargs) + if page_size: + endpoint = cls._add_query_param(endpoint, "pageSize", str(page_size)) + if page_token: + endpoint = cls._add_query_param(endpoint, "pageToken", page_token) + payload: dict = cls._payload(**{"headers": https_client.header()}) + (resp, body) = https_client.api_call(method, endpoint, **payload) + for item in body.get("items", []): + bucket = state_map.get(item.get("state", "").upper(), "active") + result[bucket].append(item) + page_token = body.get("page_token") + if not page_token: + break + return result @classmethod @register_cli_action("add_user_to_team", ("body",), ("teamId",)) @@ -100,18 +115,30 @@ def deactivate_user( return body @classmethod - @register_cli_action("list_active_user", ("",), ("teamId",)) + @register_cli_action("list_active_user", ("",), ("teamId", "pageSize")) def list_active_users( cls, https_client: Type[GremlinAPIHttpClient] = get_gremlin_httpclient(), *args: tuple, **kwargs: dict, - ) -> dict: + ) -> list: method: str = "GET" - endpoint: str = cls._optional_team_endpoint(f"/users/active", **kwargs) - payload: dict = cls._payload(**{"headers": https_client.header()}) - (resp, body) = https_client.api_call(method, endpoint, **payload) - return body + page_size = kwargs.get("pageSize", None) + page_token: str = None + all_items: list = [] + while True: + endpoint: str = cls._optional_team_endpoint("/users/active/paged", **kwargs) + if page_size: + endpoint = cls._add_query_param(endpoint, "pageSize", str(page_size)) + if page_token: + endpoint = cls._add_query_param(endpoint, "pageToken", page_token) + payload: dict = cls._payload(**{"headers": https_client.header()}) + (resp, body) = https_client.api_call(method, endpoint, **payload) + all_items.extend(body.get("items", [])) + page_token = body.get("page_token") or None + if not page_token: + break + return all_items @classmethod @register_cli_action("invite_user", ("email",), ("teamId",)) diff --git a/gremlinapi/util.py b/gremlinapi/util.py index b27ee27..00bb970 100644 --- a/gremlinapi/util.py +++ b/gremlinapi/util.py @@ -7,7 +7,18 @@ log = logging.getLogger("GremlinAPI.client") -_version = "0.18.3" +try: + from importlib.metadata import version as _pkg_version + _version = _pkg_version("gremlinapi") +except Exception: + try: + import re + from pathlib import Path + _pyproject = Path(__file__).resolve().parent.parent / "pyproject.toml" + _match = re.search(r'^version\s*=\s*"([^"]+)"', _pyproject.read_text(), re.MULTILINE) + _version = _match.group(1) if _match else "unknown" + except Exception: + _version = "unknown" def get_version(): diff --git a/pyproject.toml b/pyproject.toml index 729a118..8e91dee 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,9 +1,37 @@ +[project] +name = "gremlinapi" +version = "0.19.0" +description = "Gremlin library for Python" +readme = "README.md" +license = { text = "Apache 2.0" } +authors = [{ name = "Kyle Hultman", email = "kyle@gremlin.com" }] +requires-python = ">=3.7" +dependencies = [ + "requests>=2.22.0", + "urllib3>=1.25.8", +] +classifiers = [ + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", +] + +[project.urls] +Homepage = "https://github.com/gremlin/gremlin-python/" + +[project.scripts] +pgremlin = "gremlinapi.cli:main" + +[tool.setuptools] +include-package-data = true + +[tool.setuptools.packages.find] +exclude = ["temp*.py", "test"] + [build-system] - requires = [ - "setuptools >= 40.9.0", - "setuptools_scm >= 1.15.0", - "setuptools_scm_git_archive >= 1.0", - "wheel", - "gremlinapi", +requires = ["setuptools>=40.9.0", "wheel"] +build-backend = "setuptools.build_meta" + +[dependency-groups] +dev = [ + "tox>=4.8.0", ] -build-backend = "setuptools.build_meta" \ No newline at end of file diff --git a/setup.py b/setup.py deleted file mode 100644 index 6a05320..0000000 --- a/setup.py +++ /dev/null @@ -1,39 +0,0 @@ -import io -import os -from distutils.file_util import copy_file -from setuptools import setup, find_packages - -from gremlinapi.util import get_version - - -__version__ = get_version() - - -def getRequires(): - deps = ["requests>=2.22.0", "urllib3>=1.25.8"] - return deps - - -dir_path = os.path.abspath(os.path.dirname(__file__)) -readme = io.open(os.path.join(dir_path, "README.md"), encoding="utf-8").read() - -setup( - name="gremlinapi", - version=str(__version__), - author="Kyle Hultman", - author_email="kyle@gremlin.com", - url="https://github.com/gremlin/gremlin-python/", - packages=find_packages(exclude=["temp*.py", "test"]), - include_package_data=True, - license="Apache 2.0", - description="Gremlin library for Python", - long_description=readme, - long_description_content_type="text/markdown", - install_requires=getRequires(), - python_requires=">=3.7", - entry_points={"console_scripts": ["pgremlin = gremlinapi.cli:main"]}, - classifiers=[ - "Programming Language :: Python :: 3.7", - "Programming Language :: Python :: 3.8", - ], -) diff --git a/tests/test_attacks.py b/tests/test_attacks.py index b5a85c0..afa86f7 100644 --- a/tests/test_attacks.py +++ b/tests/test_attacks.py @@ -13,7 +13,7 @@ GremlinLatencyAttack, ) -from .util import mock_json, mock_data +from .util import mock_json, mock_data, mock_paged_json, mock_paged_data class TestAttacks(unittest.TestCase): @@ -43,8 +43,8 @@ def test_create_attack_with_decorator(self, mock_get) -> None: def test_list_active_attacks_with_decorator(self, mock_get) -> None: mock_get.return_value = requests.Response() mock_get.return_value.status_code = 200 - mock_get.return_value.json = mock_json - self.assertEqual(GremlinAPIAttacks.list_active_attacks(), mock_data) + mock_get.return_value.json = mock_paged_json + self.assertEqual(GremlinAPIAttacks.list_active_attacks(), [mock_data]) @patch("requests.get") def test_list_attacks_with_decorator(self, mock_get) -> None: @@ -57,8 +57,8 @@ def test_list_attacks_with_decorator(self, mock_get) -> None: def test_list_completed_attacks_with_decorator(self, mock_get) -> None: mock_get.return_value = requests.Response() mock_get.return_value.status_code = 200 - mock_get.return_value.json = mock_json - self.assertEqual(GremlinAPIAttacks.list_completed_attacks(), mock_data) + mock_get.return_value.json = mock_paged_json + self.assertEqual(GremlinAPIAttacks.list_completed_attacks(), [mock_data]) @patch("requests.get") def test_get_attack_with_decorator(self, mock_get) -> None: diff --git a/tests/test_companies.py b/tests/test_companies.py index 0733ca3..2841c77 100644 --- a/tests/test_companies.py +++ b/tests/test_companies.py @@ -4,7 +4,7 @@ import requests from gremlinapi.companies import GremlinAPICompanies -from .util import mock_json, mock_data, mock_identifier, hooli_id +from .util import mock_json, mock_data, mock_paged_json, mock_paged_data, mock_identifier, hooli_id class TestCompanies(unittest.TestCase): @@ -73,9 +73,9 @@ def test_update_company_saml_props_with_decorator(self, mock_get) -> None: def test_list_company_users_with_decorator(self, mock_get) -> None: mock_get.return_value = requests.Response() mock_get.return_value.status_code = 200 - mock_get.return_value.json = mock_json + mock_get.return_value.json = mock_paged_json self.assertEqual( - GremlinAPICompanies.list_company_users(**mock_identifier), mock_data + GremlinAPICompanies.list_company_users(**mock_identifier), [mock_data] ) @patch("requests.put") diff --git a/tests/test_httpclient.py b/tests/test_httpclient.py index e71a994..bb8d5a3 100644 --- a/tests/test_httpclient.py +++ b/tests/test_httpclient.py @@ -17,6 +17,10 @@ class TestHttpClient(unittest.TestCase): + def setUp(self) -> None: + config.api_key = None + config.bearer_token = None + def test_api_key(self) -> None: config.api_key = api_key https_client: GremlinAPIHttpClient = get_gremlin_httpclient() diff --git a/tests/test_reports.py b/tests/test_reports.py index f239ec9..f5807db 100644 --- a/tests/test_reports.py +++ b/tests/test_reports.py @@ -4,7 +4,7 @@ import requests from gremlinapi.reports import GremlinAPIReports, GremlinAPIReportsSecurity -from .util import mock_json, mock_data, mock_report +from .util import mock_json, mock_data, mock_paged_json, mock_paged_data, mock_report class TestReports(unittest.TestCase): @@ -40,8 +40,8 @@ def test_report_pricing_with_decorator(self, mock_get) -> None: def test_report_teams_with_decorator(self, mock_get) -> None: mock_get.return_value = requests.Response() mock_get.return_value.status_code = 200 - mock_get.return_value.json = mock_json - self.assertEqual(GremlinAPIReports.report_teams(**mock_report), mock_data) + mock_get.return_value.json = mock_paged_json + self.assertEqual(GremlinAPIReports.report_teams(**mock_report), [mock_data]) @patch("requests.get") def test_report_users_with_decorator(self, mock_get) -> None: diff --git a/tests/test_scenarios.py b/tests/test_scenarios.py index 7bd4e61..671c2e4 100644 --- a/tests/test_scenarios.py +++ b/tests/test_scenarios.py @@ -4,7 +4,7 @@ import requests from gremlinapi.scenarios import GremlinAPIScenarios, GremlinAPIScenariosRecommended -from .util import mock_json, mock_data, mock_scenario, mock_payload, mock_scenario_guid +from .util import mock_json, mock_data, mock_paged_json, mock_paged_data, mock_scenario, mock_payload, mock_scenario_guid class TestScenarios(unittest.TestCase): @@ -66,9 +66,9 @@ def test_restore_scenario_with_decorator(self, mock_get) -> None: def test_list_scenario_runs_with_decorator(self, mock_get) -> None: mock_get.return_value = requests.Response() mock_get.return_value.status_code = 200 - mock_get.return_value.json = mock_json + mock_get.return_value.json = mock_paged_json self.assertEqual( - GremlinAPIScenarios.list_scenario_runs(**mock_scenario_guid), mock_data + GremlinAPIScenarios.list_scenario_runs(**mock_scenario_guid), [mock_data] ) @patch("requests.get") @@ -132,9 +132,9 @@ def test_list_scenario_schedules_with_decorator(self, mock_get) -> None: def test_list_active_scenarios_with_decorator(self, mock_get) -> None: mock_get.return_value = requests.Response() mock_get.return_value.status_code = 200 - mock_get.return_value.json = mock_json + mock_get.return_value.json = mock_paged_json self.assertEqual( - GremlinAPIScenarios.list_active_scenarios(**mock_scenario_guid), mock_data + GremlinAPIScenarios.list_active_scenarios(**mock_scenario_guid), [mock_data] ) @patch("requests.get") diff --git a/tests/test_users.py b/tests/test_users.py index 71af717..bb3aac8 100644 --- a/tests/test_users.py +++ b/tests/test_users.py @@ -8,7 +8,7 @@ GremlinAPIUsersAuthMFA, ) -from .util import mock_json, mock_data, mock_users, mock_body +from .util import mock_json, mock_data, mock_users, mock_body, mock_paged_json, mock_paged_data class TestUsers(unittest.TestCase): @@ -21,8 +21,9 @@ def test__error_if_not_valid_role_statement(self) -> None: def test_list_users_with_decorator(self, mock_get) -> None: mock_get.return_value = requests.Response() mock_get.return_value.status_code = 200 - mock_get.return_value.json = mock_json - self.assertEqual(GremlinAPIUsers.list_users(), mock_data) + mock_get.return_value.json = mock_paged_json + expected = {"active": mock_paged_data["items"], "invited": [], "revoked": []} + self.assertEqual(GremlinAPIUsers.list_users(), expected) @patch("requests.post") def test_add_user_to_team_with_decorator(self, mock_get) -> None: @@ -49,8 +50,8 @@ def test_deactivate_user_with_decorator(self, mock_get) -> None: def test_list_active_users_with_decorator(self, mock_get) -> None: mock_get.return_value = requests.Response() mock_get.return_value.status_code = 200 - mock_get.return_value.json = mock_json - self.assertEqual(GremlinAPIUsers.list_active_users(), mock_data) + mock_get.return_value.json = mock_paged_json + self.assertEqual(GremlinAPIUsers.list_active_users(), [mock_data]) @patch("requests.post") def test_invite_user_with_decorator(self, mock_get) -> None: diff --git a/tests/util.py b/tests/util.py index b40ff43..3eef11c 100644 --- a/tests/util.py +++ b/tests/util.py @@ -22,6 +22,13 @@ def bearer_token_json(): def mock_json(): return mock_data + +mock_paged_data = {"items": [mock_data]} + + +def mock_paged_json(): + return mock_paged_data + mock_base_url = "https://api.gremlin.com/v1" mock_org_id = "1234567890a" diff --git a/tox.ini b/tox.ini index 8276d25..2d1b524 100644 --- a/tox.ini +++ b/tox.ini @@ -13,14 +13,11 @@ usedevelop = false skip_install = true ignore_outcome=true deps = - urllib3 >= 1.25.8 - requests >= 2.22.0 + build setenv = PYPI_UPLOAD = true commands = - rm -rfv {toxinidir}/dist/ - {envpython} setup.py sdist bdist_wheel -whitelist_externals = - rm + rm -rfv {toxinidir}/dist/ {toxinidir}/build/ + {envpython} -m build allowlist_externals = rm \ No newline at end of file From 46f5593340308a92e80bcddea0be45d41d67796d Mon Sep 17 00:00:00 2001 From: Sam Whyte Date: Thu, 14 May 2026 09:21:35 -0600 Subject: [PATCH 2/9] Quote Python version strings in unit-test matrix to preserve trailing zeros --- .github/workflows/unit-test.yml | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/unit-test.yml b/.github/workflows/unit-test.yml index b01cbef..c0a07a7 100644 --- a/.github/workflows/unit-test.yml +++ b/.github/workflows/unit-test.yml @@ -16,13 +16,13 @@ jobs: max-parallel: 1 matrix: python-version: - - 3.8 - - 3.9 - - 3.10 - - 3.11 - - 3.12 - - 3.13 - - 3.14 + - "3.8" + - "3.9" + - "3.10" + - "3.11" + - "3.12" + - "3.13" + - "3.14" os: - ubuntu-latest From 0cf869229fe55400e2e10866b86f1770a2829967 Mon Sep 17 00:00:00 2001 From: Sam Whyte Date: Thu, 14 May 2026 11:29:37 -0600 Subject: [PATCH 3/9] Update GitHub Actions versions and add FORCE_JAVASCRIPT_ACTIONS_TO_NODE24 env var --- .github/workflows/pypi-prod.yaml | 9 ++++++--- .github/workflows/pypi-test.yaml | 9 ++++++--- .github/workflows/unit-test.yml | 5 ++++- 3 files changed, 16 insertions(+), 7 deletions(-) diff --git a/.github/workflows/pypi-prod.yaml b/.github/workflows/pypi-prod.yaml index e046c69..57a7e10 100644 --- a/.github/workflows/pypi-prod.yaml +++ b/.github/workflows/pypi-prod.yaml @@ -5,6 +5,9 @@ on: branches: - main +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + jobs: build-n-publish: name: Build and publish Python @@ -13,13 +16,13 @@ jobs: max-parallel: 1 matrix: python-version: - - 3.8 + - "3.8" os: - ubuntu-latest steps: - - uses: actions/checkout@master + - uses: actions/checkout@v6 - name: Setup Python - uses: actions/setup-python@v1 + uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} - name: Install tox diff --git a/.github/workflows/pypi-test.yaml b/.github/workflows/pypi-test.yaml index 1c1c456..2844130 100644 --- a/.github/workflows/pypi-test.yaml +++ b/.github/workflows/pypi-test.yaml @@ -5,6 +5,9 @@ on: branches-ignore: - main +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + jobs: build-n-publish: name: Build and publish Python @@ -13,13 +16,13 @@ jobs: max-parallel: 1 matrix: python-version: - - 3.8 + - "3.8" os: - ubuntu-latest steps: - - uses: actions/checkout@master + - uses: actions/checkout@v6 - name: Setup Python - uses: actions/setup-python@v1 + uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} - name: Install tox diff --git a/.github/workflows/unit-test.yml b/.github/workflows/unit-test.yml index c0a07a7..443481b 100644 --- a/.github/workflows/unit-test.yml +++ b/.github/workflows/unit-test.yml @@ -8,6 +8,9 @@ on: # Allows you to run this workflow manually from the Actions tab workflow_dispatch: +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + jobs: build: name: Execute Unit Tests @@ -30,7 +33,7 @@ jobs: steps: - uses: actions/checkout@v6 - name: Setup Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} - name: Install modules From 0abdd37f0ba0f16bd019e04f81bb2177791fb50a Mon Sep 17 00:00:00 2001 From: Sam Whyte Date: Thu, 14 May 2026 13:22:41 -0600 Subject: [PATCH 4/9] Remove Makefile and clean up pyproject.toml - Drop Makefile (superseded by uv and tox) - Expand Python classifiers to 3.8-3.14 to match CI matrix - Bump requires-python to >=3.8 (3.7 is EOL) - Raise setuptools build requirement to >=61.0.0 for PEP 621 support - Drop wheel from build-system requires --- Makefile | 50 -------------------------------------------------- pyproject.toml | 11 ++++++++--- 2 files changed, 8 insertions(+), 53 deletions(-) delete mode 100644 Makefile diff --git a/Makefile b/Makefile deleted file mode 100644 index ba4b55a..0000000 --- a/Makefile +++ /dev/null @@ -1,50 +0,0 @@ -DOCKER_OWNER=gremlin -DOCKER_NAME=gremlin-python-api -DOCKER_IMAGE=$(DOCKER_OWNER)/$(DOCKER_NAME) - -BUILD_DATE=$(shell date -u +'%Y-%m-%dT%H:%M:%SZ') -ENV_VARS=.env - -all: docker-build - -install: - pip install . - -package: - python3 -m build - -test: - python3 -m tests.test_all - pytest tests/pytest_* - -lint: typecheck - python3 -m black $(PWD)/gremlinapi - python3 -m black $(PWD)/tests - -typecheck: - mypy gremlinapi - -pypi-test: export TWINE_PASSWORD=$(PYPI_TEST) -pypi-test: package - python3 -m twine upload --non-interactive --config-file ${HOME}/.pypirc --repository testpypi dist/* - -pypi-prod: export TWINE_PASSWORD=$(PYPI_PROD) -pypi-prod: package - python3 -m twine upload --non-interactive --config-file ${HOME}/.pypirc dist/* - -docker-build: - docker build --no-cache=true \ - --build-arg BUILD_DATE=$(BUILD_DATE) \ - --build-arg IMAGE_NAME=$(DOCKER_IMAGE) \ - -t $(DOCKER_IMAGE):latest \ - . - -docker-run: - @if ! test -f $(ENV_VARS); then touch $(ENV_VARS); fi - docker run --rm -v ~/.ssh/:/root/.ssh --mount type=bind,source="$(PWD)",target=/opt/gremlin-python \ - --env-file=$(ENV_VARS) --name $(DOCKER_NAME) --entrypoint "/bin/ash" $(DOCKER_IMAGE):latest - -docker-run-interactive: - @if ! test -f $(ENV_VARS); then touch $(ENV_VARS); fi - docker run -it --rm -v ~/.ssh/:/root/.ssh --mount type=bind,source="$(PWD)",target=/opt/gremlin-python \ - --env-file=$(ENV_VARS) --name $(DOCKER_NAME) --entrypoint "/bin/ash" $(DOCKER_IMAGE):latest diff --git a/pyproject.toml b/pyproject.toml index 8e91dee..da84f70 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,14 +5,19 @@ description = "Gremlin library for Python" readme = "README.md" license = { text = "Apache 2.0" } authors = [{ name = "Kyle Hultman", email = "kyle@gremlin.com" }] -requires-python = ">=3.7" +requires-python = ">=3.8" dependencies = [ "requests>=2.22.0", "urllib3>=1.25.8", ] classifiers = [ - "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", ] [project.urls] @@ -28,7 +33,7 @@ include-package-data = true exclude = ["temp*.py", "test"] [build-system] -requires = ["setuptools>=40.9.0", "wheel"] +requires = ["setuptools>=61.0.0"] build-backend = "setuptools.build_meta" [dependency-groups] From fade348036e16c6b4d4b34b01d94cdc82032816e Mon Sep 17 00:00:00 2001 From: Sam Whyte Date: Thu, 14 May 2026 13:48:50 -0600 Subject: [PATCH 5/9] Fix secrets filter escaping, report_teams team scoping, and test coverage - Escape secrets with re.escape() in SecretsFilter to handle metacharacters - Add _optional_team_endpoint to report_teams paged loop (was silently ignored) - Add multi-page pagination test for report_teams - Update CONTRIBUTING.md to reflect static version in pyproject.toml - Bump setuptools requirement to >=61.0.0 for PEP 621 support, drop wheel - Expand Python classifiers to 3.8-3.14, bump requires-python to >=3.8 --- CONTRIBUTING.md | 20 +++++--------------- gremlinapi/__init__.py | 6 +++--- gremlinapi/reports.py | 1 + pyproject.toml | 1 + tests/test_reports.py | 25 ++++++++++++++++++++++++- tests/util.py | 10 ++++++++++ 6 files changed, 44 insertions(+), 19 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8035fa5..373abc5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,21 +4,11 @@ Package version control conforms to semantic versioning. -This package uses [bumpversion](https://github.com/peritus/bumpversion) to manage the version +The version is defined statically in `pyproject.toml`: -Quick usage: - -Major version update: -```bash -bumpversion --new-version 1.0.0 major -``` - -Minor version update: -```bash -bumpversion --new-version 0.4.0 minor +```toml +[project] +version = "0.19.0" ``` -Patch version update: -```bash -bumpversion --new-version 0.3.13 patch -``` +To cut a release, update the `version` field manually and open a PR against `main`. diff --git a/gremlinapi/__init__.py b/gremlinapi/__init__.py index 7b2cd30..6a59d41 100644 --- a/gremlinapi/__init__.py +++ b/gremlinapi/__init__.py @@ -96,19 +96,19 @@ def filter(self, record): password = GremlinAPIConfig.password or "" if len(api_key) >= secret_length: record.msg = re.sub( - rf"{api_key}[\'\s]?", + rf"{re.escape(api_key)}[\'\s]?", "..." + api_key[-4:], record.msg, ) if len(bearer_token) >= secret_length: record.msg = re.sub( - rf"{bearer_token}[\'\s]?", + rf"{re.escape(bearer_token)}[\'\s]?", "..." + bearer_token[-4:], record.msg, ) if len(password) >= secret_length: record.msg = re.sub( - rf"{password}[\'\s]?", + rf"{re.escape(password)}[\'\s]?", "[PASSWORD REDACTED]", record.msg, ) diff --git a/gremlinapi/reports.py b/gremlinapi/reports.py index 4447d32..82f34d9 100644 --- a/gremlinapi/reports.py +++ b/gremlinapi/reports.py @@ -114,6 +114,7 @@ def report_teams( while True: endpoint = cls._add_query_param("/reports/teams/paged", "startDate", start_date) endpoint = cls._add_query_param(endpoint, "endDate", end_date) + endpoint = cls._optional_team_endpoint(endpoint, **kwargs) if page_size: endpoint = cls._add_query_param(endpoint, "pageSize", str(page_size)) if page_token: diff --git a/pyproject.toml b/pyproject.toml index da84f70..42f5380 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,6 +9,7 @@ requires-python = ">=3.8" dependencies = [ "requests>=2.22.0", "urllib3>=1.25.8", + "pytest>=7.3.1" ] classifiers = [ "Programming Language :: Python :: 3.8", diff --git a/tests/test_reports.py b/tests/test_reports.py index f5807db..fef604a 100644 --- a/tests/test_reports.py +++ b/tests/test_reports.py @@ -4,7 +4,15 @@ import requests from gremlinapi.reports import GremlinAPIReports, GremlinAPIReportsSecurity -from .util import mock_json, mock_data, mock_paged_json, mock_paged_data, mock_report +from .util import ( + mock_json, + mock_data, + mock_paged_json, + mock_paged_data, + mock_paged_json_page1, + mock_paged_json_page2, + mock_report, +) class TestReports(unittest.TestCase): @@ -43,6 +51,21 @@ def test_report_teams_with_decorator(self, mock_get) -> None: mock_get.return_value.json = mock_paged_json self.assertEqual(GremlinAPIReports.report_teams(**mock_report), [mock_data]) + @patch("requests.get") + def test_report_teams_pagination(self, mock_get) -> None: + page1 = requests.Response() + page1.status_code = 200 + page1.json = mock_paged_json_page1 + page2 = requests.Response() + page2.status_code = 200 + page2.json = mock_paged_json_page2 + mock_get.side_effect = [page1, page2] + result = GremlinAPIReports.report_teams(**mock_report) + self.assertEqual(len(result), 2) + self.assertEqual(mock_get.call_count, 2) + second_call_url = mock_get.call_args_list[1][0][0] + self.assertIn("pageToken=next-page-token", second_call_url) + @patch("requests.get") def test_report_users_with_decorator(self, mock_get) -> None: mock_get.return_value = requests.Response() diff --git a/tests/util.py b/tests/util.py index 3eef11c..1bd5ae1 100644 --- a/tests/util.py +++ b/tests/util.py @@ -24,11 +24,21 @@ def mock_json(): mock_paged_data = {"items": [mock_data]} +mock_paged_data_page1 = {"items": [mock_data], "pageToken": "next-page-token"} +mock_paged_data_page2 = {"items": [{"testkey": "testval2"}]} def mock_paged_json(): return mock_paged_data + +def mock_paged_json_page1(): + return mock_paged_data_page1 + + +def mock_paged_json_page2(): + return mock_paged_data_page2 + mock_base_url = "https://api.gremlin.com/v1" mock_org_id = "1234567890a" From ca342395dd3701d6d969f22f5826da2d3569b7cc Mon Sep 17 00:00:00 2001 From: Sam Whyte Date: Thu, 14 May 2026 13:49:25 -0600 Subject: [PATCH 6/9] Bump version to 0.19.1 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 42f5380..3140818 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "gremlinapi" -version = "0.19.0" +version = "0.19.1" description = "Gremlin library for Python" readme = "README.md" license = { text = "Apache 2.0" } From 12aa2cf961241d62315b03326453ee3682f7d724 Mon Sep 17 00:00:00 2001 From: Sam Whyte Date: Thu, 14 May 2026 14:07:18 -0600 Subject: [PATCH 7/9] Add pagination tests, fix CLI metadata, move pytest to dev deps - Add multi-page tests for list_active_attacks and list_completed_attacks - Add multi-page tests for list_scenario_runs and list_active_scenarios - Add pageSize to register_cli_action metadata for list_scenario_runs, list_active_scenarios, and report_teams - Move pytest from runtime to dev dependencies - Update CONTRIBUTING.md version example to 0.19.2 --- CONTRIBUTING.md | 2 +- gremlinapi/reports.py | 2 +- gremlinapi/scenarios.py | 3 ++- pyproject.toml | 4 ++-- tests/test_attacks.py | 32 ++++++++++++++++++++++++++++++- tests/test_scenarios.py | 42 ++++++++++++++++++++++++++++++++++++++++- 6 files changed, 78 insertions(+), 7 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 373abc5..c7da5f2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -8,7 +8,7 @@ The version is defined statically in `pyproject.toml`: ```toml [project] -version = "0.19.0" +version = "0.19.2" ``` To cut a release, update the `version` field manually and open a PR against `main`. diff --git a/gremlinapi/reports.py b/gremlinapi/reports.py index 82f34d9..bf59f8e 100644 --- a/gremlinapi/reports.py +++ b/gremlinapi/reports.py @@ -98,7 +98,7 @@ def report_pricing( return body @classmethod - @register_cli_action("report_teams", ("",), ("start", "end", "period", "teamId")) + @register_cli_action("report_teams", ("",), ("start", "end", "period", "teamId", "pageSize")) def report_teams( cls, https_client: Type[GremlinAPIHttpClient] = get_gremlin_httpclient(), diff --git a/gremlinapi/scenarios.py b/gremlinapi/scenarios.py index 283ee84..08f44ff 100644 --- a/gremlinapi/scenarios.py +++ b/gremlinapi/scenarios.py @@ -144,6 +144,7 @@ def restore_scenario( "startDate", "endDate", "teamId", + "pageSize", ), ) def list_scenario_runs( @@ -337,7 +338,7 @@ def list_scenario_schedules( return body @classmethod - @register_cli_action("list_active_scenarios", ("",), ("teamId",)) + @register_cli_action("list_active_scenarios", ("",), ("teamId", "pageSize")) def list_active_scenarios( cls, https_client: Type[GremlinAPIHttpClient] = get_gremlin_httpclient(), diff --git a/pyproject.toml b/pyproject.toml index 3140818..c715f8a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "gremlinapi" -version = "0.19.1" +version = "0.19.2" description = "Gremlin library for Python" readme = "README.md" license = { text = "Apache 2.0" } @@ -9,7 +9,6 @@ requires-python = ">=3.8" dependencies = [ "requests>=2.22.0", "urllib3>=1.25.8", - "pytest>=7.3.1" ] classifiers = [ "Programming Language :: Python :: 3.8", @@ -39,5 +38,6 @@ build-backend = "setuptools.build_meta" [dependency-groups] dev = [ + "pytest>=7.3.1", "tox>=4.8.0", ] diff --git a/tests/test_attacks.py b/tests/test_attacks.py index afa86f7..9d2c7bd 100644 --- a/tests/test_attacks.py +++ b/tests/test_attacks.py @@ -13,7 +13,7 @@ GremlinLatencyAttack, ) -from .util import mock_json, mock_data, mock_paged_json, mock_paged_data +from .util import mock_json, mock_data, mock_paged_json, mock_paged_data, mock_paged_json_page1, mock_paged_json_page2 class TestAttacks(unittest.TestCase): @@ -46,6 +46,21 @@ def test_list_active_attacks_with_decorator(self, mock_get) -> None: mock_get.return_value.json = mock_paged_json self.assertEqual(GremlinAPIAttacks.list_active_attacks(), [mock_data]) + @patch("requests.get") + def test_list_active_attacks_pagination(self, mock_get) -> None: + page1 = requests.Response() + page1.status_code = 200 + page1.json = mock_paged_json_page1 + page2 = requests.Response() + page2.status_code = 200 + page2.json = mock_paged_json_page2 + mock_get.side_effect = [page1, page2] + result = GremlinAPIAttacks.list_active_attacks() + self.assertEqual(len(result), 2) + self.assertEqual(mock_get.call_count, 2) + second_call_url = mock_get.call_args_list[1][0][0] + self.assertIn("pageToken=next-page-token", second_call_url) + @patch("requests.get") def test_list_attacks_with_decorator(self, mock_get) -> None: mock_get.return_value = requests.Response() @@ -60,6 +75,21 @@ def test_list_completed_attacks_with_decorator(self, mock_get) -> None: mock_get.return_value.json = mock_paged_json self.assertEqual(GremlinAPIAttacks.list_completed_attacks(), [mock_data]) + @patch("requests.get") + def test_list_completed_attacks_pagination(self, mock_get) -> None: + page1 = requests.Response() + page1.status_code = 200 + page1.json = mock_paged_json_page1 + page2 = requests.Response() + page2.status_code = 200 + page2.json = mock_paged_json_page2 + mock_get.side_effect = [page1, page2] + result = GremlinAPIAttacks.list_completed_attacks() + self.assertEqual(len(result), 2) + self.assertEqual(mock_get.call_count, 2) + second_call_url = mock_get.call_args_list[1][0][0] + self.assertIn("pageToken=next-page-token", second_call_url) + @patch("requests.get") def test_get_attack_with_decorator(self, mock_get) -> None: mock_get.return_value = requests.Response() diff --git a/tests/test_scenarios.py b/tests/test_scenarios.py index 671c2e4..4ae7199 100644 --- a/tests/test_scenarios.py +++ b/tests/test_scenarios.py @@ -4,7 +4,17 @@ import requests from gremlinapi.scenarios import GremlinAPIScenarios, GremlinAPIScenariosRecommended -from .util import mock_json, mock_data, mock_paged_json, mock_paged_data, mock_scenario, mock_payload, mock_scenario_guid +from .util import ( + mock_json, + mock_data, + mock_paged_json, + mock_paged_data, + mock_paged_json_page1, + mock_paged_json_page2, + mock_scenario, + mock_payload, + mock_scenario_guid, +) class TestScenarios(unittest.TestCase): @@ -71,6 +81,21 @@ def test_list_scenario_runs_with_decorator(self, mock_get) -> None: GremlinAPIScenarios.list_scenario_runs(**mock_scenario_guid), [mock_data] ) + @patch("requests.get") + def test_list_scenario_runs_pagination(self, mock_get) -> None: + page1 = requests.Response() + page1.status_code = 200 + page1.json = mock_paged_json_page1 + page2 = requests.Response() + page2.status_code = 200 + page2.json = mock_paged_json_page2 + mock_get.side_effect = [page1, page2] + result = GremlinAPIScenarios.list_scenario_runs(**mock_scenario_guid) + self.assertEqual(len(result), 2) + self.assertEqual(mock_get.call_count, 2) + second_call_url = mock_get.call_args_list[1][0][0] + self.assertIn("runNumber=next-page-token", second_call_url) + @patch("requests.get") def test_list_scenarios_runs_with_decorator(self, mock_get) -> None: mock_get.return_value = requests.Response() @@ -137,6 +162,21 @@ def test_list_active_scenarios_with_decorator(self, mock_get) -> None: GremlinAPIScenarios.list_active_scenarios(**mock_scenario_guid), [mock_data] ) + @patch("requests.get") + def test_list_active_scenarios_pagination(self, mock_get) -> None: + page1 = requests.Response() + page1.status_code = 200 + page1.json = mock_paged_json_page1 + page2 = requests.Response() + page2.status_code = 200 + page2.json = mock_paged_json_page2 + mock_get.side_effect = [page1, page2] + result = GremlinAPIScenarios.list_active_scenarios(**mock_scenario_guid) + self.assertEqual(len(result), 2) + self.assertEqual(mock_get.call_count, 2) + second_call_url = mock_get.call_args_list[1][0][0] + self.assertIn("pageToken=next-page-token", second_call_url) + @patch("requests.get") def test_list_archived_scenarios_with_decorator(self, mock_get) -> None: mock_get.return_value = requests.Response() From 6000292a2acfa1711d278e1e5dbbade67350908d Mon Sep 17 00:00:00 2001 From: Sam Whyte Date: Thu, 14 May 2026 14:25:49 -0600 Subject: [PATCH 8/9] Replace setup.py install with pip install . in README --- README.md | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/README.md b/README.md index 77d5e8f..8730a1d 100644 --- a/README.md +++ b/README.md @@ -18,22 +18,11 @@ pip3 install gremlinapi ```bash git clone git@github.com:gremlin/gremlin-python.git cd gremlin-python -python3 setup.py install -``` - -### Use Packaged Docker runtime - -Build and run this project's self contained docker image with all the necessary dependencies -```shell script - make docker-build && make docker-run-interactive +pip3 install . ``` ## Usage -### CLI - -Coming soon - ## Authenticate to the API The Gremlin API requires a form of authentication, either API Key or Bearer Token. API Keys are the least privileged From fca5252fef688cd1882d4106bdf5bd8fee3ab685 Mon Sep 17 00:00:00 2001 From: Sam Whyte Date: Thu, 14 May 2026 14:26:23 -0600 Subject: [PATCH 9/9] Bump version to 0.19.3 --- CONTRIBUTING.md | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c7da5f2..c48bcac 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -8,7 +8,7 @@ The version is defined statically in `pyproject.toml`: ```toml [project] -version = "0.19.2" +version = "0.19.3" ``` To cut a release, update the `version` field manually and open a PR against `main`. diff --git a/pyproject.toml b/pyproject.toml index c715f8a..79cc569 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "gremlinapi" -version = "0.19.2" +version = "0.19.3" description = "Gremlin library for Python" readme = "README.md" license = { text = "Apache 2.0" }