Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,11 @@ updates:
interval: "monthly"
commit-message:
# Prefix all commit messages with "pip"
prefix: "[pip]"
prefix: "[pip]"
# Keep GitHub Actions refs current (workflow uses: pins)
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "monthly"
commit-message:
prefix: "[actions]"
111 changes: 111 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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

# 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.
Comment thread
joao-onfleet marked this conversation as resolved.
on:
push:
branches: [master]
pull_request:
branches: [master]

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: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}

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==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.
- 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 -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:
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==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.
# 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
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
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.
- 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' && vars.LIVE_SUITE_ENABLED == 'true'
needs: [lint, test-offline]
uses: ./.github/workflows/live-suite.yml
secrets: inherit
44 changes: 44 additions & 0 deletions .github/workflows/live-suite.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# Reusable live-API test suite, called from ci.yml (push to master)
# and pypi-publish.yml (release gate). Single Python version: the suite
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
# 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
# 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
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
28 changes: 20 additions & 8 deletions .github/workflows/pypi-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,25 +7,34 @@ 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:
Comment thread
joao-onfleet marked this conversation as resolved.

# TODO: Run tests
# build:
# runs-on: ubuntu-latest
# 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:
uses: ./.github/workflows/live-suite.yml
secrets: inherit
Comment thread
joao-onfleet marked this conversation as resolved.

publish:
#needs: build
needs: test
runs-on: ubuntu-latest
timeout-minutes: 10
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'
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
Expand All @@ -37,7 +46,10 @@ jobs:
notify:
needs: publish
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
Comment thread
joao-onfleet marked this conversation as resolved.
with:
payload: |
Expand Down
8 changes: 0 additions & 8 deletions .travis.yml

This file was deleted.

9 changes: 6 additions & 3 deletions onfleet/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
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"]
# 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"]
Comment thread
joao-onfleet marked this conversation as resolved.
4 changes: 4 additions & 0 deletions onfleet/error.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down
35 changes: 25 additions & 10 deletions onfleet/request.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__
Expand All @@ -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`

Expand Down Expand Up @@ -60,16 +61,30 @@ 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']['message']
error_code = error['message']['error']
error_request = error['message']['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)
Comment thread
joao-onfleet marked this conversation as resolved.

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)
Comment thread
joao-onfleet marked this conversation as resolved.

# A null or non-numeric code cannot be classified; the range
# 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

if 1000 <= error_code <= 1012: # InvalidContentError
Expand Down
14 changes: 14 additions & 0 deletions ruff.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# Minimal ruff configuration (INT-3877 step 8).
target-version = "py310"
line-length = 120

[lint]
# Pinned rule set: pycodestyle errors (incl. E501 line length),
# pyflakes, import sorting.
# Pinning keeps CI deterministic across ruff versions.
select = ["E4", "E5", "E7", "E9", "F", "I"]

[lint.per-file-ignores]
# Re-export module: the imports exist for `onfleet.<name>` access and
# are deliberately absent from `__all__`.
"onfleet/__init__.py" = ["F401"]
1 change: 1 addition & 0 deletions setup.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import ast
import pathlib

import setuptools

PROJECT_ROOT = pathlib.Path(__file__).parent
Expand Down
Loading
Loading