From 1d1ac53216ba8b10f02197ec3e6de11661bea9e4 Mon Sep 17 00:00:00 2001 From: Patrick Tannoury Date: Sat, 8 Aug 2026 17:06:56 +0200 Subject: [PATCH 1/2] ci: run tests and linting on pull requests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a CI workflow that runs on every pull request and on pushes to main. It uses the same paths-filter as the release workflow, so only the language directories a PR touches are exercised, and aggregates into a single `ci` check that can be made required for branch protection. Each language now has lint, static analysis and tests: - JavaScript: oxlint, `tsc --noEmit`, build, vitest, on Node 20 and 22. typescript-eslint was not an option — its peer range stops at TypeScript <6.1.0 and this package is on TypeScript 7. - Python: ruff (lint + format check) and mypy in strict mode, tests on 3.9 and 3.13. Lint and type checks run once, on the newest runtime. - PHP: PHP_CodeSniffer against PSR-12 and PHPStan at level 8, on PHP 8.1 and 8.4. Getting to a clean baseline required a handful of code changes: docstrings on the remaining `__init__` methods, `Optional[X]` rewritten as `X | None`, narrower types on the PHP transport and test helpers, and a couple of brace-placement fixes. Also fixes the JavaScript build, which was already failing on main: tsup's `--dts` flag loads rollup-plugin-dts, which cannot read TypeScript 7's compiler API. Declarations are now emitted by `tsc` directly, via a build tsconfig that excludes the test files. One test assertion changed: testAcceptsArrayRecipients asserted on the mocked response rather than the request, so it passed regardless of how recipients were serialised. It now checks the sent payload. --- .github/workflows/ci.yml | 171 +++++++++++ .gitignore | 4 + README.md | 19 ++ javascript/.oxlintrc.json | 33 +++ javascript/package-lock.json | 397 ++++++++++++++++++++++++++ javascript/package.json | 5 +- javascript/src/services/email.test.ts | 3 +- javascript/src/services/email.ts | 1 + javascript/tsconfig.build.json | 4 + php/composer.json | 9 +- php/composer.lock | 145 +++++++++- php/phpcs.xml | 19 ++ php/phpstan.neon | 5 + php/src/CurlHttpClient.php | 3 +- php/src/EmailService.php | 3 +- php/src/HttpClient.php | 1 + php/src/HttpResponse.php | 3 +- php/src/Retry.php | 4 +- php/src/Transport.php | 2 + php/tests/EmailServiceTest.php | 12 +- php/tests/FakeHttpClient.php | 9 +- php/tests/TransportTest.php | 8 +- python/pyproject.toml | 35 +++ python/src/cosmoner/_config.py | 10 +- python/src/cosmoner/_retry.py | 17 +- python/src/cosmoner/_transport.py | 36 ++- python/src/cosmoner/client.py | 15 +- python/src/cosmoner/email.py | 46 +-- python/src/cosmoner/errors.py | 23 +- 29 files changed, 955 insertions(+), 87 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 javascript/.oxlintrc.json create mode 100644 javascript/tsconfig.build.json create mode 100644 php/phpcs.xml create mode 100644 php/phpstan.neon diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..c685ad7 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,171 @@ +name: CI + +on: + pull_request: + push: + branches: [main] + +# A new push to a PR makes the in-flight run irrelevant. +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + detect-changes: + runs-on: ubuntu-latest + outputs: + python: ${{ steps.changes.outputs.python }} + javascript: ${{ steps.changes.outputs.javascript }} + php: ${{ steps.changes.outputs.php }} + steps: + - uses: actions/checkout@v7 + + - uses: dorny/paths-filter@v4 + id: changes + with: + # Changing the workflow itself re-runs every language. + filters: | + python: + - 'python/**' + - '.github/workflows/ci.yml' + javascript: + - 'javascript/**' + - '.github/workflows/ci.yml' + php: + - 'php/**' + - '.github/workflows/ci.yml' + + python: + needs: detect-changes + if: needs.detect-changes.outputs.python == 'true' + runs-on: ubuntu-latest + defaults: + run: + working-directory: python + strategy: + fail-fast: false + matrix: + python-version: ["3.9", "3.13"] + + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + + - uses: actions/setup-python@v7 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -e ".[test,lint]" + + - name: Lint + if: matrix.python-version == '3.13' + run: ruff check . + + - name: Check formatting + if: matrix.python-version == '3.13' + run: ruff format --check . + + - name: Type check + if: matrix.python-version == '3.13' + run: mypy + + - name: Test + run: python -m pytest + + javascript: + needs: detect-changes + if: needs.detect-changes.outputs.javascript == 'true' + runs-on: ubuntu-latest + defaults: + run: + working-directory: javascript + strategy: + fail-fast: false + matrix: + node-version: ["20", "22"] + + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + + - uses: actions/setup-node@v7 + with: + node-version: ${{ matrix.node-version }} + cache: npm + cache-dependency-path: javascript/package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Lint + if: matrix.node-version == '22' + run: npm run lint + + - name: Type check + if: matrix.node-version == '22' + run: npm run typecheck + + - name: Build + run: npm run build + + - name: Test + run: npm test + + php: + needs: detect-changes + if: needs.detect-changes.outputs.php == 'true' + runs-on: ubuntu-latest + defaults: + run: + working-directory: php + strategy: + fail-fast: false + matrix: + php-version: ["8.1", "8.4"] + + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + + - uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-version }} + coverage: none + + - name: Validate composer.json + run: composer validate --strict + + - name: Install dependencies + run: composer install --no-interaction --no-progress + + - name: Lint + if: matrix.php-version == '8.4' + run: composer run lint + + - name: Static analysis + if: matrix.php-version == '8.4' + run: composer run analyse + + - name: Test + run: composer run test + + # Single required check for branch protection: green when nothing failed, + # including when a language was skipped because it did not change. + ci: + if: always() + needs: [python, javascript, php] + runs-on: ubuntu-latest + steps: + - name: Verify no job failed + run: | + if echo '${{ join(needs.*.result, ',') }}' | grep -qE 'failure|cancelled'; then + echo "One or more language jobs failed." + exit 1 + fi + echo "All language jobs passed or were skipped." diff --git a/.gitignore b/.gitignore index 797e7be..308ee66 100644 --- a/.gitignore +++ b/.gitignore @@ -10,10 +10,14 @@ build/ *.pyc .pytest_cache/ .mypy_cache/ +.ruff_cache/ +.venv/ # PHP vendor/ .phpunit.result.cache +.phpcs-cache +.phpstan.cache/ # IDE .idea/ diff --git a/README.md b/README.md index cc2ddea..ea57f0f 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,25 @@ See the README in each language directory for full API reference: - [Python](./python/README.md) - [PHP](./php/README.md) +## Development + +Every pull request runs lint, type checks and tests for each language directory +it touches. To run the same checks locally: + +```bash +# JavaScript +cd javascript && npm ci && npm run lint && npm run typecheck && npm test + +# Python +cd python && pip install -e ".[test,lint]" && ruff check . && ruff format --check . && mypy && pytest + +# PHP +cd php && composer install && composer run lint && composer run analyse && composer run test +``` + +`npm run lint` uses [oxlint](https://oxc.rs/docs/guide/usage/linter); `composer run lint:fix` +and `ruff check --fix` apply the auto-fixable subset. + ## License [MIT](./LICENSE) diff --git a/javascript/.oxlintrc.json b/javascript/.oxlintrc.json new file mode 100644 index 0000000..5eb59cc --- /dev/null +++ b/javascript/.oxlintrc.json @@ -0,0 +1,33 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["typescript", "unicorn", "promise", "import"], + "categories": { + "correctness": "error", + "suspicious": "error", + "perf": "error" + }, + "env": { + "es2022": true, + "node": true + }, + "ignorePatterns": ["dist"], + "rules": { + "eqeqeq": "error", + "no-console": "error", + "no-var": "error", + "no-await-in-loop": "off", + "prefer-const": "error", + "require-await": "error", + "typescript/consistent-type-imports": "error", + "typescript/no-explicit-any": "error", + "promise/no-return-wrap": "error" + }, + "overrides": [ + { + "files": ["**/*.test.ts"], + "rules": { + "typescript/no-explicit-any": "off" + } + } + ] +} diff --git a/javascript/package-lock.json b/javascript/package-lock.json index 3cbfa15..901860a 100644 --- a/javascript/package-lock.json +++ b/javascript/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "license": "MIT", "devDependencies": { + "oxlint": "^1.77.0", "tsup": "^8.0.0", "typescript": "^7.0.2", "vitest": "^4.1.9" @@ -561,6 +562,353 @@ "url": "https://github.com/sponsors/Boshen" } }, + "node_modules/@oxlint/binding-android-arm-eabi": { + "version": "1.77.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.77.0.tgz", + "integrity": "sha512-E06sKWS6PiI6HRxS1wyQg22HvApt01hI7fV+T3wUk3OSbaaP4a3hYGY/MIQDmASqCiRjBdpRQYkgMkqH82cWmQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-android-arm64": { + "version": "1.77.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.77.0.tgz", + "integrity": "sha512-NvsKz0KZxTp9cYWPLf+FXaSZwB3oO3peAjtukpOMBgse2vhQSoIIVqeO1yR0lEo/UcdZIDL18uq+kL0LzQ0ytA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-arm64": { + "version": "1.77.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.77.0.tgz", + "integrity": "sha512-bgjTn6nW4bQCFBvSvuHCpDD+sONvmpo4lGI4PxzMt1quBA+xYxhczk6RiCn3GZ9gY8uhaBbwhj9MdKGfu6T9DA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-x64": { + "version": "1.77.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.77.0.tgz", + "integrity": "sha512-aotaIttH1R6j1Rwhx0M0htgeZyGtVQqYNTVEYMN/UcgHPquGA6kmk9OyuDc3a2GKUQBC+3C3GVQCcrRPMYqAFA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-freebsd-x64": { + "version": "1.77.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.77.0.tgz", + "integrity": "sha512-nNx/wta7ksRAdYvq+l4AWjXkLxEXHALhENxjj2cYbQAIR4ybaA5L+hCbE63HOmft5czQ6ks+hb8vmEAnn7YGPg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-gnueabihf": { + "version": "1.77.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.77.0.tgz", + "integrity": "sha512-tMLLjM7xXtzXisVCzkOTXNCy9bZVId2wteNwjohlFDR/jY6WagpEDA1c1wu4xRc20Hojaxj+V6DSR7gbKxijWA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-musleabihf": { + "version": "1.77.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.77.0.tgz", + "integrity": "sha512-MiAFDFaqR0tmHTAyo0YDcZ5hyLREdYw/RQhc2R3cbT+8O3tB+zqPM2th9TTQ+Uo3jn/embS+DO+HyX9ztCPkOQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-gnu": { + "version": "1.77.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.77.0.tgz", + "integrity": "sha512-/xqQ3B16i1T4cyt/9Mn+4CpzhUXoBXp7kVpIwzOXNFLj5JmK1bIjsbSnX296Gg8A/o7oDtKWikFgBx0SLwztkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-musl": { + "version": "1.77.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.77.0.tgz", + "integrity": "sha512-LSbwuRKiNCenPDcbARqAZ5RfBy7gmj7vOvfJRLeCDU3gFtSxWbhv/+VTlaUqzUhNj1gFLHB8h7ALnxa/Az6z6g==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-ppc64-gnu": { + "version": "1.77.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.77.0.tgz", + "integrity": "sha512-QWdcH31mXEUe5Nq1s0CfCpceaKjIo9uZtwDjAuL681g1axf+5x8xrg/eXWaw//4NCxYZ4V4e5Hu5tvdR+pTBlg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-gnu": { + "version": "1.77.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.77.0.tgz", + "integrity": "sha512-GnOfYgJxbcElOiPZaDFDl406ONddwvOWk2jvAAAEjwAl4GofNoHF+/HHUIBYa6bFCArlcGPi0XjC4cU1pkgF/Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-musl": { + "version": "1.77.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.77.0.tgz", + "integrity": "sha512-AyEMTUCf0xY+hHF+IxqXFQIX0yQOIR8ykpY0lJNOw9xYqOzUX8dyZfRvlG0RfXwuQn2eonf/8NrMmDSZJjdqsA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-s390x-gnu": { + "version": "1.77.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.77.0.tgz", + "integrity": "sha512-sPLzEcNvxd/oyVQ5oZo92CiHkFkpBeRop13E/P3TPY+hZfXHKCOWKI70TE2RYwMKFJDc20EMjH16L7NZICtKTw==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-gnu": { + "version": "1.77.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.77.0.tgz", + "integrity": "sha512-1Oh2ssH2L7lwyvkdSqaMUfsGfwU2Wfvew+obBUYjRVqhpBcUpwnsPSEr1IzVi9XqkuY10geiLsNKecqaZC34Dw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-musl": { + "version": "1.77.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.77.0.tgz", + "integrity": "sha512-0j/2wRgNGO+Qj/M1uu/p57h/hFTTWWcfie0ufkbabeus2s5+/QqkCflnMOwLLN5m2GsNeWp4xdl4cPa4n7QCOQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-openharmony-arm64": { + "version": "1.77.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.77.0.tgz", + "integrity": "sha512-BJ/j54qS0usEnyDkLYURMj2iiD9h5Cyy+ppzeMSXBGRXaGRNWnj1Mw14NqWMR5E/PzdgB30OOCCzLzbRoduafw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-arm64-msvc": { + "version": "1.77.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.77.0.tgz", + "integrity": "sha512-Yh8w+g2Lpx7StrvtYkoz9JJvXjB9wxgFChFNb85nrXm/wj/XTwGWS1hve9+900HL7llrntYB3YP+y32E3tRqzA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-ia32-msvc": { + "version": "1.77.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.77.0.tgz", + "integrity": "sha512-zja5b7+6a7UsRFgAQSrnax5vrzliEyNPLCjfXONu/vTWswaIVZGFajJZptaeRvPE4LghtFdAzVFlexTm7MVTGA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-x64-msvc": { + "version": "1.77.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.77.0.tgz", + "integrity": "sha512-+teyvPDZ2RjUvo+SuCqS/UhaJl1QtdW5fWT5NJTV61V5MIuIS90Db9LixmtEGvXixyttiK62P96MSu3UlpviBw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", @@ -2384,6 +2732,55 @@ "node": ">=12.20.0" } }, + "node_modules/oxlint": { + "version": "1.77.0", + "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.77.0.tgz", + "integrity": "sha512-qnGh8XJHaQ0dprrDXNQZgS0FgjI6v+V3+X8DwmaV++5Aamy6jGKfDdQ1TUvhUxtmKFAbEf4/WeO5QZX+5WSngg==", + "dev": true, + "license": "MIT", + "bin": { + "oxlint": "bin/oxlint" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/Boshen" + }, + "optionalDependencies": { + "@oxlint/binding-android-arm-eabi": "1.77.0", + "@oxlint/binding-android-arm64": "1.77.0", + "@oxlint/binding-darwin-arm64": "1.77.0", + "@oxlint/binding-darwin-x64": "1.77.0", + "@oxlint/binding-freebsd-x64": "1.77.0", + "@oxlint/binding-linux-arm-gnueabihf": "1.77.0", + "@oxlint/binding-linux-arm-musleabihf": "1.77.0", + "@oxlint/binding-linux-arm64-gnu": "1.77.0", + "@oxlint/binding-linux-arm64-musl": "1.77.0", + "@oxlint/binding-linux-ppc64-gnu": "1.77.0", + "@oxlint/binding-linux-riscv64-gnu": "1.77.0", + "@oxlint/binding-linux-riscv64-musl": "1.77.0", + "@oxlint/binding-linux-s390x-gnu": "1.77.0", + "@oxlint/binding-linux-x64-gnu": "1.77.0", + "@oxlint/binding-linux-x64-musl": "1.77.0", + "@oxlint/binding-openharmony-arm64": "1.77.0", + "@oxlint/binding-win32-arm64-msvc": "1.77.0", + "@oxlint/binding-win32-ia32-msvc": "1.77.0", + "@oxlint/binding-win32-x64-msvc": "1.77.0" + }, + "peerDependencies": { + "oxlint-tsgolint": ">=7.0.2001", + "vite-plus": "*" + }, + "peerDependenciesMeta": { + "oxlint-tsgolint": { + "optional": true + }, + "vite-plus": { + "optional": true + } + } + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", diff --git a/javascript/package.json b/javascript/package.json index 2707126..90d0de2 100644 --- a/javascript/package.json +++ b/javascript/package.json @@ -20,7 +20,9 @@ "dist" ], "scripts": { - "build": "tsup src/index.ts --format cjs,esm --dts", + "build": "tsup src/index.ts --format cjs,esm && tsc -p tsconfig.build.json --emitDeclarationOnly", + "lint": "oxlint", + "typecheck": "tsc --noEmit", "test": "vitest run", "prepublishOnly": "npm run build" }, @@ -30,6 +32,7 @@ ], "license": "MIT", "devDependencies": { + "oxlint": "^1.77.0", "tsup": "^8.0.0", "typescript": "^7.0.2", "vitest": "^4.1.9" diff --git a/javascript/src/services/email.test.ts b/javascript/src/services/email.test.ts index 7a729ee..18d5719 100644 --- a/javascript/src/services/email.test.ts +++ b/javascript/src/services/email.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { Cosmoner, CosmonerError, RateLimitError } from "../index"; +import { Cosmoner, RateLimitError } from "../index"; +import type { CosmonerError } from "../index"; const URL = "https://api.test.dev/v1/projects/proj-1/email/send"; diff --git a/javascript/src/services/email.ts b/javascript/src/services/email.ts index 8651783..4b9f9de 100644 --- a/javascript/src/services/email.ts +++ b/javascript/src/services/email.ts @@ -33,6 +33,7 @@ export class EmailService { * * At least one of `html` or `text` is required. */ + // eslint-disable-next-line require-await -- `async` makes the validation below reject rather than throw synchronously. async send(params: SendEmailParams): Promise { if (!params.to) throw new Error("to is required"); if (!params.subject) throw new Error("subject is required"); diff --git a/javascript/tsconfig.build.json b/javascript/tsconfig.build.json new file mode 100644 index 0000000..d472b2c --- /dev/null +++ b/javascript/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["**/*.test.ts"] +} diff --git a/php/composer.json b/php/composer.json index 4b78965..3923e64 100644 --- a/php/composer.json +++ b/php/composer.json @@ -17,10 +17,15 @@ } }, "require-dev": { - "phpunit/phpunit": "^10.0 || ^11.0" + "phpunit/phpunit": "^10.0 || ^11.0", + "squizlabs/php_codesniffer": "^3.10", + "phpstan/phpstan": "^2.0" }, "scripts": { - "test": "phpunit --testdox" + "test": "phpunit --testdox", + "lint": "phpcs", + "lint:fix": "phpcbf", + "analyse": "phpstan analyse" }, "keywords": [ "cosmoner", diff --git a/php/composer.lock b/php/composer.lock index d6e5b83..3830ac0 100644 --- a/php/composer.lock +++ b/php/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "ed47ab6a71afdd2e698fc61024193717", + "content-hash": "1fc9bd23654e8bf96f5f4ea9e4af7ada", "packages": [], "packages-dev": [ { @@ -243,6 +243,70 @@ }, "time": "2022-02-21T01:04:05+00:00" }, + { + "name": "phpstan/phpstan", + "version": "2.2.8", + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/e285254e60f33c21902efef4a926ca0987c06804", + "reference": "e285254e60f33c21902efef4a926ca0987c06804", + "shasum": "" + }, + "require": { + "php": "^7.4|^8.0" + }, + "conflict": { + "phpstan/phpstan-shim": "*" + }, + "bin": [ + "phpstan", + "phpstan.phar" + ], + "type": "library", + "autoload": { + "files": [ + "bootstrap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ondřej Mirtes" + }, + { + "name": "Markus Staab" + }, + { + "name": "Vincent Langlet" + } + ], + "description": "PHPStan - PHP Static Analysis Tool", + "keywords": [ + "dev", + "static analysis" + ], + "support": { + "docs": "https://phpstan.org/user-guide/getting-started", + "forum": "https://github.com/phpstan/phpstan/discussions", + "issues": "https://github.com/phpstan/phpstan/issues", + "security": "https://github.com/phpstan/phpstan/security/policy", + "source": "https://github.com/phpstan/phpstan-src" + }, + "funding": [ + { + "url": "https://github.com/ondrejmirtes", + "type": "github" + }, + { + "url": "https://github.com/phpstan", + "type": "github" + } + ], + "time": "2026-08-04T22:21:45+00:00" + }, { "name": "phpunit/php-code-coverage", "version": "11.0.12", @@ -1686,6 +1750,85 @@ ], "time": "2024-10-09T05:16:32+00:00" }, + { + "name": "squizlabs/php_codesniffer", + "version": "3.13.6", + "source": { + "type": "git", + "url": "https://github.com/PHPCSStandards/PHP_CodeSniffer.git", + "reference": "4c378e1a528ea066890fc2397cbdd2f94eb2fc91" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/4c378e1a528ea066890fc2397cbdd2f94eb2fc91", + "reference": "4c378e1a528ea066890fc2397cbdd2f94eb2fc91", + "shasum": "" + }, + "require": { + "ext-simplexml": "*", + "ext-tokenizer": "*", + "ext-xmlwriter": "*", + "php": ">=5.4.0" + }, + "require-dev": { + "phpunit/phpunit": "^4.0 || ^5.0 || ^6.0 || ^7.0 || ^8.0 || ^9.3.4" + }, + "bin": [ + "bin/phpcbf", + "bin/phpcs" + ], + "type": "library", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Greg Sherwood", + "role": "Former lead" + }, + { + "name": "Juliette Reinders Folmer", + "role": "Current lead" + }, + { + "name": "Contributors", + "homepage": "https://github.com/PHPCSStandards/PHP_CodeSniffer/graphs/contributors" + } + ], + "description": "PHP_CodeSniffer tokenizes PHP, JavaScript and CSS files and detects violations of a defined set of coding standards.", + "homepage": "https://github.com/PHPCSStandards/PHP_CodeSniffer", + "keywords": [ + "phpcs", + "standards", + "static analysis" + ], + "support": { + "issues": "https://github.com/PHPCSStandards/PHP_CodeSniffer/issues", + "security": "https://github.com/PHPCSStandards/PHP_CodeSniffer/security/policy", + "source": "https://github.com/PHPCSStandards/PHP_CodeSniffer", + "wiki": "https://github.com/PHPCSStandards/PHP_CodeSniffer/wiki" + }, + "funding": [ + { + "url": "https://github.com/PHPCSStandards", + "type": "github" + }, + { + "url": "https://github.com/jrfnl", + "type": "github" + }, + { + "url": "https://opencollective.com/php_codesniffer", + "type": "open_collective" + }, + { + "url": "https://thanks.dev/u/gh/phpcsstandards", + "type": "thanks_dev" + } + ], + "time": "2026-08-06T00:17:32+00:00" + }, { "name": "staabm/side-effects-detector", "version": "1.0.5", diff --git a/php/phpcs.xml b/php/phpcs.xml new file mode 100644 index 0000000..d7f4769 --- /dev/null +++ b/php/phpcs.xml @@ -0,0 +1,19 @@ + + + PSR-12 with a line limit, applied to source and tests. + + src + tests + + + + + + + + + + + + + diff --git a/php/phpstan.neon b/php/phpstan.neon new file mode 100644 index 0000000..1cd333b --- /dev/null +++ b/php/phpstan.neon @@ -0,0 +1,5 @@ +parameters: + level: 8 + paths: + - src + - tests diff --git a/php/src/CurlHttpClient.php b/php/src/CurlHttpClient.php index 34b0798..17b371f 100644 --- a/php/src/CurlHttpClient.php +++ b/php/src/CurlHttpClient.php @@ -11,6 +11,7 @@ final class CurlHttpClient implements HttpClient * Sends one request via curl, translating transport failures into * `CosmonerConnectionError` (or `CosmonerTimeoutError` for timeouts). * + * @param non-empty-string $method * @param array $headers */ public function send( @@ -33,7 +34,7 @@ public function send( CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => $formatted, CURLOPT_TIMEOUT_MS => (int) round($timeout * 1000), - CURLOPT_HEADERFUNCTION => function ($_ch, string $line) use (&$responseHeaders): int { + CURLOPT_HEADERFUNCTION => function (\CurlHandle $_ch, string $line) use (&$responseHeaders): int { $parts = explode(':', $line, 2); if (count($parts) === 2) { $responseHeaders[strtolower(trim($parts[0]))] = trim($parts[1]); diff --git a/php/src/EmailService.php b/php/src/EmailService.php index 388d411..c30aa1e 100644 --- a/php/src/EmailService.php +++ b/php/src/EmailService.php @@ -12,7 +12,8 @@ class EmailService public function __construct( private readonly Transport $transport, private readonly Config $config, - ) {} + ) { + } /** * Sends a transactional email and returns the API envelope with its message id. diff --git a/php/src/HttpClient.php b/php/src/HttpClient.php index 539b723..7519bfa 100644 --- a/php/src/HttpClient.php +++ b/php/src/HttpClient.php @@ -15,6 +15,7 @@ interface HttpClient /** * Sends one request and returns the raw response. * + * @param non-empty-string $method * @param array $headers * * @throws CosmonerConnectionError When the request never produced a response. diff --git a/php/src/HttpResponse.php b/php/src/HttpResponse.php index a8dfcd3..e14ee8f 100644 --- a/php/src/HttpResponse.php +++ b/php/src/HttpResponse.php @@ -14,7 +14,8 @@ public function __construct( public readonly int $status, public readonly string $body, public readonly array $headers = [], - ) {} + ) { + } /** Whether the status is in the 2xx range. */ public function isSuccess(): bool diff --git a/php/src/Retry.php b/php/src/Retry.php index 88d7979..03d96df 100644 --- a/php/src/Retry.php +++ b/php/src/Retry.php @@ -16,7 +16,9 @@ final class Retry private const BACKOFF_BASE_SECONDS = 0.5; private const BACKOFF_CAP_SECONDS = 8.0; - public function __construct(private readonly int $maxRetries) {} + public function __construct(private readonly int $maxRetries) + { + } /** * Decides whether a failure should be retried. diff --git a/php/src/Transport.php b/php/src/Transport.php index 9879099..a3ee18c 100644 --- a/php/src/Transport.php +++ b/php/src/Transport.php @@ -27,6 +27,8 @@ public function __construct( * @param array|null $body * @param array $query * + * @param non-empty-string $method + * * @return array * * @throws CosmonerError On API and transport failures. diff --git a/php/tests/EmailServiceTest.php b/php/tests/EmailServiceTest.php index 6f578c3..fbb93fd 100644 --- a/php/tests/EmailServiceTest.php +++ b/php/tests/EmailServiceTest.php @@ -26,10 +26,14 @@ protected function setUp(): void ); } - /** Decode the JSON body of the recorded request. */ + /** + * Decode the JSON body of the recorded request. + * + * @return array + */ private function sentBody(): array { - return json_decode($this->http->requests[0]['body'], true); + return json_decode((string) $this->http->requests[0]['body'], true); } public function testThrowsWhenNeitherHtmlNorTextProvided(): void @@ -94,13 +98,13 @@ public function testAcceptsArrayRecipients(): void { $this->http->queueJson(200, ['success' => true, 'data' => ['messageId' => 'msg-jkl']]); - $result = $this->client->email->send( + $this->client->email->send( 'cred-1', ['a@test.com', 'b@test.com'], 'Hello', '

Hi

', ); - $this->assertTrue($result['success']); + $this->assertSame(['a@test.com', 'b@test.com'], $this->sentBody()['to']); } } diff --git a/php/tests/FakeHttpClient.php b/php/tests/FakeHttpClient.php index e38db47..6e35f6c 100644 --- a/php/tests/FakeHttpClient.php +++ b/php/tests/FakeHttpClient.php @@ -25,10 +25,15 @@ public function queue(HttpResponse|CosmonerConnectionError $response): self return $this; } - /** Queues a JSON response with the given status. */ + /** + * Queues a JSON response with the given status. + * + * @param array $body + * @param array $headers + */ public function queueJson(int $status, array $body = [], array $headers = []): self { - return $this->queue(new HttpResponse($status, json_encode($body), $headers)); + return $this->queue(new HttpResponse($status, (string) json_encode($body), $headers)); } /** Returns the next queued response, repeating the last one once exhausted. */ diff --git a/php/tests/TransportTest.php b/php/tests/TransportTest.php index 6f53b15..ca0ef30 100644 --- a/php/tests/TransportTest.php +++ b/php/tests/TransportTest.php @@ -40,7 +40,11 @@ private function client(int $maxRetries = 0): Cosmoner ); } - /** Issue a representative POST through the transport. */ + /** + * Issue a representative POST through the transport. + * + * @return array + */ private function send(Cosmoner $client): array { return $client->email->send('cred-1', 'user@test.com', 'Test', null, 'body'); @@ -204,6 +208,7 @@ public function testSurfacesValidationDetails(): void } } + /** @param class-string<\Throwable> $expected */ #[DataProvider('statusProvider')] public function testMapsStatusToErrorClass(int $status, string $expected): void { @@ -214,6 +219,7 @@ public function testMapsStatusToErrorClass(int $status, string $expected): void $this->send($this->client()); } + /** @return list}> */ public static function statusProvider(): array { return [ diff --git a/python/pyproject.toml b/python/pyproject.toml index f5b69a7..f53e431 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -22,10 +22,45 @@ test = [ "pytest-httpx>=0.28", "pytest-asyncio>=0.23", ] +lint = [ + "ruff>=0.9", + "mypy>=1.15", +] [tool.pytest.ini_options] testpaths = ["tests"] asyncio_mode = "auto" +[tool.ruff] +line-length = 90 +target-version = "py39" +src = ["src", "tests"] +extend-exclude = ["*.md"] + +[tool.ruff.lint] +select = [ + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # pyflakes + "I", # isort + "B", # flake8-bugbear + "UP", # pyupgrade + "SIM", # flake8-simplify + "RUF", # ruff-specific + "D", # pydocstyle — every function/class needs a docstring +] + +[tool.ruff.lint.pydocstyle] +convention = "google" + +[tool.ruff.lint.per-file-ignores] +# Tests document themselves through their names; docstrings on every case are noise. +"tests/*" = ["D"] + +[tool.mypy] +files = ["src"] +strict = true +warn_unused_ignores = true + [project.urls] Homepage = "https://cosmoner.com" diff --git a/python/src/cosmoner/_config.py b/python/src/cosmoner/_config.py index 55296e0..236d56a 100644 --- a/python/src/cosmoner/_config.py +++ b/python/src/cosmoner/_config.py @@ -3,7 +3,6 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Optional DEFAULT_BASE_URL = "https://api.cosmoner.com" DEFAULT_TIMEOUT = 30.0 @@ -15,7 +14,7 @@ class ClientConfig: """Immutable connection settings shared by a client and its service namespaces.""" api_key: str - project_id: Optional[str] + project_id: str | None base_url: str timeout: float max_retries: int @@ -23,13 +22,12 @@ class ClientConfig: def build_config( api_key: str, - project_id: Optional[str], + project_id: str | None, base_url: str = DEFAULT_BASE_URL, timeout: float = DEFAULT_TIMEOUT, max_retries: int = DEFAULT_MAX_RETRIES, ) -> ClientConfig: - """ - Validates constructor arguments and normalizes them into a ``ClientConfig``. + """Validates constructor arguments and normalizes them into a ``ClientConfig``. ``project_id`` is optional so a single client can span projects, but an explicitly empty string is rejected rather than silently treated as unset — @@ -53,7 +51,7 @@ def build_config( ) -def resolve_project_id(config: ClientConfig, override: Optional[str] = None) -> str: +def resolve_project_id(config: ClientConfig, override: str | None = None) -> str: """Returns the per-call project id, falling back to the client-level default.""" project_id = override or config.project_id if not project_id: diff --git a/python/src/cosmoner/_retry.py b/python/src/cosmoner/_retry.py index f790d24..8ec033f 100644 --- a/python/src/cosmoner/_retry.py +++ b/python/src/cosmoner/_retry.py @@ -3,8 +3,8 @@ from __future__ import annotations import random +from collections.abc import Mapping from dataclasses import dataclass -from typing import Mapping, Optional # Methods the HTTP spec defines as idempotent: replaying one cannot create a # second side effect, so they are safe to retry after a timeout or a 5xx. @@ -25,11 +25,10 @@ def should_retry( *, attempt: int, method: str, - status: Optional[int], + status: int | None, retry_non_idempotent: bool = False, ) -> bool: - """ - Returns whether another attempt is warranted. + """Returns whether another attempt is warranted. A ``status`` of ``None`` means the request failed at the transport layer and never reached the API. @@ -53,9 +52,8 @@ def should_retry( return method.upper() in IDEMPOTENT_METHODS or retry_non_idempotent - def backoff_seconds(self, attempt: int, retry_after: Optional[float] = None) -> float: - """ - Returns the delay before the next attempt. + def backoff_seconds(self, attempt: int, retry_after: float | None = None) -> float: + """Returns the delay before the next attempt. A server-supplied ``Retry-After`` wins outright. Otherwise the delay is exponential with full jitter, which spreads a thundering herd of clients @@ -68,9 +66,8 @@ def backoff_seconds(self, attempt: int, retry_after: Optional[float] = None) -> return random.uniform(0, ceiling) -def parse_retry_after(headers: Mapping[str, str]) -> Optional[float]: - """ - Reads a ``Retry-After`` header expressed in seconds. +def parse_retry_after(headers: Mapping[str, str]) -> float | None: + """Reads a ``Retry-After`` header expressed in seconds. The API does not currently send this header; it is honoured so the SDK starts respecting it the moment the platform adds it. The HTTP-date form is diff --git a/python/src/cosmoner/_transport.py b/python/src/cosmoner/_transport.py index 9237f1b..cfa2451 100644 --- a/python/src/cosmoner/_transport.py +++ b/python/src/cosmoner/_transport.py @@ -5,7 +5,8 @@ import asyncio import time import uuid -from typing import Any, Dict, Mapping, Optional +from collections.abc import Mapping +from typing import Any import httpx @@ -38,10 +39,9 @@ def _headers( self, method: str, has_body: bool, - idempotency_key: Optional[str], - ) -> Dict[str, str]: - """ - Builds the header set for one request. + idempotency_key: str | None, + ) -> dict[str, str]: + """Builds the header set for one request. The idempotency key is generated once per logical request and reused across retries, so a replayed write can be collapsed server-side. The @@ -95,9 +95,9 @@ def request( method: str, path: str, *, - json: Optional[Mapping[str, Any]] = None, - params: Optional[Mapping[str, Any]] = None, - idempotency_key: Optional[str] = None, + json: Mapping[str, Any] | None = None, + params: Mapping[str, Any] | None = None, + idempotency_key: str | None = None, retry_non_idempotent: bool = False, ) -> Any: """Issues a request, retrying transient failures, and returns the parsed body.""" @@ -106,8 +106,9 @@ def request( attempt = 0 while True: - status: Optional[int] = None - retry_after: Optional[float] = None + status: int | None = None + retry_after: float | None = None + error: CosmonerConnectionError | None = None try: response = self._client.request( @@ -133,6 +134,8 @@ def request( ): if error is not None: raise error + # ``response`` is always set when no transport error was captured. + assert response is not None return self._decode(response) # raises the mapped API error time.sleep(self._policy.backoff_seconds(attempt, retry_after)) @@ -155,9 +158,9 @@ async def request( method: str, path: str, *, - json: Optional[Mapping[str, Any]] = None, - params: Optional[Mapping[str, Any]] = None, - idempotency_key: Optional[str] = None, + json: Mapping[str, Any] | None = None, + params: Mapping[str, Any] | None = None, + idempotency_key: str | None = None, retry_non_idempotent: bool = False, ) -> Any: """Issues a request, retrying transient failures, and returns the parsed body.""" @@ -166,8 +169,9 @@ async def request( attempt = 0 while True: - status: Optional[int] = None - retry_after: Optional[float] = None + status: int | None = None + retry_after: float | None = None + error: CosmonerConnectionError | None = None try: response = await self._client.request( @@ -193,6 +197,8 @@ async def request( ): if error is not None: raise error + # ``response`` is always set when no transport error was captured. + assert response is not None return self._decode(response) # raises the mapped API error await asyncio.sleep(self._policy.backoff_seconds(attempt, retry_after)) diff --git a/python/src/cosmoner/client.py b/python/src/cosmoner/client.py index 20df384..1a8fa4b 100644 --- a/python/src/cosmoner/client.py +++ b/python/src/cosmoner/client.py @@ -2,8 +2,6 @@ from __future__ import annotations -from typing import Optional - from ._config import ( DEFAULT_BASE_URL, DEFAULT_MAX_RETRIES, @@ -15,8 +13,7 @@ class Cosmoner: - """ - Synchronous Cosmoner API client. + """Synchronous Cosmoner API client. ``project_id`` is optional: set it here to make it the default for every call, or omit it and pass ``project_id=`` per method to work across @@ -26,11 +23,12 @@ class Cosmoner: def __init__( self, api_key: str, - project_id: Optional[str] = None, + project_id: str | None = None, base_url: str = DEFAULT_BASE_URL, timeout: float = DEFAULT_TIMEOUT, max_retries: int = DEFAULT_MAX_RETRIES, ) -> None: + """Validates the settings and opens the connection pool the client will reuse.""" config = build_config(api_key, project_id, base_url, timeout, max_retries) self.api_key = config.api_key @@ -48,7 +46,7 @@ def close(self) -> None: """Releases the underlying connection pool.""" self._transport.close() - def __enter__(self) -> "Cosmoner": + def __enter__(self) -> Cosmoner: """Enters a context that closes the connection pool on exit.""" return self @@ -63,11 +61,12 @@ class AsyncCosmoner: def __init__( self, api_key: str, - project_id: Optional[str] = None, + project_id: str | None = None, base_url: str = DEFAULT_BASE_URL, timeout: float = DEFAULT_TIMEOUT, max_retries: int = DEFAULT_MAX_RETRIES, ) -> None: + """Validates the settings and opens the connection pool the client will reuse.""" config = build_config(api_key, project_id, base_url, timeout, max_retries) self.api_key = config.api_key @@ -85,7 +84,7 @@ async def aclose(self) -> None: """Releases the underlying connection pool.""" await self._transport.aclose() - async def __aenter__(self) -> "AsyncCosmoner": + async def __aenter__(self) -> AsyncCosmoner: """Enters a context that closes the connection pool on exit.""" return self diff --git a/python/src/cosmoner/email.py b/python/src/cosmoner/email.py index 0b76359..d64380e 100644 --- a/python/src/cosmoner/email.py +++ b/python/src/cosmoner/email.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import Any, Dict, Optional, Union +from typing import Any, Union from ._config import ClientConfig, resolve_project_id from ._transport import AsyncTransport, Transport @@ -14,15 +14,15 @@ def _build_payload( credential_id: str, to: Recipients, subject: str, - html: Optional[str], - text: Optional[str], - reply_to: Optional[Recipients], -) -> Dict[str, Any]: + html: str | None, + text: str | None, + reply_to: Recipients | None, +) -> dict[str, Any]: """Validates send arguments and shapes them into the API request body.""" if not html and not text: raise ValueError("Either html or text must be provided") - payload: Dict[str, Any] = { + payload: dict[str, Any] = { "credentialId": credential_id, "to": to, "subject": subject, @@ -41,6 +41,7 @@ class EmailService: """Synchronous email operations for a project.""" def __init__(self, transport: Transport, config: ClientConfig) -> None: + """Binds the namespace to the client's transport and resolved configuration.""" self._transport = transport self._config = config @@ -50,13 +51,12 @@ def send( to: Recipients, subject: str, *, - html: Optional[str] = None, - text: Optional[str] = None, - reply_to: Optional[Recipients] = None, - project_id: Optional[str] = None, - ) -> dict: - """ - Sends a transactional email and returns the API envelope with its message id. + html: str | None = None, + text: str | None = None, + reply_to: Recipients | None = None, + project_id: str | None = None, + ) -> dict[str, Any]: + """Sends a transactional email and returns the API envelope with its message id. At least one of ``html`` or ``text`` is required. ``project_id`` overrides the client-level default for this call. @@ -64,15 +64,17 @@ def send( payload = _build_payload(credential_id, to, subject, html, text, reply_to) project = resolve_project_id(self._config, project_id) - return self._transport.request( + result: dict[str, Any] = self._transport.request( "POST", f"/v1/projects/{project}/email/send", json=payload ) + return result class AsyncEmailService: """Asynchronous counterpart to :class:`EmailService`.""" def __init__(self, transport: AsyncTransport, config: ClientConfig) -> None: + """Binds the namespace to the client's transport and resolved configuration.""" self._transport = transport self._config = config @@ -82,13 +84,12 @@ async def send( to: Recipients, subject: str, *, - html: Optional[str] = None, - text: Optional[str] = None, - reply_to: Optional[Recipients] = None, - project_id: Optional[str] = None, - ) -> dict: - """ - Sends a transactional email and returns the API envelope with its message id. + html: str | None = None, + text: str | None = None, + reply_to: Recipients | None = None, + project_id: str | None = None, + ) -> dict[str, Any]: + """Sends a transactional email and returns the API envelope with its message id. At least one of ``html`` or ``text`` is required. ``project_id`` overrides the client-level default for this call. @@ -96,6 +97,7 @@ async def send( payload = _build_payload(credential_id, to, subject, html, text, reply_to) project = resolve_project_id(self._config, project_id) - return await self._transport.request( + result: dict[str, Any] = await self._transport.request( "POST", f"/v1/projects/{project}/email/send", json=payload ) + return result diff --git a/python/src/cosmoner/errors.py b/python/src/cosmoner/errors.py index 0997c6e..90fe461 100644 --- a/python/src/cosmoner/errors.py +++ b/python/src/cosmoner/errors.py @@ -2,12 +2,12 @@ from __future__ import annotations -from typing import Any, Mapping, Optional +from collections.abc import Mapping +from typing import Any class CosmonerError(Exception): - """ - Base class for every error the SDK raises. + """Base class for every error the SDK raises. Catching this catches all API and transport failures, so existing ``except CosmonerError`` blocks keep working as the hierarchy grows. @@ -20,8 +20,9 @@ def __init__( message: str, *, details: Any = None, - request_id: Optional[str] = None, + request_id: str | None = None, ) -> None: + """Records the status, machine-readable code and message of a failed request.""" super().__init__(message) self.status = status self.code = code @@ -34,6 +35,7 @@ class CosmonerConnectionError(CosmonerError): """The request never produced a response (DNS, TCP, TLS or socket failure).""" def __init__(self, message: str, *, code: str = "CONNECTION_ERROR") -> None: + """Reports a transport failure, which has no HTTP status of its own.""" super().__init__(status=0, code=code, message=message) @@ -41,6 +43,7 @@ class CosmonerTimeoutError(CosmonerConnectionError): """The request exceeded the configured timeout.""" def __init__(self, message: str) -> None: + """Narrows a connection failure to a timeout.""" super().__init__(message, code="TIMEOUT") @@ -74,9 +77,10 @@ def __init__( message: str, *, details: Any = None, - request_id: Optional[str] = None, - retry_after: Optional[float] = None, + request_id: str | None = None, + retry_after: float | None = None, ) -> None: + """Adds the parsed ``Retry-After`` delay, in seconds, when the API sends one.""" super().__init__(status, code, message, details=details, request_id=request_id) self.retry_after = retry_after @@ -99,11 +103,10 @@ class ServerError(CosmonerError): def error_from_response( status: int, body: Any, - headers: Optional[Mapping[str, str]] = None, - retry_after: Optional[float] = None, + headers: Mapping[str, str] | None = None, + retry_after: float | None = None, ) -> CosmonerError: - """ - Maps an error response onto the most specific exception class available. + """Maps an error response onto the most specific exception class available. The API envelope is ``{"success": false, "error": {"code", "message", "details"?}}`` but proxies and load balancers can return HTML or an empty body, so every From fe0a178856ec77ba10ced60d6cc8c2974834d89a Mon Sep 17 00:00:00 2001 From: Patrick Tannoury Date: Sat, 8 Aug 2026 17:09:08 +0200 Subject: [PATCH 2/2] ci: resolve PHP dev dependencies fresh on the 8.1 floor version --- .github/workflows/ci.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c685ad7..b92483d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -142,8 +142,16 @@ jobs: run: composer validate --strict - name: Install dependencies + if: matrix.php-version != '8.1' run: composer install --no-interaction --no-progress + # The lock file pins PHPUnit 11, which needs PHP 8.2+. On the floor + # version the dev tools are resolved fresh so PHPUnit 10 is chosen — + # which is the point: the library itself still has to work on 8.1. + - name: Install dependencies (resolved for PHP 8.1) + if: matrix.php-version == '8.1' + run: composer update --no-interaction --no-progress + - name: Lint if: matrix.php-version == '8.4' run: composer run lint