diff --git a/.github/workflows/build-test-lint.yml b/.github/workflows/build-test-lint.yml index 14722dcc..27e9936d 100644 --- a/.github/workflows/build-test-lint.yml +++ b/.github/workflows/build-test-lint.yml @@ -1,63 +1,95 @@ -name: Python Build, Lint +name: Python Build, Lint and Test on: - push: + push: + branches: [master] + pull_request: + types: [opened, reopened, synchronize] + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true 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: + # 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 + 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 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 + if: matrix.python == '3.13' + 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 + env: + XERO_OPENAPI_REF: ee65f8bddb16f37cde3e32741d465eeed6507a74 + run: | + bash tests/utils/start-prism.sh "$XERO_OPENAPI_REF" + 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..918abad4 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -1,3 +1,5 @@ -r test.txt -r flake8.txt +build +pip-audit==2.10.1; python_version >= "3.10" tox diff --git a/requirements/flake8.txt b/requirements/flake8.txt index d266010c..87adfcc9 100644 --- a/requirements/flake8.txt +++ b/requirements/flake8.txt @@ -1 +1,4 @@ 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 be7c09e3..84cafee7 100755 --- a/tests/utils/start-prism.sh +++ b/tests/utils/start-prism.sh @@ -1,12 +1,20 @@ #!/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" + +# 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 & +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 &