From 1016e7cc8e59a7128ce2be547ae22afc7f50879a Mon Sep 17 00:00:00 2001 From: Ryan Duguid <152749594+ryanduguid@users.noreply.github.com> Date: Thu, 13 Aug 2026 02:48:26 +1000 Subject: [PATCH 1/4] ci: test the Python event source --- .github/workflows/build-test-lint.yml | 140 +++++++++++++++----------- requirements/dev.txt | 2 + requirements/flake8.txt | 2 +- 3 files changed, 84 insertions(+), 60 deletions(-) diff --git a/.github/workflows/build-test-lint.yml b/.github/workflows/build-test-lint.yml index 14722dcc..d3318f41 100644 --- a/.github/workflows/build-test-lint.yml +++ b/.github/workflows/build-test-lint.yml @@ -1,63 +1,85 @@ -name: Python Build, Lint +name: Python Build, Lint and Test on: - push: + push: + branches: [master] + pull_request: + types: [opened, reopened, synchronize] + +permissions: + contents: read jobs: - build-test-lint: - runs-on: ubuntu-latest - - steps: - - name: Checkout xero-python repo - uses: actions/checkout@v4 - with: - repository: XeroAPI/xero-python - path: xero-python - - - name: Set up Python environment - uses: actions/setup-python@v5 - with: - python-version: '3.8' - cache: 'pip' - - - name: Install dependencies - run: | - python -m venv venv - source venv/bin/activate - pip install --upgrade pip - pip install black - sudo pip install flake8 - sudo pip install pip-audit - pip install -r requirements.txt -r requirements/dev.txt - working-directory: xero-python - - - name: Run Flake8 - run: flake8 xero_python - working-directory: xero-python - - - name: Build package - run: python setup.py sdist - working-directory: xero-python - - - name: Set up Node environment - uses: actions/setup-node@v2 - with: - node-version: 20 - - - name: Install Prism - run: npm install -g @stoplight/prism-cli - - - name: Start PRISM Server - run: ./start-prism.sh & sleep 15 - working-directory: xero-python/tests/utils - - - name: Run Test - run: | - source venv/bin/activate - pytest -v - working-directory: xero-python - - - name: Stop PRISM - if: success() || failure() - run: pkill -f prism - working-directory: xero-python + build-test-lint: + name: Python ${{ matrix.python }} + runs-on: ubuntu-latest + timeout-minutes: 25 + strategy: + fail-fast: false + matrix: + python: ["3.8", "3.12"] + + steps: + - name: Check out event source + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.2.2 + + - name: Set up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: ${{ matrix.python }} + cache: pip + + - name: Install Python dependencies + run: | + python -m pip install --upgrade pip + python -m pip install -r requirements.txt -r requirements/dev.txt + python -m pip check + + - name: Lint + run: python -m flake8 --select E9,F63,F7,F82 xero_python + + - name: Audit runtime dependencies + run: python -m pip_audit -r requirements.txt + + - name: Build package + if: matrix.python == '3.12' + run: python -m build + + - name: Set up Node + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 24.18 + + - name: Install Prism + run: npm install --global @stoplight/prism-cli@5.16.0 + + - name: Start Prism + run: | + bash tests/utils/start-prism.sh + python - <<'PY' + import socket + import time + + def listening(port): + try: + with socket.create_connection(("127.0.0.1", port), timeout=1): + return True + except OSError: + return False + + ports = range(4010, 4019) + deadline = time.monotonic() + 60 + while time.monotonic() < deadline: + if all(listening(port) for port in ports): + break + time.sleep(1) + else: + raise SystemExit("Prism did not open ports 4010-4018 within 60 seconds") + PY + + - name: Test + run: python -m pytest -v + + - name: Stop Prism + if: always() + run: pkill -f "[p]rism mock" || true diff --git a/requirements/dev.txt b/requirements/dev.txt index ced8db1c..401eb827 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -1,3 +1,5 @@ -r test.txt -r flake8.txt +build +pip-audit tox diff --git a/requirements/flake8.txt b/requirements/flake8.txt index d266010c..39304807 100644 --- a/requirements/flake8.txt +++ b/requirements/flake8.txt @@ -1 +1 @@ -flake8-black +flake8 From 4e8b1af5f359bef0601582e82e8684d13b25f205 Mon Sep 17 00:00:00 2001 From: Ryan Duguid <152749594+ryanduguid@users.noreply.github.com> Date: Thu, 13 Aug 2026 03:18:36 +1000 Subject: [PATCH 2/4] ci: pin OpenAPI mock inputs --- .github/workflows/build-test-lint.yml | 8 +++++--- tests/utils/start-prism.sh | 26 ++++++++++++++++---------- 2 files changed, 21 insertions(+), 13 deletions(-) diff --git a/.github/workflows/build-test-lint.yml b/.github/workflows/build-test-lint.yml index d3318f41..c6a648ad 100644 --- a/.github/workflows/build-test-lint.yml +++ b/.github/workflows/build-test-lint.yml @@ -17,7 +17,7 @@ jobs: strategy: fail-fast: false matrix: - python: ["3.8", "3.12"] + python: ["3.8", "3.12", "3.13"] steps: - name: Check out event source @@ -42,7 +42,7 @@ jobs: run: python -m pip_audit -r requirements.txt - name: Build package - if: matrix.python == '3.12' + if: matrix.python == '3.13' run: python -m build - name: Set up Node @@ -54,8 +54,10 @@ jobs: run: npm install --global @stoplight/prism-cli@5.16.0 - name: Start Prism + env: + XERO_OPENAPI_REF: ee65f8bddb16f37cde3e32741d465eeed6507a74 run: | - bash tests/utils/start-prism.sh + bash tests/utils/start-prism.sh "$XERO_OPENAPI_REF" python - <<'PY' import socket import time diff --git a/tests/utils/start-prism.sh b/tests/utils/start-prism.sh index be7c09e3..8eff015d 100755 --- a/tests/utils/start-prism.sh +++ b/tests/utils/start-prism.sh @@ -1,12 +1,18 @@ #!/bin/bash -branchName=${1:-"master"} +specRef=${1:?pass an immutable Xero-OpenAPI commit SHA} +if ! [[ "$specRef" =~ ^[0-9a-f]{40}$ ]]; then + echo "Xero-OpenAPI ref must be a full commit SHA" >&2 + exit 2 +fi -prism mock https://raw.githubusercontent.com/XeroAPI/Xero-OpenAPI/refs/heads/$branchName/xero_accounting.yaml --host 127.0.0.1 --port 4010 & -prism mock https://raw.githubusercontent.com/XeroAPI/Xero-OpenAPI/refs/heads/$branchName/xero-app-store.yaml --host 127.0.0.1 --port 4011 & -prism mock https://raw.githubusercontent.com/XeroAPI/Xero-OpenAPI/refs/heads/$branchName/xero_assets.yaml --host 127.0.0.1 --port 4012 & -prism mock https://raw.githubusercontent.com/XeroAPI/Xero-OpenAPI/refs/heads/$branchName/xero_bankfeeds.yaml --host 127.0.0.1 --port 4013 & -prism mock https://raw.githubusercontent.com/XeroAPI/Xero-OpenAPI/refs/heads/$branchName/xero-finance.yaml --host 127.0.0.1 --port 4014 & -prism mock https://raw.githubusercontent.com/XeroAPI/Xero-OpenAPI/refs/heads/$branchName/xero-payroll-uk.yaml --host 127.0.0.1 --port 4015 & -prism mock https://raw.githubusercontent.com/XeroAPI/Xero-OpenAPI/refs/heads/$branchName/xero-payroll-nz.yaml --host 127.0.0.1 --port 4016 & -prism mock https://raw.githubusercontent.com/XeroAPI/Xero-OpenAPI/refs/heads/$branchName/xero-payroll-au.yaml --host 127.0.0.1 --port 4017 & -prism mock https://raw.githubusercontent.com/XeroAPI/Xero-OpenAPI/refs/heads/$branchName/xero-projects.yaml --host 127.0.0.1 --port 4018 +specUrl="https://raw.githubusercontent.com/XeroAPI/Xero-OpenAPI/$specRef" + +prism mock "$specUrl/xero_accounting.yaml" --host 127.0.0.1 --port 4010 & +prism mock "$specUrl/xero-app-store.yaml" --host 127.0.0.1 --port 4011 & +prism mock "$specUrl/xero_assets.yaml" --host 127.0.0.1 --port 4012 & +prism mock "$specUrl/xero_bankfeeds.yaml" --host 127.0.0.1 --port 4013 & +prism mock "$specUrl/xero-finance.yaml" --host 127.0.0.1 --port 4014 & +prism mock "$specUrl/xero-payroll-uk.yaml" --host 127.0.0.1 --port 4015 & +prism mock "$specUrl/xero-payroll-nz.yaml" --host 127.0.0.1 --port 4016 & +prism mock "$specUrl/xero-payroll-au.yaml" --host 127.0.0.1 --port 4017 & +prism mock "$specUrl/xero-projects.yaml" --host 127.0.0.1 --port 4018 From 462717f7d94a9280e6d4bc2bcb4c308445d33f45 Mon Sep 17 00:00:00 2001 From: Ryan Duguid <152749594+ryanduguid@users.noreply.github.com> Date: Thu, 13 Aug 2026 03:39:43 +1000 Subject: [PATCH 3/4] Bound and pin Python CI audit runs --- .github/workflows/build-test-lint.yml | 4 ++++ requirements/dev.txt | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build-test-lint.yml b/.github/workflows/build-test-lint.yml index c6a648ad..abef2b57 100644 --- a/.github/workflows/build-test-lint.yml +++ b/.github/workflows/build-test-lint.yml @@ -9,6 +9,10 @@ on: permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + jobs: build-test-lint: name: Python ${{ matrix.python }} diff --git a/requirements/dev.txt b/requirements/dev.txt index 401eb827..81c1c4a4 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -1,5 +1,5 @@ -r test.txt -r flake8.txt build -pip-audit +pip-audit==2.10.1 tox From e74e157887fc33ab8b453582ba4fb33246cd88f4 Mon Sep 17 00:00:00 2001 From: Ryan Duguid <152749594+ryanduguid@users.noreply.github.com> Date: Wed, 19 Aug 2026 15:17:04 +1000 Subject: [PATCH 4/4] fix: unblock CI and restore the lint gate it was bypassing start-prism.sh ended with a non-backgrounded prism mock, so invoking it in the foreground never returned. Every push and PR sat on the Start Prism step until the 25 minute job timeout, which meant the readiness loop and pytest never ran. Background the last mock so the script returns once all nine are spawned and the readiness loop is what gates the Test step. pip-audit 2.10.1 declares Requires-Python >=3.10, so the 3.8 leg died while installing requirements/dev.txt. Gate it with an environment marker and run the audit once on the newest leg, since auditing requirements.txt does not vary by interpreter. Restore flake8-black so black checking comes back for every consumer of requirements/flake8.txt, including tox -e flake8, and drop the --select that was overriding the [flake8] section in setup.cfg. Pin black, because an unpinned black now reports 543 BLK100 findings against generated code on master and would make the restored gate unusable. Reformat the two test files that were genuinely misformatted so the full gate passes. Widen the matrix to the declared support range; setup.py and tox move to the same range in the runtime policy branch. --- .github/workflows/build-test-lint.yml | 8 ++- requirements/dev.txt | 2 +- requirements/flake8.txt | 5 +- tests/accounting/api/test_accounting_api.py | 60 ++++++++++++--------- tests/test_api_client/test_oauth2.py | 2 + tests/utils/start-prism.sh | 4 +- 6 files changed, 51 insertions(+), 30 deletions(-) diff --git a/.github/workflows/build-test-lint.yml b/.github/workflows/build-test-lint.yml index abef2b57..27e9936d 100644 --- a/.github/workflows/build-test-lint.yml +++ b/.github/workflows/build-test-lint.yml @@ -21,7 +21,8 @@ jobs: strategy: fail-fast: false matrix: - python: ["3.8", "3.12", "3.13"] + # Mirrors the support range declared in setup.py and the tox envlist. + python: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] steps: - name: Check out event source @@ -40,9 +41,12 @@ jobs: python -m pip check - name: Lint - run: python -m flake8 --select E9,F63,F7,F82 xero_python + run: python -m flake8 xero_python tests - name: Audit runtime dependencies + # pip-audit requires Python 3.10+, and auditing requirements.txt does + # not vary by interpreter, so run it once on the newest leg. + if: matrix.python == '3.13' run: python -m pip_audit -r requirements.txt - name: Build package diff --git a/requirements/dev.txt b/requirements/dev.txt index 81c1c4a4..918abad4 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -1,5 +1,5 @@ -r test.txt -r flake8.txt build -pip-audit==2.10.1 +pip-audit==2.10.1; python_version >= "3.10" tox diff --git a/requirements/flake8.txt b/requirements/flake8.txt index 39304807..87adfcc9 100644 --- a/requirements/flake8.txt +++ b/requirements/flake8.txt @@ -1 +1,4 @@ -flake8 +flake8-black +# Pin black so the BLK100 gate tracks this codebase's formatting instead of +# whatever black released today, and so every matrix leg lints identically. +black==24.8.0 diff --git a/tests/accounting/api/test_accounting_api.py b/tests/accounting/api/test_accounting_api.py index 4927e630..e0d90511 100644 --- a/tests/accounting/api/test_accounting_api.py +++ b/tests/accounting/api/test_accounting_api.py @@ -342,12 +342,12 @@ def test_get_invoices(sandbox_accounting_api: AccountingApi, xero_tenant_id): # When getting all invoices result: Invoices = sandbox_accounting_api.get_invoices(xero_tenant_id) # Then expect correct invoices received - expected = Invoices ( + expected = Invoices( invoices=[ Invoice( - amount_credited = Decimal(0), - amount_due = Decimal(0), - amount_paid= Decimal(0), + amount_credited=Decimal(0), + amount_due=Decimal(0), + amount_paid=Decimal(0), contact=Contact( addresses=[], contact_groups=[], @@ -381,7 +381,9 @@ def test_get_invoices(sandbox_accounting_api: AccountingApi, xero_tenant_id): total=Decimal(40), total_tax=Decimal(0), type="ACCREC", - updated_date_utc=datetime.datetime(2018, 11, 2, 16, 31, 30, 160000, tzinfo=tz.UTC), + updated_date_utc=datetime.datetime( + 2018, 11, 2, 16, 31, 30, 160000, tzinfo=tz.UTC + ), updated_date_utc_string="2018-11-02T16:31:30Z", ), Invoice( @@ -421,17 +423,19 @@ def test_get_invoices(sandbox_accounting_api: AccountingApi, xero_tenant_id): has_validation_errors=False, payment_id="99ea7f6b-c513-4066-bc27-b7c65dcd76c2", ) - ], + ], prepayments=[], reference="Red Fish, Blue Fish", - #repeating_invoice_id="428c0d75-909f-4b04-8403-a48dc27283b0", + # repeating_invoice_id="428c0d75-909f-4b04-8403-a48dc27283b0", sent_to_contact=True, status="PAID", sub_total=Decimal(40), total=Decimal(46), total_tax=Decimal(6), type="ACCREC", - updated_date_utc=datetime.datetime(2018, 11, 2, 16, 36, 32, 690000, tzinfo=tz.UTC), + updated_date_utc=datetime.datetime( + 2018, 11, 2, 16, 36, 32, 690000, tzinfo=tz.UTC + ), updated_date_utc_string="2018-11-02T16:36:32Z", ), Invoice( @@ -469,19 +473,17 @@ def test_get_invoices(sandbox_accounting_api: AccountingApi, xero_tenant_id): total=Decimal(115), total_tax=Decimal(15), type="ACCREC", - updated_date_utc=datetime.datetime(2018, 11, 2, 16, 37, 28, 927000, tzinfo=tz.UTC), - updated_date_utc_string="2018-11-02T16:37:28Z" - ) + updated_date_utc=datetime.datetime( + 2018, 11, 2, 16, 37, 28, 927000, tzinfo=tz.UTC + ), + updated_date_utc_string="2018-11-02T16:37:28Z", + ), ], - pagination= Pagination( - item_count= 3, - page = 1, - page_count= 1, - page_size= 100 - ) + pagination=Pagination(item_count=3, page=1, page_count=1, page_size=100), ) assert str(result) == str(expected) + @pytest.mark.sandbox def test_get_invoice_history(sandbox_accounting_api: AccountingApi, xero_tenant_id): # Given sandbox API, tenant id, and hardcoded test invoice data @@ -493,16 +495,20 @@ def test_get_invoice_history(sandbox_accounting_api: AccountingApi, xero_tenant_ [ HistoryRecord( changes="Attached a file", - date_utc= datetime.datetime(2018, 11, 8, 15, 1, 21, 470000, tzinfo=tz.UTC), - details= "Attached the file sample2.jpg through the Xero API using Xero API Partner", + date_utc=datetime.datetime( + 2018, 11, 8, 15, 1, 21, 470000, tzinfo=tz.UTC + ), + details="Attached the file sample2.jpg through the Xero API using Xero API Partner", user="System Generated", ), HistoryRecord( changes="Credit Applied", - date_utc=datetime.datetime(2016, 10, 17, 20, 46, 1, 173000, tzinfo=tz.UTC), + date_utc=datetime.datetime( + 2016, 10, 17, 20, 46, 1, 173000, tzinfo=tz.UTC + ), details="Bank transfer from Business Wells Fargo to My Savings on November 12, 2016 for 20.00.", user="System Generated", - ) + ), ] ) assert result == expected @@ -668,10 +674,14 @@ def test_create_invoice_history(sandbox_accounting_api: AccountingApi, xero_tena ) # Then expect created invoice history records expected = HistoryRecords( - [HistoryRecord( - date_utc=datetime.datetime(2019, 2, 23, 5, 23, 20, 362000, tzinfo=tz.UTC), - details="Hello World" - )] + [ + HistoryRecord( + date_utc=datetime.datetime( + 2019, 2, 23, 5, 23, 20, 362000, tzinfo=tz.UTC + ), + details="Hello World", + ) + ] ) assert result == expected diff --git a/tests/test_api_client/test_oauth2.py b/tests/test_api_client/test_oauth2.py index cc2eb2c1..40958885 100644 --- a/tests/test_api_client/test_oauth2.py +++ b/tests/test_api_client/test_oauth2.py @@ -151,6 +151,7 @@ def test_auth2_refresh_access_token(): assert oauth2_token.access_token == new_token["access_token"] assert oauth2_token.refresh_token == new_token["refresh_token"] + def test_auth2_refresh_access_token_having_scope_as_string(): # given OAuth2Token with expired access_token api_client = FakeClass() @@ -195,6 +196,7 @@ def test_auth2_refresh_access_token_having_scope_as_string(): assert oauth2_token.access_token == new_token["access_token"] assert oauth2_token.refresh_token == new_token["refresh_token"] + def test_auth2_fetch_access_token(): # Given OAuth2Token with valid refresh_token oauth2_token = OAuth2Token() diff --git a/tests/utils/start-prism.sh b/tests/utils/start-prism.sh index 8eff015d..84cafee7 100755 --- a/tests/utils/start-prism.sh +++ b/tests/utils/start-prism.sh @@ -7,6 +7,8 @@ fi specUrl="https://raw.githubusercontent.com/XeroAPI/Xero-OpenAPI/$specRef" +# Every mock is backgrounded so this script returns once they are all spawned. +# The caller is responsible for waiting until ports 4010-4018 accept connections. prism mock "$specUrl/xero_accounting.yaml" --host 127.0.0.1 --port 4010 & prism mock "$specUrl/xero-app-store.yaml" --host 127.0.0.1 --port 4011 & prism mock "$specUrl/xero_assets.yaml" --host 127.0.0.1 --port 4012 & @@ -15,4 +17,4 @@ prism mock "$specUrl/xero-finance.yaml" --host 127.0.0.1 --port 4014 & prism mock "$specUrl/xero-payroll-uk.yaml" --host 127.0.0.1 --port 4015 & prism mock "$specUrl/xero-payroll-nz.yaml" --host 127.0.0.1 --port 4016 & prism mock "$specUrl/xero-payroll-au.yaml" --host 127.0.0.1 --port 4017 & -prism mock "$specUrl/xero-projects.yaml" --host 127.0.0.1 --port 4018 +prism mock "$specUrl/xero-projects.yaml" --host 127.0.0.1 --port 4018 &