From 63f2b75c158805475bdd84b705c8966e2e0e9cde Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Wed, 29 Apr 2026 18:32:40 -0400 Subject: [PATCH 1/5] WIP --- .github/copilot-instructions.md | 3 + .github/workflows/ci.yml | 191 +++++++++++++++++ .github/workflows/matchers/python.json | 18 ++ .github/workflows/publish-to-pypi.yml | 64 ++++++ .github/workflows/pythonpublish.yml | 30 --- .github/workflows/test.yml | 41 ---- .gitignore | 12 +- prek.toml | 53 +++++ pyproject.toml | 282 +++++++++++++++++++++++-- requirements-test.txt | 6 - requirements.txt | 5 - setup.cfg | 5 - 12 files changed, 598 insertions(+), 112 deletions(-) create mode 100644 .github/copilot-instructions.md create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/matchers/python.json create mode 100644 .github/workflows/publish-to-pypi.yml delete mode 100644 .github/workflows/pythonpublish.yml delete mode 100644 .github/workflows/test.yml create mode 100644 prek.toml delete mode 100644 requirements-test.txt delete mode 100644 requirements.txt delete mode 100644 setup.cfg diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..1125b0e --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,3 @@ +python-otbr-api is a Python library exposing a typed async API to interact with an Open Thread Border Router (OTBR) over its REST API. + +- `prek run` is used to check the code formatting and style. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..b637e51 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,191 @@ +name: CI + +on: + push: + pull_request: ~ + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +permissions: + contents: read + +jobs: + prepare-base: + name: Prepare base dependencies + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.11", "3.12", "3.13", "3.14"] + steps: + - name: Check out code from GitHub + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false + - name: Set up Python ${{ matrix.python-version }} + id: python + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 + with: + python-version: ${{ matrix.python-version }} + allow-prereleases: true + check-latest: true + - name: Restore base Python virtual environment + id: cache-venv + uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + path: venv + key: >- + 1-${{ runner.os }}-base-venv-${{ steps.python.outputs.python-version }}-${{ hashFiles('pyproject.toml') }} + - name: Create Python virtual environment + if: steps.cache-venv.outputs.cache-hit != 'true' + run: | + curl -LsSf https://astral.sh/uv/install.sh | sh + uv venv venv + . venv/bin/activate + uv pip install -U pip setuptools prek + uv pip install -e .[dev] + - name: Cache base Python virtual environment + if: steps.cache-venv.outputs.cache-hit != 'true' + uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + path: venv + key: ${{ steps.cache-venv.outputs.cache-primary-key }} + + prek: + name: Run prek + runs-on: ubuntu-latest + needs: prepare-base + steps: + - name: Check out code from GitHub + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false + - name: Set up Python 3.11 + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 + id: python + with: + python-version: "3.11" + check-latest: true + - name: Restore base Python virtual environment + id: cache-venv + uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + fail-on-cache-miss: true + path: venv + key: >- + 1-${{ runner.os }}-base-venv-${{ steps.python.outputs.python-version }}-${{ hashFiles('pyproject.toml') }} + - name: Restore prek environment from cache + id: cache-prek + uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + path: ~/.cache/prek + key: | + 1-${{ runner.os }}-prek-${{ hashFiles('prek.toml') }} + - name: Install prek hooks + if: steps.cache-prek.outputs.cache-hit != 'true' + run: | + . venv/bin/activate + prek prepare-hooks + - name: Cache prek environment + if: steps.cache-prek.outputs.cache-hit != 'true' + uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + path: ~/.cache/prek + key: ${{ steps.cache-prek.outputs.cache-primary-key }} + - name: Lint and static analysis + run: | + . venv/bin/activate + prek run --show-diff-on-failure --color=always --all-files + + pytest: + runs-on: ubuntu-latest + needs: prepare-base + strategy: + matrix: + python-version: ["3.11", "3.12", "3.13", "3.14"] + name: Run tests Python ${{ matrix.python-version }} + steps: + - name: Check out code from GitHub + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 + id: python + with: + python-version: ${{ matrix.python-version }} + allow-prereleases: true + check-latest: true + - name: Restore base Python virtual environment + id: cache-venv + uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + fail-on-cache-miss: true + path: venv + key: >- + 1-${{ runner.os }}-base-venv-${{ steps.python.outputs.python-version }}-${{ hashFiles('pyproject.toml') }} + - name: Set up uv + run: | + curl -LsSf https://astral.sh/uv/install.sh | sh + - name: Register Python problem matcher + run: | + echo "::add-matcher::.github/workflows/matchers/python.json" + - name: Install Pytest Annotation plugin + run: | + . venv/bin/activate + uv pip install pytest-github-actions-annotate-failures pytest-cov + - name: Run pytest + run: | + . venv/bin/activate + pytest \ + -qq \ + --durations=10 \ + --cov=python_otbr_api \ + -o console_output_style=count \ + tests + - name: Upload coverage artifact + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: coverage-${{ matrix.python-version }} + include-hidden-files: true + path: .coverage + + coverage: + name: Process test coverage + runs-on: ubuntu-latest + needs: pytest + steps: + - name: Check out code from GitHub + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false + - name: Set up Python 3.11 + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 + id: python + with: + python-version: "3.11" + check-latest: true + - name: Restore base Python virtual environment + id: cache-venv + uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + fail-on-cache-miss: true + path: venv + key: >- + 1-${{ runner.os }}-base-venv-${{ steps.python.outputs.python-version }}-${{ hashFiles('pyproject.toml') }} + - name: Download all coverage artifacts + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + - name: Combine coverage results + run: | + . venv/bin/activate + pip install coverage + coverage combine coverage*/.coverage* + coverage report + coverage xml + - name: Upload coverage to Codecov + if: ${{ github.event.repository.fork == false }} + uses: codecov/codecov-action@75cd11691c0faa626561e295848008c8a7dddffe # v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} + fail_ci_if_error: false diff --git a/.github/workflows/matchers/python.json b/.github/workflows/matchers/python.json new file mode 100644 index 0000000..3e5d8d5 --- /dev/null +++ b/.github/workflows/matchers/python.json @@ -0,0 +1,18 @@ +{ + "problemMatcher": [ + { + "owner": "python", + "pattern": [ + { + "regexp": "^\\s*File\\s\\\"(.*)\\\",\\sline\\s(\\d+),\\sin\\s(.*)$", + "file": 1, + "line": 2 + }, + { + "regexp": "^\\s*raise\\s(.*)\\(\\'(.*)\\'\\)$", + "message": 2 + } + ] + } + ] +} diff --git a/.github/workflows/publish-to-pypi.yml b/.github/workflows/publish-to-pypi.yml new file mode 100644 index 0000000..ea05991 --- /dev/null +++ b/.github/workflows/publish-to-pypi.yml @@ -0,0 +1,64 @@ +name: Build and Publish to PyPI + +on: + release: + types: + - published + workflow_dispatch: + push: + paths: + - ".github/workflows/publish-to-pypi.yml" + pull_request: + paths: + - ".github/workflows/publish-to-pypi.yml" + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +permissions: + contents: read + +jobs: + build: + name: Build sdist and wheel + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 + with: + python-version: "3.11" + + - name: Install build tools + run: pip install build + + - name: Build sdist and wheel + run: python -m build + + - name: Upload distributions + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: dist + path: dist/* + + publish: + name: Publish python-otbr-api to PyPI + needs: build + runs-on: ubuntu-latest + if: github.event_name == 'release' + permissions: + id-token: write # required for PyPI trusted publishing (OIDC) + steps: + - name: Download distributions + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: dist + path: dist + + - name: Publish python-otbr-api to PyPI + uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # release/v1 diff --git a/.github/workflows/pythonpublish.yml b/.github/workflows/pythonpublish.yml deleted file mode 100644 index ddb0e02..0000000 --- a/.github/workflows/pythonpublish.yml +++ /dev/null @@ -1,30 +0,0 @@ -# This workflows will upload a Python Package using Twine when a release is created -# For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries - -name: Upload Python Package - -on: - release: - types: - - published - -jobs: - deploy: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6.0.2 - - name: Set up Python - uses: actions/setup-python@v6.2.0 - with: - python-version: '3.x' - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install build wheel twine - - name: Build and publish - env: - TWINE_USERNAME: __token__ - TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }} - run: | - python -m build - twine upload dist/* diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml deleted file mode 100644 index 340c162..0000000 --- a/.github/workflows/test.yml +++ /dev/null @@ -1,41 +0,0 @@ -# This workflow will install Python dependencies, run tests and lint with a single version of Python -# For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions - -name: Run Tests - -on: - push: - branches: - - main - pull_request: - branches: - - main - -jobs: - build: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6.0.2 - - name: Set up Python 3.11 - uses: actions/setup-python@v6.2.0 - with: - python-version: 3.11 - - name: Install dependencies - run: | - pip install -r requirements.txt - pip install -r requirements-test.txt - - name: Lint with flake8 - run: | - flake8 python_otbr_api tests - - name: Check formatting with black - run: | - black python_otbr_api tests --check --diff - - name: Lint with mypy - run: | - mypy python_otbr_api tests - - name: Lint with pylint - run: | - pylint python_otbr_api tests - - name: Run tests - run: | - pytest tests diff --git a/.gitignore b/.gitignore index 4c5fa89..ff75c61 100644 --- a/.gitignore +++ b/.gitignore @@ -5,15 +5,23 @@ tmp/ *.py[cod] *.egg -htmlcov +htmlcov/ +.coverage +coverage.xml .projectile .venv/ venv/ .mypy_cache/ +.pytest_cache/ +.ruff_cache/ *.egg-info/ # Visual Studio Code .vscode/* -dist +build/ +dist/ + +# Claude Code one-off scripts +claude/ diff --git a/prek.toml b/prek.toml new file mode 100644 index 0000000..4b2e8f1 --- /dev/null +++ b/prek.toml @@ -0,0 +1,53 @@ +# Configuration file for `prek`, a git hook framework written in Rust. +# See https://prek.j178.dev for more information. +#:schema https://www.schemastore.org/prek.json + +[[repos]] +repo = "https://github.com/codespell-project/codespell" +rev = "v2.4.2" + +[[repos.hooks]] +id = "codespell" +additional_dependencies = ["tomli"] +args = ["--toml", "pyproject.toml"] + +[[repos]] +repo = "https://github.com/pre-commit/mirrors-mypy" +rev = "v1.20.2" + +[[repos.hooks]] +id = "mypy" + +[[repos]] +repo = "https://github.com/astral-sh/ruff-pre-commit" +rev = "v0.15.12" + +[[repos.hooks]] +id = "ruff-check" +args = ["--fix"] + +[[repos.hooks]] +id = "ruff-format" + +[[repos]] +repo = "https://github.com/google/yamlfmt" +rev = "v0.21.0" + +[[repos.hooks]] +id = "yamlfmt" +args = ["-formatter", "retain_line_breaks=true"] + +[[repos]] +repo = "https://github.com/ComPWA/taplo-pre-commit" +rev = "v0.9.3" + +[[repos.hooks]] +id = "taplo-format" + +[[repos]] +repo = "https://github.com/zizmorcore/zizmor-pre-commit" +rev = "v1.24.1" + +[[repos.hooks]] +id = "zizmor" +args = ["--pedantic"] diff --git a/pyproject.toml b/pyproject.toml index 1c494e9..37f7c3d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,39 +3,32 @@ requires = ["setuptools>=65.6"] build-backend = "setuptools.build_meta" [project] -name = "python-otbr-api" -version = "2.10.0" -license = {text = "MIT"} -description = "API to interact with an OTBR via its REST API" -readme = "README.md" -authors = [ - {name = "The Home Assistant Authors", email = "hello@home-assistant.io"} +name = "python-otbr-api" +version = "2.10.0" +license = { text = "MIT" } +description = "API to interact with an OTBR via its REST API" +readme = "README.md" +authors = [ + { name = "The Home Assistant Authors", email = "hello@home-assistant.io" }, ] requires-python = ">=3.11.0" dependencies = [ - "aiohttp", - "bitstruct", - "cryptography", - "typing_extensions", - "voluptuous", + "aiohttp", + "bitstruct", + "cryptography", + "typing_extensions", + "voluptuous", ] [project.urls] -"Homepage" = "https://github.com/home-assistant-libs/python-otbr-api" +Homepage = "https://github.com/home-assistant-libs/python-otbr-api" -[tool.pylint.BASIC] -class-const-naming-style = "any" -good-names = [ - "c", - "i", -] - -[tool.pytest.ini_options] -asyncio_mode = "auto" +[project.optional-dependencies] +dev = ["mypy", "prek", "pytest", "pytest-asyncio", "ruff"] [tool.setuptools] platforms = ["any"] -zip-safe = true +zip-safe = true include-package-data = true [tool.setuptools.packages.find] @@ -43,3 +36,246 @@ include = ["python_otbr_api*"] [tool.setuptools.package-data] "*" = ["py.typed"] + +[tool.pytest.ini_options] +addopts = "--showlocals --verbose" +testpaths = ["tests"] +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" +log_format = "%(asctime)s.%(msecs)03d %(levelname)s %(message)s" +log_date_format = "%Y-%m-%d %H:%M:%S" + +[tool.mypy] +python_version = "3.11" +strict = true +check_untyped_defs = true +show_error_codes = true +show_error_context = true +warn_unreachable = true +disallow_any_expr = false +disallow_any_decorated = false +enable_error_code = [ + "abstract", + "annotation-unchecked", + "arg-type", + "assert-type", + "assignment", + "attr-defined", + "await-not-async", + "call-arg", + "call-overload", + "comparison-overlap", + "deprecated", + "dict-item", + "empty-body", + "exhaustive-match", + "exit-return", + "func-returns-value", + "has-type", + "ignore-without-code", + "import", + "import-not-found", + "import-untyped", + "index", + "list-item", + "literal-required", + "maybe-unrecognized-str-typeform", + "metaclass", + "method-assign", + "name-defined", + "name-match", + "narrowed-type-not-subtype", + "no-overload-impl", + "no-redef", + "no-untyped-call", + "no-untyped-def", + "operator", + "overload-cannot-match", + "overload-overlap", + "override", + "possibly-undefined", + "prop-decorator", + "redundant-cast", + "redundant-expr", + "redundant-self", + "return", + "return-value", + "safe-super", + "str-bytes-safe", + "str-format", + "syntax", + "top-level-await", + "truthy-bool", + "truthy-function", + "truthy-iterable", + "type-abstract", + "type-arg", + "type-var", + "typeddict-item", + "typeddict-readonly-mutated", + "typeddict-unknown-key", + "unimported-reveal", + "union-attr", + "unreachable", + "untyped-decorator", + "unused-awaitable", + "unused-coroutine", + "unused-ignore", + "used-before-def", + "valid-newtype", + "valid-type", + "var-annotated", +] + +[[tool.mypy.overrides]] +module = ["tests.*"] +disable_error_code = [ + "abstract", + "no-untyped-call", + "no-untyped-def", + "redundant-expr", + "unreachable", + "untyped-decorator", +] + +[tool.coverage.run] +source = ["python_otbr_api"] +relative_files = true + +[tool.coverage.paths] +source = ["python_otbr_api/", "python_otbr_api\\"] + +[tool.coverage.report] +show_missing = true +exclude_also = ["if TYPE_CHECKING:", "raise NotImplementedError"] + +[tool.ruff] +target-version = "py311" + +[tool.ruff.lint] +select = [ + "B002", # Python does not support the unary prefix increment + "B007", # Loop control variable {name} not used within loop body + "B014", # Exception handler with duplicate exception + "B023", # Function definition does not bind loop variable {name} + "B026", # Star-arg unpacking after a keyword argument is strongly discouraged + "B904", # Use raise from to specify exception cause + "C", # complexity + "COM818", # Trailing comma on bare tuple prohibited + "D", # docstrings + "DTZ003", # Use datetime.now(tz=) instead of datetime.utcnow() + "DTZ004", # Use datetime.fromtimestamp(ts, tz=) instead of datetime.utcfromtimestamp(ts) + "E", # pycodestyle + "F", # pyflakes/autoflake + "G", # flake8-logging-format + "I", # isort + "ICN001", # import conventions; {name} should be imported as {asname} + "N804", # First argument of a class method should be named cls + "N805", # First argument of a method should be named self + "N815", # Variable {name} in class scope should not be mixedCase + "PERF101", # Do not cast an iterable to list before iterating over it + "PERF102", # When using only the {subset} of a dict use the {subset}() method + "PERF203", # try-except within a loop incurs performance overhead + "PGH004", # Use specific rule codes when using noqa + "PLC0414", # Useless import alias. Import alias does not rename original package. + "PLC", # pylint + "PLE", # pylint + "PLR", # pylint + "PLW", # pylint + "Q000", # Double quotes found but single quotes preferred + "RUF006", # Store a reference to the return value of asyncio.create_task + "S102", # Use of exec detected + "S103", # bad-file-permissions + "S108", # hardcoded-temp-file + "S306", # suspicious-mktemp-usage + "S307", # suspicious-eval-usage + "S313", # suspicious-xmlc-element-tree-usage + "S314", # suspicious-xml-element-tree-usage + "S315", # suspicious-xml-expat-reader-usage + "S316", # suspicious-xml-expat-builder-usage + "S317", # suspicious-xml-sax-usage + "S318", # suspicious-xml-mini-dom-usage + "S319", # suspicious-xml-pull-dom-usage + "S601", # paramiko-call + "S602", # subprocess-popen-with-shell-equals-true + "S604", # call-with-shell-equals-true + "S608", # hardcoded-sql-expression + "S609", # unix-command-wildcard-injection + "SIM", # flake8-simplify + "T100", # Trace found: {name} used + "T20", # flake8-print + "TID251", # Banned imports + "TRY", # tryceratops + "UP", # pyupgrade + "W", # pycodestyle +] + +ignore = [ + "D202", # No blank lines allowed after function docstring + "D203", # 1 blank line required before class docstring + "D213", # Multi-line docstring summary should start at the second line + "D406", # Section name should end with a newline + "D407", # Section name underlining + "E501", # line too long + "E731", # do not assign a lambda expression, use a def + + # Ignore ignored, as the rule is now back in preview/nursery, which cannot + # be ignored anymore without warnings. + # https://github.com/astral-sh/ruff/issues/7491 + # "PLC1901", # Lots of false positives + + # False positives https://github.com/astral-sh/ruff/issues/5386 + "PLC0208", # Use a sequence type instead of a `set` when iterating over values + "PLR0911", # Too many return statements ({returns} > {max_returns}) + "PLR0912", # Too many branches ({branches} > {max_branches}) + "PLR0913", # Too many arguments to function call ({c_args} > {max_args}) + "PLR0915", # Too many statements ({statements} > {max_statements}) + "PLR2004", # Magic value used in comparison, consider replacing {value} with a constant variable + "PLW2901", # Outer {outer_kind} variable {name} overwritten by inner {inner_kind} target + "UP006", # keep type annotation style as is + "UP007", # keep type annotation style as is + + # Ignored due to incompatible with mypy: https://github.com/python/mypy/issues/15238 + "UP040", # Checks for use of TypeAlias annotation for declaring type aliases. + + # May conflict with the formatter, https://docs.astral.sh/ruff/formatter/#conflicting-lint-rules + "W191", + "E111", + "E114", + "E117", + "D206", + "D300", + "Q000", + "Q", + "COM812", + "COM819", + "ISC001", + "ISC002", + + # Disabled because ruff does not understand type of __all__ generated by a function + "PLE0605", + "TRY003", + "TRY201", + "TRY300", + + "SIM103", # Return the condition {condition} directly +] + +extend-safe-fixes = ["F401"] + +[tool.ruff.lint.flake8-pytest-style] +fixture-parentheses = false + +[tool.ruff.lint.flake8-tidy-imports] +ban-relative-imports = "all" + +[tool.ruff.lint.isort] +force-sort-within-sections = true +known-first-party = ["python_otbr_api", "tests"] +combine-as-imports = true +split-on-trailing-comma = false + +[tool.ruff.lint.mccabe] +max-complexity = 25 + +[tool.codespell] diff --git a/requirements-test.txt b/requirements-test.txt deleted file mode 100644 index f2ab1b9..0000000 --- a/requirements-test.txt +++ /dev/null @@ -1,6 +0,0 @@ -black==26.3.1 -flake8==7.3.0 -mypy==1.20.2 -pylint==4.0.5 -pytest-asyncio==1.3.0 -pytest==9.0.3 diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index fddfe1a..0000000 --- a/requirements.txt +++ /dev/null @@ -1,5 +0,0 @@ -aiohttp -bitstruct -cryptography -typing_extensions -voluptuous diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index dc112aa..0000000 --- a/setup.cfg +++ /dev/null @@ -1,5 +0,0 @@ -[flake8] -# To work with Black -max-line-length = 88 -# E203: Whitespace before ':' -extend-ignore = E203 From 517c28f28298282705883a0b56b7bc2173836b50 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Wed, 29 Apr 2026 18:35:32 -0400 Subject: [PATCH 2/5] WIP: Run prek --- python_otbr_api/__init__.py | 2 +- python_otbr_api/mdns.py | 2 +- python_otbr_api/pskc.py | 2 +- python_otbr_api/tlv_parser.py | 2 +- tests/test_init.py | 5 +++-- tests/test_init_legacy.py | 2 +- tests/test_tlv_parser.py | 17 ++++++++--------- 7 files changed, 16 insertions(+), 16 deletions(-) diff --git a/python_otbr_api/__init__.py b/python_otbr_api/__init__.py index 9188c89..72f9028 100644 --- a/python_otbr_api/__init__.py +++ b/python_otbr_api/__init__.py @@ -4,9 +4,9 @@ from enum import Enum from http import HTTPStatus -from typing import Any import json import logging +from typing import Any import aiohttp import voluptuous as vol # type: ignore[import] diff --git a/python_otbr_api/mdns.py b/python_otbr_api/mdns.py index 9011150..132256c 100644 --- a/python_otbr_api/mdns.py +++ b/python_otbr_api/mdns.py @@ -6,9 +6,9 @@ from dataclasses import dataclass from enum import IntEnum +from typing import Self import bitstruct # type: ignore[import] -from typing_extensions import Self class ConnectionMode(IntEnum): diff --git a/python_otbr_api/pskc.py b/python_otbr_api/pskc.py index d2f5450..72db79e 100644 --- a/python_otbr_api/pskc.py +++ b/python_otbr_api/pskc.py @@ -11,7 +11,7 @@ AES_128_KEY_LEN = 16 ITERATION_COUNTS = 16384 BLKSIZE = 16 -SALT_PREFIX = "Thread".encode() +SALT_PREFIX = b"Thread" def _derive_key(passphrase: str) -> bytes: diff --git a/python_otbr_api/tlv_parser.py b/python_otbr_api/tlv_parser.py index a213679..e95a594 100644 --- a/python_otbr_api/tlv_parser.py +++ b/python_otbr_api/tlv_parser.py @@ -4,8 +4,8 @@ from dataclasses import dataclass, field from enum import IntEnum -import struct import logging +import struct _LOGGER = logging.getLogger(__name__) diff --git a/tests/test_init.py b/tests/test_init.py index a44a85e..40e8054 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -4,9 +4,9 @@ from typing import Any import pytest + import python_otbr_api from python_otbr_api import KeyFormat - from tests.test_util.aiohttp import AiohttpClientMocker BASE_URL = "http://core-openthread-border-router:8081" @@ -132,7 +132,8 @@ async def test_camel_read_with_pascal_straggler( ) -> None: """CAMEL_CASE reads are always normalized, so Sept-2025..Apr-2026 OTBR builds that still emit pascal-case stragglers (e.g. `Routers`) parse - cleanly instead of blowing up schema validation.""" + cleanly instead of blowing up schema validation. + """ otbr = python_otbr_api.OTBR( BASE_URL, aioclient_mock.create_session(), key_format=KeyFormat.CAMEL_CASE ) diff --git a/tests/test_init_legacy.py b/tests/test_init_legacy.py index 5047dea..889768d 100644 --- a/tests/test_init_legacy.py +++ b/tests/test_init_legacy.py @@ -4,9 +4,9 @@ from typing import Any import pytest + import python_otbr_api from python_otbr_api import KeyFormat - from tests.test_util.aiohttp import AiohttpClientMocker BASE_URL = "http://core-silabs-multiprotocol:8081" diff --git a/tests/test_tlv_parser.py b/tests/test_tlv_parser.py index 9aeb3e9..8d8b6a6 100644 --- a/tests/test_tlv_parser.py +++ b/tests/test_tlv_parser.py @@ -3,11 +3,11 @@ import pytest from python_otbr_api.tlv_parser import ( - Timestamp, Channel, MeshcopTLVItem, MeshcopTLVType, NetworkName, + Timestamp, TLVError, encode_tlv, parse_tlv, @@ -19,10 +19,10 @@ MeshcopTLVType.DURATION, bytes.fromhex("05") ), MeshcopTLVType.PROVISIONING_URL: MeshcopTLVItem( - MeshcopTLVType.PROVISIONING_URL, "test".encode() + MeshcopTLVType.PROVISIONING_URL, b"test" ), MeshcopTLVType.VENDOR_NAME_TLV: MeshcopTLVItem( - MeshcopTLVType.VENDOR_NAME_TLV, "ACME".encode() + MeshcopTLVType.VENDOR_NAME_TLV, b"ACME" ), MeshcopTLVType.UDP_ENCAPSULATION_TLV: MeshcopTLVItem( MeshcopTLVType.UDP_ENCAPSULATION_TLV, bytes.fromhex("beef") @@ -46,7 +46,7 @@ MeshcopTLVType.ENERGY_LIST, bytes.fromhex("010203") ), MeshcopTLVType.THREAD_DOMAIN_NAME: MeshcopTLVItem( - MeshcopTLVType.THREAD_DOMAIN_NAME, "home".encode() + MeshcopTLVType.THREAD_DOMAIN_NAME, b"home" ), MeshcopTLVType.DISCOVERYREQUEST: MeshcopTLVItem( MeshcopTLVType.DISCOVERYREQUEST, bytes.fromhex("00") @@ -89,7 +89,7 @@ def test_encode_tlv() -> None: MeshcopTLVType.NETWORKKEY, bytes.fromhex("00112233445566778899aabbccddeeff") ), MeshcopTLVType.NETWORKNAME: NetworkName( - MeshcopTLVType.NETWORKNAME, "OpenThreadDemo".encode() + MeshcopTLVType.NETWORKNAME, b"OpenThreadDemo" ), MeshcopTLVType.PANID: MeshcopTLVItem( MeshcopTLVType.PANID, bytes.fromhex("1234") @@ -135,7 +135,7 @@ def test_parse_tlv() -> None: MeshcopTLVType.EXTPANID, bytes.fromhex("1111111122222222") ), MeshcopTLVType.NETWORKNAME: NetworkName( - MeshcopTLVType.NETWORKNAME, "OpenThreadDemo".encode() + MeshcopTLVType.NETWORKNAME, b"OpenThreadDemo" ), MeshcopTLVType.PSKC: MeshcopTLVItem( MeshcopTLVType.PSKC, bytes.fromhex("445f2b5ca6f2a93a55ce570a70efeecb") @@ -183,7 +183,7 @@ def test_parse_tlv_with_wakeup_channel() -> None: MeshcopTLVType.CHANNELMASK, bytes.fromhex("0004001fffc0") ), MeshcopTLVType.NETWORKNAME: NetworkName( - MeshcopTLVType.NETWORKNAME, "MyHome1231231234".encode() + MeshcopTLVType.NETWORKNAME, b"MyHome1231231234" ), } @@ -225,8 +225,7 @@ def test_parse_tlv_error(tlv, error, msg) -> None: def test_timestamp_parsing_full_integrity() -> None: - """ - Test parsing of a timestamp with mixed values for seconds, ticks, and authoritative. + """Test parsing of a timestamp with mixed values for seconds, ticks, and authoritative. We construct a value to ensure no bit overlap: - Seconds: 400 (0x190) From bd8b0aa45ef5b3c71b7eb2a7de48139d3adfbea4 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Wed, 29 Apr 2026 18:45:45 -0400 Subject: [PATCH 3/5] Fix mypy --- python_otbr_api/__init__.py | 36 +++++++++++++++++++++++++---------- python_otbr_api/mdns.py | 2 +- python_otbr_api/models.py | 10 +++++----- python_otbr_api/pskc.py | 5 +++-- python_otbr_api/tlv_parser.py | 1 + tests/test_init.py | 7 ++++--- tests/test_models.py | 1 + 7 files changed, 41 insertions(+), 21 deletions(-) diff --git a/python_otbr_api/__init__.py b/python_otbr_api/__init__.py index 72f9028..5d1cc9d 100644 --- a/python_otbr_api/__init__.py +++ b/python_otbr_api/__init__.py @@ -6,12 +6,26 @@ from http import HTTPStatus import json import logging -from typing import Any +from typing import Any, TypeVar, cast import aiohttp -import voluptuous as vol # type: ignore[import] - -from .models import ActiveDataSet, PendingDataSet, Timestamp +import voluptuous as vol + +from .models import ActiveDataSet, PendingDataSet, SecurityPolicy, Timestamp + +__all__ = [ + "ActiveDataSet", + "FactoryResetNotSupportedError", + "GetBorderAgentIdNotSupportedError", + "KeyFormat", + "OTBR", + "OTBRError", + "PENDING_DATASET_DELAY_TIMER", + "PendingDataSet", + "SecurityPolicy", + "ThreadNetworkActiveError", + "Timestamp", +] # 5 minutes as recommended by # https://github.com/openthread/openthread/discussions/8567#discussioncomment-4468920 @@ -56,6 +70,8 @@ } _PASCAL_TO_CAMEL: dict[str, str] = {v: k for k, v in _CAMEL_TO_PASCAL.items()} +_RewriteT = TypeVar("_RewriteT", dict[str, Any], list[Any], str, int, float, bool, None) + class KeyFormat(Enum): """JSON key format used by the OTBR REST API. @@ -88,7 +104,7 @@ class ThreadNetworkActiveError(OTBRError): """Raised on attempts to modify the active dataset when thread network is active.""" -def _rewrite_keys(data: Any, mapping: dict[str, str]) -> Any: +def _rewrite_keys(data: _RewriteT, mapping: dict[str, str]) -> _RewriteT: """Recursively rename dict keys according to mapping; pass through others.""" if not isinstance(data, dict): return data @@ -134,13 +150,13 @@ async def _maybe_detect_key_format(self) -> None: _LOGGER.debug("Detected OTBR JSON key format: %s", self._key_format) - def _encode(self, data: dict) -> dict: + def _encode(self, data: dict[str, Any]) -> dict[str, Any]: """Rewrite a camelCase body to the detected wire format.""" if self._key_format == KeyFormat.PASCAL_CASE: return _rewrite_keys(data, _CAMEL_TO_PASCAL) return data - def _decode(self, data: dict) -> dict: + def _decode(self, data: dict[str, Any]) -> dict[str, Any]: """Normalize a wire response body to camelCase.""" # Runs unconditionally: camelCase keys aren't in the table so they pass through @@ -149,7 +165,7 @@ def _decode(self, data: dict) -> dict: return _rewrite_keys(data, _PASCAL_TO_CAMEL) async def factory_reset(self) -> None: - """Factory reset the router.""" + """Reset the router to factory defaults.""" await self._maybe_detect_key_format() response = await self._session.delete( f"{self._url}/node", @@ -349,7 +365,7 @@ async def set_active_dataset_tlvs(self, dataset: bytes) -> None: async def set_channel( self, channel: int, delay: int = PENDING_DATASET_DELAY_TIMER ) -> None: - """Change the channel + """Change the channel. The channel is changed by creating a new pending dataset based on the active dataset. @@ -404,6 +420,6 @@ async def get_coprocessor_version(self) -> str: raise OTBRError(f"unexpected http status {response.status}") try: - return await response.json() + return cast(str, await response.json()) except ValueError as exc: raise OTBRError("unexpected API response") from exc diff --git a/python_otbr_api/mdns.py b/python_otbr_api/mdns.py index 132256c..c333876 100644 --- a/python_otbr_api/mdns.py +++ b/python_otbr_api/mdns.py @@ -8,7 +8,7 @@ from enum import IntEnum from typing import Self -import bitstruct # type: ignore[import] +import bitstruct class ConnectionMode(IntEnum): diff --git a/python_otbr_api/models.py b/python_otbr_api/models.py index e0d9874..e6875b7 100644 --- a/python_otbr_api/models.py +++ b/python_otbr_api/models.py @@ -5,7 +5,7 @@ from dataclasses import dataclass from typing import Any -import voluptuous as vol # type: ignore[import] +import voluptuous as vol @dataclass @@ -24,7 +24,7 @@ class Timestamp: seconds: int | None = None ticks: int | None = None - def as_json(self) -> dict: + def as_json(self) -> dict[str, Any]: """Serialize to JSON.""" result: dict[str, Any] = {} if self.authoritative is not None: @@ -76,7 +76,7 @@ class SecurityPolicy: # pylint: disable=too-many-instance-attributes routers: bool | None = None to_ble_link: bool | None = None - def as_json(self) -> dict: + def as_json(self) -> dict[str, Any]: """Serialize to JSON.""" result: dict[str, Any] = {} if self.autonomous_enrollment is not None: @@ -149,7 +149,7 @@ class ActiveDataSet: # pylint: disable=too-many-instance-attributes psk_c: str | None = None security_policy: SecurityPolicy | None = None - def as_json(self) -> dict: + def as_json(self) -> dict[str, Any]: """Serialize to JSON.""" result: dict[str, Any] = {} if self.active_timestamp is not None: @@ -215,7 +215,7 @@ class PendingDataSet: # pylint: disable=too-many-instance-attributes delay: int | None = None pending_timestamp: Timestamp | None = None - def as_json(self) -> dict: + def as_json(self) -> dict[str, Any]: """Serialize to JSON.""" result: dict[str, Any] = {} if self.active_dataset is not None: diff --git a/python_otbr_api/pskc.py b/python_otbr_api/pskc.py index 72db79e..31bbf32 100644 --- a/python_otbr_api/pskc.py +++ b/python_otbr_api/pskc.py @@ -4,6 +4,7 @@ """ import struct +from typing import cast from cryptography.hazmat.primitives import cmac from cryptography.hazmat.primitives.ciphers import algorithms @@ -21,7 +22,7 @@ def _derive_key(passphrase: str) -> bytes: return passphrase_bytes c = cmac.CMAC(algorithms.AES128(b"\0" * AES_128_KEY_LEN)) c.update(passphrase_bytes) - return c.finalize() + return cast(bytes, c.finalize()) def compute_pskc(ext_pan_id: bytes, network_name: str, passphrase: str) -> bytes: @@ -50,4 +51,4 @@ def compute_pskc(ext_pan_id: bytes, network_name: str, passphrase: str) -> bytes for i in range(BLKSIZE): pskc[i] ^= prf_output[i] - return pskc + return bytes(pskc) diff --git a/python_otbr_api/tlv_parser.py b/python_otbr_api/tlv_parser.py index e95a594..b597a63 100644 --- a/python_otbr_api/tlv_parser.py +++ b/python_otbr_api/tlv_parser.py @@ -101,6 +101,7 @@ def __post_init__(self) -> None: raise TLVError(f"invalid network name '{self.data.hex()}'") from err def __str__(self) -> str: + """Return the network name.""" return self.name diff --git a/tests/test_init.py b/tests/test_init.py index 40e8054..f65b53e 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -130,9 +130,10 @@ async def test_get_active_dataset_camel(aioclient_mock: AiohttpClientMocker) -> async def test_camel_read_with_pascal_straggler( aioclient_mock: AiohttpClientMocker, ) -> None: - """CAMEL_CASE reads are always normalized, so Sept-2025..Apr-2026 OTBR - builds that still emit pascal-case stragglers (e.g. `Routers`) parse - cleanly instead of blowing up schema validation. + """Test that CAMEL_CASE reads normalize pascal-case stragglers. + + Sept-2025..Apr-2026 OTBR builds emit pascal-case stragglers (e.g. `Routers`) + that should parse cleanly instead of blowing up schema validation. """ otbr = python_otbr_api.OTBR( BASE_URL, aioclient_mock.create_session(), key_format=KeyFormat.CAMEL_CASE diff --git a/tests/test_models.py b/tests/test_models.py index fa31f7a..671cb9b 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -28,6 +28,7 @@ def test_active_dataset_from_json(): assert dataset.active_timestamp == python_otbr_api.Timestamp( authoritative=False, seconds=1, ticks=0 ) + assert dataset.security_policy is not None assert dataset.security_policy.rotation_time == 672 assert dataset.as_json() == ACTIVE_DATASET_CAMEL From 9778e0a983d1d284c32ab1f5b3e34f02585eadd4 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Wed, 29 Apr 2026 18:48:44 -0400 Subject: [PATCH 4/5] Fix zizimor --- .github/dependabot.yml | 4 ++++ .github/workflows/release-drafter.yml | 13 ++++++++++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 74e05b8..169c392 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -5,8 +5,12 @@ updates: schedule: interval: daily open-pull-requests-limit: 10 + cooldown: + default-days: 7 - package-ecosystem: pip directory: "/" schedule: interval: weekly open-pull-requests-limit: 10 + cooldown: + default-days: 7 diff --git a/.github/workflows/release-drafter.yml b/.github/workflows/release-drafter.yml index bec74e5..f79dac6 100644 --- a/.github/workflows/release-drafter.yml +++ b/.github/workflows/release-drafter.yml @@ -5,11 +5,22 @@ on: branches: - main +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +permissions: + contents: read + jobs: update_release_draft: + name: Update release draft runs-on: ubuntu-latest + permissions: + contents: write # release-drafter writes the draft release + pull-requests: write # release-drafter labels merged PRs steps: # Drafts your next Release notes as Pull Requests are merged into "main" - - uses: release-drafter/release-drafter@v7.2.1 + - uses: release-drafter/release-drafter@563bf132657a13ded0b01fcb723c5a58cdd824e2 # v7.2.1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} From 77b793973be650bb47b4b440d402e7569971fe7c Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Wed, 29 Apr 2026 18:58:16 -0400 Subject: [PATCH 5/5] Simplify CI --- .github/workflows/ci.yml | 4 ++-- .github/workflows/publish-to-pypi.yml | 27 +++++---------------------- 2 files changed, 7 insertions(+), 24 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b637e51..c9488d7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,7 +17,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.11", "3.12", "3.13", "3.14"] + python-version: ["3.14"] steps: - name: Check out code from GitHub uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 @@ -103,7 +103,7 @@ jobs: needs: prepare-base strategy: matrix: - python-version: ["3.11", "3.12", "3.13", "3.14"] + python-version: ["3.14"] name: Run tests Python ${{ matrix.python-version }} steps: - name: Check out code from GitHub diff --git a/.github/workflows/publish-to-pypi.yml b/.github/workflows/publish-to-pypi.yml index ea05991..448240a 100644 --- a/.github/workflows/publish-to-pypi.yml +++ b/.github/workflows/publish-to-pypi.yml @@ -20,9 +20,11 @@ permissions: contents: read jobs: - build: - name: Build sdist and wheel + publish: + name: Build and publish python-otbr-api runs-on: ubuntu-latest + permissions: + id-token: write # required for PyPI trusted publishing (OIDC) steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: @@ -40,25 +42,6 @@ jobs: - name: Build sdist and wheel run: python -m build - - name: Upload distributions - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 - with: - name: dist - path: dist/* - - publish: - name: Publish python-otbr-api to PyPI - needs: build - runs-on: ubuntu-latest - if: github.event_name == 'release' - permissions: - id-token: write # required for PyPI trusted publishing (OIDC) - steps: - - name: Download distributions - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 - with: - name: dist - path: dist - - name: Publish python-otbr-api to PyPI + if: github.event_name == 'release' uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # release/v1