From 370b3e90e397381feec1b8739e177cb67d6414b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Oliveira?= Date: Fri, 18 Sep 2026 11:01:21 -0300 Subject: [PATCH 01/13] [INT-3877] Handle missing fields in Onfleet error payloads Use .get() for the optional error and request fields. A payload without them now raises the intended exception (HttpError, ValidationError, ...) instead of KeyError. Fixes #30. Re-applied from PR #54 after the git history rewrite. Co-authored-by: wooooodward <25610026+wooooodward@users.noreply.github.com> Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01UmWS7B7g3HKpoenKRaGnNu --- onfleet/request.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/onfleet/request.py b/onfleet/request.py index 4cdb02d..61695d9 100644 --- a/onfleet/request.py +++ b/onfleet/request.py @@ -3,7 +3,7 @@ import time from urllib import parse -from backoff import on_exception, expo +from backoff import expo, on_exception from ratelimit import limits from onfleet._meta import __version__ @@ -65,8 +65,8 @@ def __call__(self, id=None, body=None, workerId=None, hubId=None, googleApiKey=N raise HttpError(error.get('message', 'Error'), response.status_code) error_message = error['message']['message'] - error_code = error['message']['error'] - error_request = error['message']['request'] + error_code = error['message'].get('error', 0) + error_request = error['message'].get('request') error_cause = error['message'].get('cause') exception_args = (error_message, error_code, error_request, error_cause) From b91d5429cab1ccbb2ef7f3a01efae1e3c7da2105 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Oliveira?= Date: Fri, 18 Sep 2026 11:01:29 -0300 Subject: [PATCH 02/13] [INT-3877] Add CI quality gates, ruff lint, and security checks - Add CI workflow on push and pull_request to master: lint (ruff), pip-audit, full-history gitleaks scan, and an offline test job on a Python 3.10/3.11/3.12 matrix. The live-API suite runs on push to master only, since Actions secrets are not exposed to fork PRs. - Add minimal ruff config and clean the lint baseline (import order, re-exports in __all__). - Delete the dead .travis.yml. - pypi-publish.yml: bump actions/checkout and actions/setup-python, add a test job, and make publish require it (resolves the TODO). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01UmWS7B7g3HKpoenKRaGnNu --- .github/workflows/ci.yml | 111 +++++++++++++++++++++++++++++ .github/workflows/pypi-publish.yml | 27 +++++-- .travis.yml | 8 --- onfleet/__init__.py | 15 +++- ruff.toml | 12 ++++ setup.py | 1 + 6 files changed, 157 insertions(+), 17 deletions(-) create mode 100644 .github/workflows/ci.yml delete mode 100644 .travis.yml create mode 100644 ruff.toml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..15a5f0c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,111 @@ +# CI quality gates (INT-3877 steps 6-10). +# Offline checks (lint, audit, secret scan, tests) run on every PR. +# The live-API suite runs only on push to master: Actions secrets are not +# exposed to fork PRs, and the suite performs real writes. +name: CI + +on: + push: + branches: [master] + paths-ignore: + - 'README*' + - 'CHANGELOG.md' + pull_request: + branches: [master] + paths-ignore: + - 'README*' + - 'CHANGELOG.md' + +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + lint: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - name: Install ruff + run: pip install ruff==0.16.8 + - name: Lint + run: ruff check . + + audit: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - name: Install pip-audit + run: pip install pip-audit + # pip-audit has no severity filter; it fails on any known + # vulnerability, which is stricter than the "high severity" gate + # the ticket asks for. + - name: Audit dependencies + run: pip-audit -r requirements.txt + + secret-scan: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + # gitleaks-action requires a license for organization repos; + # the CLI performs the same full-history scan without one. + - name: Run gitleaks (full history) + run: | + curl -sSfL https://github.com/gitleaks/gitleaks/releases/download/v8.24.3/gitleaks_8.24.3_linux_x64.tar.gz | tar -xz gitleaks + ./gitleaks git --redact --verbose . + + test-offline: + runs-on: ubuntu-latest + timeout-minutes: 10 + strategy: + matrix: + python-version: ['3.10', '3.11', '3.12'] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + pip install -r requirements.txt + pip install build + - name: Build package + run: python -m build + - name: Import smoke test + run: | + pip install . + python -c "import onfleet; print(onfleet.Onfleet)" + # Without ONFLEET_API_KEY the live-API tests skip; this verifies + # collection and the offline paths only. + - name: Run test suite (offline) + run: python -m unittest discover -s test -v + + test-live: + if: github.event_name == 'push' + runs-on: ubuntu-latest + timeout-minutes: 10 + needs: [lint, test-offline] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - name: Install dependencies + run: pip install -r requirements.txt + - name: Run test suite (live API) + env: + ONFLEET_API_KEY: ${{ secrets.ONFLEET_API_KEY }} + run: python -m unittest discover -s test -v diff --git a/.github/workflows/pypi-publish.yml b/.github/workflows/pypi-publish.yml index e963a9f..c8baa18 100644 --- a/.github/workflows/pypi-publish.yml +++ b/.github/workflows/pypi-publish.yml @@ -9,17 +9,32 @@ on: jobs: -# TODO: Run tests -# build: -# runs-on: ubuntu-latest + test: + runs-on: ubuntu-latest + timeout-minutes: 10 + strategy: + matrix: + python-version: ['3.10', '3.11', '3.12'] + steps: + - uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: pip install -r requirements.txt + - name: Run test suite + env: + ONFLEET_API_KEY: ${{ secrets.ONFLEET_API_KEY }} + run: python -m unittest discover -s test -v publish: - #needs: build + needs: test runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - name: Set up Python - uses: actions/setup-python@v2 + uses: actions/setup-python@v5 with: python-version: '3.x' - name: Install dependencies diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index ed36238..0000000 --- a/.travis.yml +++ /dev/null @@ -1,8 +0,0 @@ -language: python -python: - - "3.7" -cache: pip -install: - - pip install -r requirements.txt -script: - # - python test/test_onfleet.py \ No newline at end of file diff --git a/onfleet/__init__.py b/onfleet/__init__.py index 003c842..466ba0c 100644 --- a/onfleet/__init__.py +++ b/onfleet/__init__.py @@ -1,6 +1,15 @@ -from onfleet.onfleet import Onfleet from onfleet.endpoint import Endpoint +from onfleet.error import HttpError, PermissionError, RateLimitError, ServiceError, ValidationError +from onfleet.onfleet import Onfleet from onfleet.request import Request -from onfleet.error import ValidationError, PermissionError, HttpError, RateLimitError, ServiceError -__all__ = ["Onfleet"] \ No newline at end of file +__all__ = [ + "Endpoint", + "HttpError", + "Onfleet", + "PermissionError", + "RateLimitError", + "Request", + "ServiceError", + "ValidationError", +] \ No newline at end of file diff --git a/ruff.toml b/ruff.toml new file mode 100644 index 0000000..e23fba7 --- /dev/null +++ b/ruff.toml @@ -0,0 +1,12 @@ +# Minimal ruff configuration (INT-3877 step 8). +target-version = "py310" +line-length = 120 + +[lint] +# Pinned rule set: pycodestyle errors, pyflakes, import sorting. +# Pinning keeps CI deterministic across ruff versions. +select = ["E4", "E7", "E9", "F", "I"] + +[lint.per-file-ignores] +# The test suite mutates sys.path before the onfleet import (import order). +"test/*" = ["E402", "I001"] diff --git a/setup.py b/setup.py index 7cbc9fd..6176454 100644 --- a/setup.py +++ b/setup.py @@ -1,5 +1,6 @@ import ast import pathlib + import setuptools PROJECT_ROOT = pathlib.Path(__file__).parent From 31c6a172b17dedb9306a4cc2a9f1daaa95014da3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Oliveira?= Date: Fri, 18 Sep 2026 12:25:04 -0300 Subject: [PATCH 03/13] [INT-3877] Address review feedback on CI gates - Fail test-live and the release test job when ONFLEET_API_KEY is absent, instead of passing green with every test skipped. - Verify the gitleaks tarball against its published SHA-256 before execution. - Cancel in-progress runs on pull_request only; a cancelled live run would leave test resources behind. - Run the release test job on a single Python version; concurrent live suites race each other in the shared test organization. - Pin pip-audit and build versions. - Enforce the 120-column limit (E5) and wrap the long lines. - Use .get() for the inner message field too, so a payload without it degrades to HttpError instead of KeyError. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01UmWS7B7g3HKpoenKRaGnNu --- .github/workflows/ci.yml | 23 +++++++++++++++++++---- .github/workflows/pypi-publish.yml | 18 ++++++++++++++---- onfleet/request.py | 5 +++-- ruff.toml | 5 +++-- test/test_onfleet.py | 13 ++++++++++--- 5 files changed, 49 insertions(+), 15 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 15a5f0c..a47b400 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,9 +19,12 @@ on: permissions: contents: read +# Cancel superseded runs on PR churn only. Push runs to master include +# the live-API suite, which mutates real data; a mid-test cancellation +# would leave test resources behind. concurrency: group: ci-${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: lint: @@ -46,7 +49,7 @@ jobs: with: python-version: '3.12' - name: Install pip-audit - run: pip install pip-audit + run: pip install pip-audit==2.10.1 # pip-audit has no severity filter; it fails on any known # vulnerability, which is stricter than the "high severity" gate # the ticket asks for. @@ -64,7 +67,9 @@ jobs: # the CLI performs the same full-history scan without one. - name: Run gitleaks (full history) run: | - curl -sSfL https://github.com/gitleaks/gitleaks/releases/download/v8.24.3/gitleaks_8.24.3_linux_x64.tar.gz | tar -xz gitleaks + curl -sSfLO https://github.com/gitleaks/gitleaks/releases/download/v8.24.3/gitleaks_8.24.3_linux_x64.tar.gz + echo "9991e0b2903da4c8f6122b5c3186448b927a5da4deef1fe45271c3793f4ee29c gitleaks_8.24.3_linux_x64.tar.gz" | sha256sum -c - + tar -xzf gitleaks_8.24.3_linux_x64.tar.gz gitleaks ./gitleaks git --redact --verbose . test-offline: @@ -81,7 +86,7 @@ jobs: - name: Install dependencies run: | pip install -r requirements.txt - pip install build + pip install build==1.6.1 - name: Build package run: python -m build - name: Import smoke test @@ -105,6 +110,16 @@ jobs: python-version: '3.12' - name: Install dependencies run: pip install -r requirements.txt + # Without the secret the suite skips every test and the job would + # pass green with zero assertions. Fail loudly instead. + - name: Require ONFLEET_API_KEY + env: + ONFLEET_API_KEY: ${{ secrets.ONFLEET_API_KEY }} + run: | + if [ -z "$ONFLEET_API_KEY" ]; then + echo "::error::ONFLEET_API_KEY secret is not set; the live-API suite cannot run (INT-3877 step 5)." + exit 1 + fi - name: Run test suite (live API) env: ONFLEET_API_KEY: ${{ secrets.ONFLEET_API_KEY }} diff --git a/.github/workflows/pypi-publish.yml b/.github/workflows/pypi-publish.yml index c8baa18..c836241 100644 --- a/.github/workflows/pypi-publish.yml +++ b/.github/workflows/pypi-publish.yml @@ -9,20 +9,30 @@ on: jobs: + # Single Python version: the live-API suite mutates real data in the + # test organization, so concurrent matrix jobs would race each other. + # Per-version coverage runs offline in ci.yml. test: runs-on: ubuntu-latest timeout-minutes: 10 - strategy: - matrix: - python-version: ['3.10', '3.11', '3.12'] steps: - uses: actions/checkout@v4 - name: Set up Python uses: actions/setup-python@v5 with: - python-version: ${{ matrix.python-version }} + python-version: '3.12' - name: Install dependencies run: pip install -r requirements.txt + # Without the secret the suite skips every test and the release gate + # would pass green with zero assertions. Fail loudly instead. + - name: Require ONFLEET_API_KEY + env: + ONFLEET_API_KEY: ${{ secrets.ONFLEET_API_KEY }} + run: | + if [ -z "$ONFLEET_API_KEY" ]; then + echo "::error::ONFLEET_API_KEY secret is not set; the live-API suite cannot run (INT-3877 step 5)." + exit 1 + fi - name: Run test suite env: ONFLEET_API_KEY: ${{ secrets.ONFLEET_API_KEY }} diff --git a/onfleet/request.py b/onfleet/request.py index 61695d9..b5f5e4c 100644 --- a/onfleet/request.py +++ b/onfleet/request.py @@ -27,7 +27,8 @@ def __init__(self, http_method, path, session): @on_exception(expo, RateLimitError, max_tries=8) @limits(calls=RATE_LIMIT, period=1) - def __call__(self, id=None, body=None, workerId=None, hubId=None, googleApiKey=None, queryParams=None, **extra_data): + def __call__(self, id=None, body=None, workerId=None, hubId=None, googleApiKey=None, queryParams=None, + **extra_data): obj_id = id # TODO(julian): `id` is a reserved name, let's rename it to 'obj_id' query_params = queryParams # TODO(julian): Let's rename `queryParams` to `query_params` @@ -64,7 +65,7 @@ def __call__(self, id=None, body=None, workerId=None, hubId=None, googleApiKey=N if type(error['message']) is not dict: raise HttpError(error.get('message', 'Error'), response.status_code) - error_message = error['message']['message'] + error_message = error['message'].get('message', 'Error') error_code = error['message'].get('error', 0) error_request = error['message'].get('request') error_cause = error['message'].get('cause') diff --git a/ruff.toml b/ruff.toml index e23fba7..171ff17 100644 --- a/ruff.toml +++ b/ruff.toml @@ -3,9 +3,10 @@ target-version = "py310" line-length = 120 [lint] -# Pinned rule set: pycodestyle errors, pyflakes, import sorting. +# Pinned rule set: pycodestyle errors (incl. E501 line length), +# pyflakes, import sorting. # Pinning keeps CI deterministic across ruff versions. -select = ["E4", "E7", "E9", "F", "I"] +select = ["E4", "E5", "E7", "E9", "F", "I"] [lint.per-file-ignores] # The test suite mutates sys.path before the onfleet import (import order). diff --git a/test/test_onfleet.py b/test/test_onfleet.py index 713cd0c..d7b8a47 100644 --- a/test/test_onfleet.py +++ b/test/test_onfleet.py @@ -47,7 +47,8 @@ def test_integrated_functions(self): new_admin_list_length = len(self.api.administrators.get()) # Length of admin list should increase by 1 self.assertTrue(old_admin_list_length + 1 == new_admin_list_length) - updated_result = self.api.administrators.update(id=new_admin_id, body={"name": "Onfleet Testing - Please delete"}) + updated_result = self.api.administrators.update( + id=new_admin_id, body={"name": "Onfleet Testing - Please delete"}) # Admin name should be updated self.assertTrue(updated_result["name"] == "Onfleet Testing - Please delete") deletion_result = self.api.administrators.deleteOne(id=new_admin_id) @@ -104,12 +105,18 @@ def test_find_and_query_parameters(self): search_result = (self.api.recipients.get(search=data)) # Create the recipient if not exist if ("id" not in search_result): - new_recipient_data = {"name":"Boris Foster","phone":"+16505551133","notes":"Always orders our GSC special", "skipPhoneNumberValidation":"true"} + new_recipient_data = { + "name": "Boris Foster", + "phone": "+16505551133", + "notes": "Always orders our GSC special", + "skipPhoneNumberValidation": "true" + } new_recipient = self.api.recipients.create(body=new_recipient_data) recipient_id = new_recipient["id"] else: recipient_id = search_result["id"] - search_result_with_query = self.api.recipients.get(search=data, queryParams={"skipPhoneNumberValidation":"true"}) + search_result_with_query = self.api.recipients.get( + search=data, queryParams={"skipPhoneNumberValidation": "true"}) self.assertTrue(search_result_with_query["id"] == recipient_id) From 67308ee2d1aeeb51d2561d03807d401e598c4f21 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Oliveira?= Date: Fri, 18 Sep 2026 13:24:02 -0300 Subject: [PATCH 04/13] [INT-3877] Add API-free unit tests for the offline gate The offline matrix ran zero assertions on PRs: without the secret, every live-API test skips. Add unit tests for the Request URL helpers, response handling, and error classification, including regression tests for the missing-field fix from PR #54. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01UmWS7B7g3HKpoenKRaGnNu --- .github/workflows/ci.yml | 4 +- test/test_request_offline.py | 117 +++++++++++++++++++++++++++++++++++ 2 files changed, 119 insertions(+), 2 deletions(-) create mode 100644 test/test_request_offline.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a47b400..ecc4f06 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -93,8 +93,8 @@ jobs: run: | pip install . python -c "import onfleet; print(onfleet.Onfleet)" - # Without ONFLEET_API_KEY the live-API tests skip; this verifies - # collection and the offline paths only. + # Without ONFLEET_API_KEY the live-API tests skip; the API-free + # unit tests in test_request_offline.py still run and assert. - name: Run test suite (offline) run: python -m unittest discover -s test -v diff --git a/test/test_request_offline.py b/test/test_request_offline.py new file mode 100644 index 0000000..063775e --- /dev/null +++ b/test/test_request_offline.py @@ -0,0 +1,117 @@ +import json +import unittest +from unittest.mock import Mock + +from onfleet.error import HttpError, PermissionError, ServiceError, ValidationError +from onfleet.request import Request + + +def _response(payload, status_code=400, ok=False): + response = Mock() + response.ok = ok + response.text = json.dumps(payload) + response.status_code = status_code + response.headers = {'X-RateLimit-Remaining': '100'} + response.json.return_value = payload + return response + + +def _request(http_method, payload, status_code=400, ok=False): + session = Mock() + session.request.return_value = _response(payload, status_code, ok) + return Request(http_method, '/tasks', session) + + +class TestRequestUrlHelpers(unittest.TestCase): + + def test_path_selector_single_path(self): + self.assertEqual(Request._path_selector('/tasks', None, {}), '/tasks') + + def test_path_selector_get_all(self): + paths = ('/workers', '/workers/:workerId') + self.assertEqual(Request._path_selector(paths, None, {}), '/workers') + + def test_path_selector_get_by_id(self): + paths = ('/workers', '/workers/:workerId') + self.assertEqual(Request._path_selector(paths, '123', {}), '/workers/:workerId') + + def test_url_id_setter(self): + url = Request._url_id_setter('https://onfleet.com/api/v2/admins/:adminId', 'abc123') + self.assertEqual(url, 'https://onfleet.com/api/v2/admins/abc123') + + def test_url_extra_data_setter_entity(self): + url = Request._url_extra_data_setter( + 'https://onfleet.com/api/v2/containers/:entityType/:entityId', {'workers': 'w123'}) + self.assertEqual(url, 'https://onfleet.com/api/v2/containers/workers/w123') + + def test_url_extra_data_setter_lookup(self): + url = Request._url_extra_data_setter( + 'https://onfleet.com/api/v2/tasks/:taskId', {'shortId': '44a5'}) + self.assertEqual(url, 'https://onfleet.com/api/v2/tasks/shortId/44a5') + + def test_clean_params_string(self): + self.assertEqual(Request._clean_params('from=1,to=2'), {'from': '1', 'to': '2'}) + + def test_clean_params_dict_passthrough(self): + params = {'from': '1'} + self.assertIs(Request._clean_params(params), params) + + def test_clean_params_none(self): + self.assertIsNone(Request._clean_params(None)) + + +class TestRequestResponses(unittest.TestCase): + + def test_get_returns_json_body(self): + payload = {'id': 'abc123'} + self.assertEqual(_request('GET', payload, status_code=200, ok=True)(), payload) + + def test_delete_returns_status_code(self): + self.assertEqual(_request('DELETE', {}, status_code=200, ok=True)(), 200) + + +class TestRequestErrorClassification(unittest.TestCase): + + def _error_payload(self, code): + return {'message': {'message': 'Boom', 'error': code, 'request': 'req-id'}} + + def test_non_dict_message_raises_http_error(self): + with self.assertRaises(HttpError): + _request('GET', {'message': 'plain text error'})() + + def test_validation_error(self): + with self.assertRaises(ValidationError): + _request('GET', self._error_payload(1000))() + + def test_permission_error(self): + with self.assertRaises(PermissionError): + _request('GET', self._error_payload(1100))() + + def test_service_error(self): + with self.assertRaises(ServiceError): + _request('GET', self._error_payload(2500), status_code=500)() + + def test_unclassified_code_raises_http_error(self): + with self.assertRaises(HttpError): + _request('GET', self._error_payload(1500))() + + # Regression tests for the fix re-applied from PR #54 (issue #30): + # payloads with missing optional fields raise the intended exception, + # not KeyError. + def test_missing_optional_fields_raise_http_error(self): + with self.assertRaises(HttpError): + _request('GET', {'message': {'message': 'Boom'}})() + + def test_empty_message_dict_raises_http_error(self): + with self.assertRaises(HttpError): + _request('GET', {'message': {}})() + + def test_missing_code_keeps_error_details(self): + try: + _request('GET', {'message': {'message': 'Boom'}})() + except HttpError as caught: + self.assertIn('Boom', caught.args) + + +if (__name__ == '__main__'): + unittest.main() From 13ffd9671462b03d8087cd3c17a33323398fc21c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Oliveira?= Date: Mon, 21 Sep 2026 10:27:25 -0300 Subject: [PATCH 05/13] [INT-3877] Address second review round - Assert the exception with assertRaises so the regression test cannot pass silently when no exception is raised. - Remove the internal ticket reference from the public CI error message, per the repository PR template rule. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01UmWS7B7g3HKpoenKRaGnNu --- .github/workflows/ci.yml | 2 +- .github/workflows/pypi-publish.yml | 2 +- test/test_request_offline.py | 5 ++--- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ecc4f06..7398259 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -117,7 +117,7 @@ jobs: ONFLEET_API_KEY: ${{ secrets.ONFLEET_API_KEY }} run: | if [ -z "$ONFLEET_API_KEY" ]; then - echo "::error::ONFLEET_API_KEY secret is not set; the live-API suite cannot run (INT-3877 step 5)." + echo "::error::The live-API suite cannot run without the ONFLEET_API_KEY secret." exit 1 fi - name: Run test suite (live API) diff --git a/.github/workflows/pypi-publish.yml b/.github/workflows/pypi-publish.yml index c836241..e9a8403 100644 --- a/.github/workflows/pypi-publish.yml +++ b/.github/workflows/pypi-publish.yml @@ -30,7 +30,7 @@ jobs: ONFLEET_API_KEY: ${{ secrets.ONFLEET_API_KEY }} run: | if [ -z "$ONFLEET_API_KEY" ]; then - echo "::error::ONFLEET_API_KEY secret is not set; the live-API suite cannot run (INT-3877 step 5)." + echo "::error::The live-API suite cannot run without the ONFLEET_API_KEY secret." exit 1 fi - name: Run test suite diff --git a/test/test_request_offline.py b/test/test_request_offline.py index 063775e..dcba943 100644 --- a/test/test_request_offline.py +++ b/test/test_request_offline.py @@ -107,10 +107,9 @@ def test_empty_message_dict_raises_http_error(self): _request('GET', {'message': {}})() def test_missing_code_keeps_error_details(self): - try: + with self.assertRaises(HttpError) as context: _request('GET', {'message': {'message': 'Boom'}})() - except HttpError as caught: - self.assertIn('Boom', caught.args) + self.assertIn('Boom', context.exception.args) if (__name__ == '__main__'): From 35b7260ee3f69210e15314f858ef23310fb9844e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Oliveira?= Date: Mon, 21 Sep 2026 11:18:38 -0300 Subject: [PATCH 06/13] TEMP: run live suite on same-repo PR to validate secret --- .github/workflows/ci.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7398259..2adca26 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -99,7 +99,9 @@ jobs: run: python -m unittest discover -s test -v test-live: - if: github.event_name == 'push' + # TEMP: also run on this same-repo PR to validate the new secret. + # Revert before merge. + if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository runs-on: ubuntu-latest timeout-minutes: 10 needs: [lint, test-offline] From 900f5276d69c0b5ca5135ad24cd509585bd74980 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Oliveira?= Date: Mon, 21 Sep 2026 11:21:10 -0300 Subject: [PATCH 07/13] [INT-3877] Fix stale assertion in test_authenticate The auth/test message no longer embeds the organization ID, so the regex cross-check crashed with AttributeError. Assert successful authentication and the organization ID shape instead. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01UmWS7B7g3HKpoenKRaGnNu --- test/test_onfleet.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/test/test_onfleet.py b/test/test_onfleet.py index d7b8a47..1c8a91e 100644 --- a/test/test_onfleet.py +++ b/test/test_onfleet.py @@ -21,10 +21,14 @@ def setUp(self): self.api = onfleet.Onfleet(api_key=self.api_key) def test_authenticate(self): + # A bad key raises PermissionError on either call, so reaching the + # asserts proves authentication. The old cross-check against the + # auth message no longer works: the message does not embed the + # organization ID anymore. auth_result = self.api.auth_test() org_result = self.api.organization.get() - match = re.search(r"[a-zA-Z\d]{24}", auth_result["message"]) - self.assertTrue(match.group(0) == org_result["id"]) + self.assertIn("message", auth_result) + self.assertTrue(re.fullmatch(r"[a-zA-Z\d]{24}", org_result["id"])) def test_datatype(self): admins_list = self.api.administrators.get() From ed3a9c12db4f199451b499c5625776452abce3a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Oliveira?= Date: Mon, 21 Sep 2026 11:23:06 -0300 Subject: [PATCH 08/13] [INT-3877] Do not assume an organization ID format in test_authenticate Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01UmWS7B7g3HKpoenKRaGnNu --- test/test_onfleet.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/test_onfleet.py b/test/test_onfleet.py index 1c8a91e..797f4ad 100644 --- a/test/test_onfleet.py +++ b/test/test_onfleet.py @@ -4,7 +4,6 @@ import os import unittest -import re from datetime import datetime, timedelta @@ -24,11 +23,12 @@ def test_authenticate(self): # A bad key raises PermissionError on either call, so reaching the # asserts proves authentication. The old cross-check against the # auth message no longer works: the message does not embed the - # organization ID anymore. + # organization ID anymore, and the ID format is not guaranteed. auth_result = self.api.auth_test() org_result = self.api.organization.get() self.assertIn("message", auth_result) - self.assertTrue(re.fullmatch(r"[a-zA-Z\d]{24}", org_result["id"])) + self.assertIsInstance(org_result["id"], str) + self.assertTrue(org_result["id"]) def test_datatype(self): admins_list = self.api.administrators.get() From 117ac2429e89843bcb28cbf9577bb16231e77a15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Oliveira?= Date: Mon, 21 Sep 2026 11:25:10 -0300 Subject: [PATCH 09/13] Revert "TEMP: run live suite on same-repo PR to validate secret" This reverts commit 35b7260ee3f69210e15314f858ef23310fb9844e. --- .github/workflows/ci.yml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2adca26..7398259 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -99,9 +99,7 @@ jobs: run: python -m unittest discover -s test -v test-live: - # TEMP: also run on this same-repo PR to validate the new secret. - # Revert before merge. - if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository + if: github.event_name == 'push' runs-on: ubuntu-latest timeout-minutes: 10 needs: [lint, test-offline] From fc422c8b3d19e70bbdfd793440abc881db121385 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Oliveira?= Date: Mon, 21 Sep 2026 11:44:54 -0300 Subject: [PATCH 10/13] [INT-3877] Address round-1 code review findings - Drop paths-ignore: required checks and paths-ignore do not compose; doc-only PRs would be stuck once branch protection lands (step 12) - Gate test-live on the LIVE_SUITE_ENABLED repository variable so master stays green until the ONFLEET_API_KEY secret exists - Extract the live suite into a reusable workflow (live-suite.yml) called from ci.yml and pypi-publish.yml - Install the built wheel in the import smoke test instead of the source tree - Drop the redundant ci- prefix from the concurrency group - Add timeout-minutes to the publish and notify jobs - request.py: raise HttpError for malformed error payloads (non-JSON body, missing or non-dict top-level message), with regression tests - Add the missing trailing newline in onfleet/__init__.py Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 46 +++++++++--------------------- .github/workflows/live-suite.yml | 37 ++++++++++++++++++++++++ .github/workflows/pypi-publish.yml | 34 +++++----------------- onfleet/__init__.py | 2 +- onfleet/request.py | 24 ++++++++++------ test/test_request_offline.py | 13 +++++++++ 6 files changed, 88 insertions(+), 68 deletions(-) create mode 100644 .github/workflows/live-suite.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7398259..581040c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,17 +4,14 @@ # exposed to fork PRs, and the suite performs real writes. name: CI +# No paths-ignore here: these checks become required via branch +# protection (INT-3877 step 12), and a required check that never +# triggers leaves doc-only PRs stuck in the "Expected" state. on: push: branches: [master] - paths-ignore: - - 'README*' - - 'CHANGELOG.md' pull_request: branches: [master] - paths-ignore: - - 'README*' - - 'CHANGELOG.md' permissions: contents: read @@ -23,7 +20,7 @@ permissions: # the live-API suite, which mutates real data; a mid-test cancellation # would leave test resources behind. concurrency: - group: ci-${{ github.workflow }}-${{ github.ref }} + group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: @@ -89,38 +86,23 @@ jobs: pip install build==1.6.1 - name: Build package run: python -m build + # Install the wheel the build step produced, not the source tree, + # so the smoke test exercises the artifact pypi-publish.yml ships. - name: Import smoke test run: | - pip install . + pip install dist/*.whl python -c "import onfleet; print(onfleet.Onfleet)" # Without ONFLEET_API_KEY the live-API tests skip; the API-free # unit tests in test_request_offline.py still run and assert. - name: Run test suite (offline) run: python -m unittest discover -s test -v + # The job stays disabled until the ONFLEET_API_KEY secret has a value: + # set the LIVE_SUITE_ENABLED repository variable to 'true' when the + # secret lands. Secrets are not readable in job-level `if` expressions; + # variables are. This avoids a red master while the secret is pending. test-live: - if: github.event_name == 'push' - runs-on: ubuntu-latest - timeout-minutes: 10 + if: github.event_name == 'push' && vars.LIVE_SUITE_ENABLED == 'true' needs: [lint, test-offline] - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 - with: - python-version: '3.12' - - name: Install dependencies - run: pip install -r requirements.txt - # Without the secret the suite skips every test and the job would - # pass green with zero assertions. Fail loudly instead. - - name: Require ONFLEET_API_KEY - env: - ONFLEET_API_KEY: ${{ secrets.ONFLEET_API_KEY }} - run: | - if [ -z "$ONFLEET_API_KEY" ]; then - echo "::error::The live-API suite cannot run without the ONFLEET_API_KEY secret." - exit 1 - fi - - name: Run test suite (live API) - env: - ONFLEET_API_KEY: ${{ secrets.ONFLEET_API_KEY }} - run: python -m unittest discover -s test -v + uses: ./.github/workflows/live-suite.yml + secrets: inherit diff --git a/.github/workflows/live-suite.yml b/.github/workflows/live-suite.yml new file mode 100644 index 0000000..aecd22c --- /dev/null +++ b/.github/workflows/live-suite.yml @@ -0,0 +1,37 @@ +# Reusable live-API test suite, called from ci.yml (push to master) +# and pypi-publish.yml (release gate). Single Python version: the suite +# mutates real data in the test organization, so concurrent matrix jobs +# would race each other. Per-version coverage runs offline in ci.yml. +name: Live suite + +on: + workflow_call: + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - name: Install dependencies + run: pip install -r requirements.txt + # Without the secret the suite skips every test and the job would + # pass green with zero assertions. Fail loudly instead. + - name: Require ONFLEET_API_KEY + env: + ONFLEET_API_KEY: ${{ secrets.ONFLEET_API_KEY }} + run: | + if [ -z "$ONFLEET_API_KEY" ]; then + echo "::error::The live-API suite cannot run without the ONFLEET_API_KEY secret." + exit 1 + fi + - name: Run test suite (live API) + env: + ONFLEET_API_KEY: ${{ secrets.ONFLEET_API_KEY }} + run: python -m unittest discover -s test -v diff --git a/.github/workflows/pypi-publish.yml b/.github/workflows/pypi-publish.yml index e9a8403..ae19fe3 100644 --- a/.github/workflows/pypi-publish.yml +++ b/.github/workflows/pypi-publish.yml @@ -9,38 +9,17 @@ on: jobs: - # Single Python version: the live-API suite mutates real data in the - # test organization, so concurrent matrix jobs would race each other. - # Per-version coverage runs offline in ci.yml. + # Release gate: the shared live-API suite. Unlike ci.yml, this call is + # not gated on LIVE_SUITE_ENABLED — a release must never skip tests, + # so a missing secret fails the run loudly inside the suite. test: - runs-on: ubuntu-latest - timeout-minutes: 10 - steps: - - uses: actions/checkout@v4 - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: '3.12' - - name: Install dependencies - run: pip install -r requirements.txt - # Without the secret the suite skips every test and the release gate - # would pass green with zero assertions. Fail loudly instead. - - name: Require ONFLEET_API_KEY - env: - ONFLEET_API_KEY: ${{ secrets.ONFLEET_API_KEY }} - run: | - if [ -z "$ONFLEET_API_KEY" ]; then - echo "::error::The live-API suite cannot run without the ONFLEET_API_KEY secret." - exit 1 - fi - - name: Run test suite - env: - ONFLEET_API_KEY: ${{ secrets.ONFLEET_API_KEY }} - run: python -m unittest discover -s test -v + uses: ./.github/workflows/live-suite.yml + secrets: inherit publish: needs: test runs-on: ubuntu-latest + timeout-minutes: 10 steps: - uses: actions/checkout@v4 - name: Set up Python @@ -62,6 +41,7 @@ jobs: notify: needs: publish runs-on: ubuntu-latest + timeout-minutes: 10 steps: - uses: slackapi/slack-github-action@v1.18.0 with: diff --git a/onfleet/__init__.py b/onfleet/__init__.py index 466ba0c..f7fdff9 100644 --- a/onfleet/__init__.py +++ b/onfleet/__init__.py @@ -12,4 +12,4 @@ "Request", "ServiceError", "ValidationError", -] \ No newline at end of file +] diff --git a/onfleet/request.py b/onfleet/request.py index b5f5e4c..b1fb29a 100644 --- a/onfleet/request.py +++ b/onfleet/request.py @@ -61,14 +61,22 @@ def __call__(self, id=None, body=None, workerId=None, hubId=None, googleApiKey=N time.sleep(1 / rate_remaining) return response.status_code if (self.http_method == 'DELETE' or 'complete' in url) else response.json() - error = json.loads(response.text) - if type(error['message']) is not dict: - raise HttpError(error.get('message', 'Error'), response.status_code) - - error_message = error['message'].get('message', 'Error') - error_code = error['message'].get('error', 0) - error_request = error['message'].get('request') - error_cause = error['message'].get('cause') + # The expected error shape is {'message': {...}}. Malformed + # payloads (non-JSON body, missing or non-dict 'message') must + # still raise the intended exception, not KeyError (issue #30). + try: + error = json.loads(response.text) + except ValueError: + error = None + error_body = error.get('message') if isinstance(error, dict) else None + + if not isinstance(error_body, dict): + raise HttpError(error_body or response.text or 'Error', response.status_code) + + error_message = error_body.get('message', 'Error') + error_code = error_body.get('error', 0) + error_request = error_body.get('request') + error_cause = error_body.get('cause') exception_args = (error_message, error_code, error_request, error_cause) # https://github.com/addyinc/web/blob/master/yerba/config/errors.json diff --git a/test/test_request_offline.py b/test/test_request_offline.py index dcba943..13cc61f 100644 --- a/test/test_request_offline.py +++ b/test/test_request_offline.py @@ -111,6 +111,19 @@ def test_missing_code_keeps_error_details(self): _request('GET', {'message': {'message': 'Boom'}})() self.assertIn('Boom', context.exception.args) + def test_missing_top_level_message_raises_http_error(self): + with self.assertRaises(HttpError): + _request('GET', {})() + + def test_non_json_body_raises_http_error(self): + response = _response({}) + response.text = '502 Bad Gateway' + session = Mock() + session.request.return_value = response + with self.assertRaises(HttpError) as context: + Request('GET', '/tasks', session)() + self.assertIn('502 Bad Gateway', context.exception.args) + if (__name__ == '__main__'): unittest.main() From b358fe1d4ae286e3703ecd6376cd0cbd60d0bc33 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Oliveira?= Date: Mon, 21 Sep 2026 12:57:18 -0300 Subject: [PATCH 11/13] [INT-3877] Address cubic review comments - Import the smoke-test wheel from outside the repo root, where the onfleet/ source directory shadows the installed package - Serialize live-suite runs across callers with a shared job-level concurrency group in the reusable workflow Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 3 +++ .github/workflows/live-suite.yml | 7 +++++++ 2 files changed, 10 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 581040c..4e0979b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -88,9 +88,12 @@ jobs: run: python -m build # Install the wheel the build step produced, not the source tree, # so the smoke test exercises the artifact pypi-publish.yml ships. + # Import from outside the repo root: in the root, the onfleet/ + # source directory shadows the installed wheel. - name: Import smoke test run: | pip install dist/*.whl + cd "$RUNNER_TEMP" python -c "import onfleet; print(onfleet.Onfleet)" # Without ONFLEET_API_KEY the live-API tests skip; the API-free # unit tests in test_request_offline.py still run and assert. diff --git a/.github/workflows/live-suite.yml b/.github/workflows/live-suite.yml index aecd22c..5d7c611 100644 --- a/.github/workflows/live-suite.yml +++ b/.github/workflows/live-suite.yml @@ -14,6 +14,13 @@ jobs: test: runs-on: ubuntu-latest timeout-minutes: 10 + # Both callers share this group, so a master push and a release + # never mutate the test organization at the same time. Never cancel + # a running suite: a mid-test cancellation leaves test resources + # behind. + concurrency: + group: live-api-suite + cancel-in-progress: false steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 From bcc808e36c9b0036a9ada9a98d3876730de4ad4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Oliveira?= Date: Mon, 21 Sep 2026 15:56:01 -0300 Subject: [PATCH 12/13] [INT-3877] Address review findings from izzyandrade - Revert __all__ to ["Onfleet"]: a star import must not shadow the builtin PermissionError; ignore F401 for the re-export module - Guard non-int error codes in request.py so null or string codes raise HttpError instead of TypeError, with regression tests - Delete the dead sys.path hack in test_onfleet.py and drop the test/* ruff carve-out - Bump gitleaks to 8.30.1 (checksum verified against the release asset); track GitHub Actions refs in dependabot - pypi-publish: add permissions contents:read, pin build==1.6.1, set Python 3.12, leave a TODO for the slack-action bump - Document the status field semantics on _OnfleetError Co-Authored-By: Claude Fable 5 --- .github/dependabot.yml | 9 ++++++++- .github/workflows/ci.yml | 6 +++--- .github/workflows/pypi-publish.yml | 11 +++++++++-- onfleet/__init__.py | 14 ++++---------- onfleet/error.py | 4 ++++ onfleet/request.py | 5 +++++ ruff.toml | 5 +++-- test/test_onfleet.py | 6 ++---- test/test_request_offline.py | 8 ++++++++ 9 files changed, 46 insertions(+), 22 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 0e7e3d2..fd7fb4c 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -7,4 +7,11 @@ updates: interval: "monthly" commit-message: # Prefix all commit messages with "pip" - prefix: "[pip]" \ No newline at end of file + prefix: "[pip]" + # Keep GitHub Actions refs current (workflow uses: pins) + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "monthly" + commit-message: + prefix: "[actions]" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4e0979b..f651bea 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -64,9 +64,9 @@ jobs: # the CLI performs the same full-history scan without one. - name: Run gitleaks (full history) run: | - curl -sSfLO https://github.com/gitleaks/gitleaks/releases/download/v8.24.3/gitleaks_8.24.3_linux_x64.tar.gz - echo "9991e0b2903da4c8f6122b5c3186448b927a5da4deef1fe45271c3793f4ee29c gitleaks_8.24.3_linux_x64.tar.gz" | sha256sum -c - - tar -xzf gitleaks_8.24.3_linux_x64.tar.gz gitleaks + curl -sSfLO https://github.com/gitleaks/gitleaks/releases/download/v8.30.1/gitleaks_8.30.1_linux_x64.tar.gz + echo "551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb gitleaks_8.30.1_linux_x64.tar.gz" | sha256sum -c - + tar -xzf gitleaks_8.30.1_linux_x64.tar.gz gitleaks ./gitleaks git --redact --verbose . test-offline: diff --git a/.github/workflows/pypi-publish.yml b/.github/workflows/pypi-publish.yml index ae19fe3..54f7569 100644 --- a/.github/workflows/pypi-publish.yml +++ b/.github/workflows/pypi-publish.yml @@ -7,6 +7,11 @@ on: release: types: [published] +# The token needs no write access: PyPI uses PYPI_API_TOKEN and Slack +# uses a webhook secret. +permissions: + contents: read + jobs: # Release gate: the shared live-API suite. Unlike ci.yml, this call is @@ -25,11 +30,11 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: '3.x' + python-version: '3.12' - name: Install dependencies run: | python -m pip install --upgrade pip - pip install build + pip install build==1.6.1 - name: Build package run: python -m build - name: Publish package @@ -43,6 +48,8 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 10 steps: + # TODO: bump slack-github-action (v2+ changed the webhook inputs; + # the payload and SLACK_WEBHOOK_TYPE need a review with the bump). - uses: slackapi/slack-github-action@v1.18.0 with: payload: | diff --git a/onfleet/__init__.py b/onfleet/__init__.py index f7fdff9..e86e82e 100644 --- a/onfleet/__init__.py +++ b/onfleet/__init__.py @@ -3,13 +3,7 @@ from onfleet.onfleet import Onfleet from onfleet.request import Request -__all__ = [ - "Endpoint", - "HttpError", - "Onfleet", - "PermissionError", - "RateLimitError", - "Request", - "ServiceError", - "ValidationError", -] +# Only Onfleet is exported on star imports. The error classes stay +# importable by name, but out of `__all__`: onfleet.PermissionError is +# the API exception, and a star import must not shadow the builtin. +__all__ = ["Onfleet"] diff --git a/onfleet/error.py b/onfleet/error.py index 5756f10..e05a569 100644 --- a/onfleet/error.py +++ b/onfleet/error.py @@ -1,6 +1,10 @@ class _OnfleetError(Exception): ''' Base exception class for all errors raised by the Onfleet API. + + `status` holds the Onfleet error code (e.g. 1500) when the API + returns a structured error payload, and the HTTP status code + (e.g. 502) when the payload is malformed. ''' def __init__(self, message, status=None, request=None, cause=None): self.message = message diff --git a/onfleet/request.py b/onfleet/request.py index b1fb29a..7823f8f 100644 --- a/onfleet/request.py +++ b/onfleet/request.py @@ -79,6 +79,11 @@ def __call__(self, id=None, body=None, workerId=None, hubId=None, googleApiKey=N error_cause = error_body.get('cause') exception_args = (error_message, error_code, error_request, error_cause) + # A null or non-numeric code cannot be classified; the range + # checks below would raise TypeError on it. + if not isinstance(error_code, int): + raise HttpError(*exception_args) + # https://github.com/addyinc/web/blob/master/yerba/config/errors.json if 1000 <= error_code <= 1012: # InvalidContentError diff --git a/ruff.toml b/ruff.toml index 171ff17..95154e8 100644 --- a/ruff.toml +++ b/ruff.toml @@ -9,5 +9,6 @@ line-length = 120 select = ["E4", "E5", "E7", "E9", "F", "I"] [lint.per-file-ignores] -# The test suite mutates sys.path before the onfleet import (import order). -"test/*" = ["E402", "I001"] +# Re-export module: the imports exist for `onfleet.` access and +# are deliberately absent from `__all__`. +"onfleet/__init__.py" = ["F401"] diff --git a/test/test_onfleet.py b/test/test_onfleet.py index 797f4ad..97ad404 100644 --- a/test/test_onfleet.py +++ b/test/test_onfleet.py @@ -1,11 +1,9 @@ -import sys -sys.path.append("../onfleet") -import onfleet - import os import unittest from datetime import datetime, timedelta +import onfleet + # TODO(julian): We need to deprecate this! class TestOnfleet(unittest.TestCase): diff --git a/test/test_request_offline.py b/test/test_request_offline.py index 13cc61f..8ba7236 100644 --- a/test/test_request_offline.py +++ b/test/test_request_offline.py @@ -111,6 +111,14 @@ def test_missing_code_keeps_error_details(self): _request('GET', {'message': {'message': 'Boom'}})() self.assertIn('Boom', context.exception.args) + def test_null_error_code_raises_http_error(self): + with self.assertRaises(HttpError): + _request('GET', {'message': {'message': 'Boom', 'error': None}})() + + def test_string_error_code_raises_http_error(self): + with self.assertRaises(HttpError): + _request('GET', {'message': {'message': 'Boom', 'error': '1000'}})() + def test_missing_top_level_message_raises_http_error(self): with self.assertRaises(HttpError): _request('GET', {})() From 3694616f18375ec81cb54ad4c52f90fab35b69a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Oliveira?= Date: Mon, 21 Sep 2026 16:41:53 -0300 Subject: [PATCH 13/13] [INT-3877] Keep float error codes classifiable The non-int guard routed a float code such as 1000.0 to the generic HttpError. The range checks compare floats fine, so widen the guard to (int, float) and keep the classification master had. Co-Authored-By: Claude Fable 5 --- onfleet/request.py | 5 +++-- test/test_request_offline.py | 4 ++++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/onfleet/request.py b/onfleet/request.py index 7823f8f..7527b8c 100644 --- a/onfleet/request.py +++ b/onfleet/request.py @@ -80,8 +80,9 @@ def __call__(self, id=None, body=None, workerId=None, hubId=None, googleApiKey=N exception_args = (error_message, error_code, error_request, error_cause) # A null or non-numeric code cannot be classified; the range - # checks below would raise TypeError on it. - if not isinstance(error_code, int): + # checks below would raise TypeError on it. Floats stay + # classifiable: the range checks compare them fine. + if not isinstance(error_code, (int, float)): raise HttpError(*exception_args) # https://github.com/addyinc/web/blob/master/yerba/config/errors.json diff --git a/test/test_request_offline.py b/test/test_request_offline.py index 8ba7236..58e863b 100644 --- a/test/test_request_offline.py +++ b/test/test_request_offline.py @@ -119,6 +119,10 @@ def test_string_error_code_raises_http_error(self): with self.assertRaises(HttpError): _request('GET', {'message': {'message': 'Boom', 'error': '1000'}})() + def test_float_error_code_still_classifies(self): + with self.assertRaises(ValidationError): + _request('GET', self._error_payload(1000.0))() + def test_missing_top_level_message_raises_http_error(self): with self.assertRaises(HttpError): _request('GET', {})()