diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..eda3c92 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,21 @@ +name: CI +on: + workflow_dispatch: + push: + pull_request: + +jobs: + mypy-test: + name: mypy test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: "3.x" + - name: Install dependencies + run: | + pip install -r requirements.txt + pip install mypy + - name: Run mypy test + run: mypy -p pyrympro diff --git a/.github/workflows/pr-build.yml b/.github/workflows/pr-build.yml new file mode 100644 index 0000000..53e70cf --- /dev/null +++ b/.github/workflows/pr-build.yml @@ -0,0 +1,41 @@ +name: PR Build + +on: + pull_request: + types: [opened, synchronize, reopened] + +concurrency: + group: pr-build-${{ github.event.pull_request.number }} + cancel-in-progress: true + +permissions: + contents: read + actions: write + +jobs: + build: + name: Build PR wheel + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: "3.x" + + - run: pip install build + + - name: Build wheel + run: python -m build --wheel + env: + SETUPTOOLS_SCM_PRETEND_VERSION: 0.0.0.dev${{ github.event.pull_request.number }} + + - name: Save PR metadata + run: | + echo "${{ github.event.pull_request.number }}" > dist/PR_NUMBER + echo "${{ github.event.pull_request.head.sha }}" > dist/PR_HEAD_SHA + + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: pr-wheel + path: dist/ diff --git a/.github/workflows/pr-cleanup.yml b/.github/workflows/pr-cleanup.yml new file mode 100644 index 0000000..0e38170 --- /dev/null +++ b/.github/workflows/pr-cleanup.yml @@ -0,0 +1,30 @@ +name: PR Cleanup + +on: + pull_request_target: + types: [closed] + +permissions: + contents: write + issues: write + +jobs: + cleanup: + name: Delete PR pre-release + runs-on: ubuntu-latest + env: + PR: ${{ github.event.pull_request.number }} + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + steps: + - name: Delete PR release and tag + run: gh release delete "pr-${PR}" --cleanup-tag --yes || true + + - name: Delete PR comment + run: | + gh api "repos/${GH_REPO}/issues/${PR}/comments" \ + --paginate \ + --jq '.[] | select(.user.login == "github-actions[bot]") | select(.body | startswith("")) | .id' \ + | while read -r ID; do + gh api "repos/${GH_REPO}/issues/comments/${ID}" -X DELETE + done diff --git a/.github/workflows/pr-publish.yml b/.github/workflows/pr-publish.yml new file mode 100644 index 0000000..f3aabb8 --- /dev/null +++ b/.github/workflows/pr-publish.yml @@ -0,0 +1,121 @@ +name: PR Publish + +on: + workflow_run: + workflows: ["PR Build"] + types: [completed] + workflow_dispatch: + inputs: + run_id: + description: 'PR Build run ID to publish (find it in the Actions URL)' + required: true + +concurrency: + group: pr-publish-${{ github.event.workflow_run.pull_requests[0].number || github.event.inputs.run_id || github.run_id }} + cancel-in-progress: true + +permissions: + contents: write + issues: write + pull-requests: write + actions: read + +jobs: + publish: + name: Publish PR pre-release + runs-on: ubuntu-latest + if: > + github.event_name == 'workflow_dispatch' || + github.event.workflow_run.conclusion == 'success' + steps: + - name: Resolve run ID + id: run + run: | + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + echo "id=${{ github.event.inputs.run_id }}" >> "$GITHUB_OUTPUT" + else + echo "id=${{ github.event.workflow_run.id }}" >> "$GITHUB_OUTPUT" + fi + + - uses: actions/download-artifact@95815c38cf2ff2164869cbab79da8d1f422bc89e # v4.2.1 + with: + name: pr-wheel + run-id: ${{ steps.run.outputs.id }} + github-token: ${{ github.token }} + path: dist/ + + - name: Read PR metadata + id: pr + run: | + echo "number=$(cat dist/PR_NUMBER)" >> "$GITHUB_OUTPUT" + echo "head_sha=$(cat dist/PR_HEAD_SHA)" >> "$GITHUB_OUTPUT" + + - name: Validate wheel + id: wheel + run: | + PR="$(cat dist/PR_NUMBER)" + WHL_COUNT=$(ls dist/pyrympro-0.0.0.dev${PR}-*.whl 2>/dev/null | wc -l) + if [ "$WHL_COUNT" -ne 1 ]; then + echo "Expected exactly 1 wheel matching pyrympro-0.0.0.dev${PR}-*.whl, found ${WHL_COUNT}:" + ls dist/ || true + exit 1 + fi + WHEEL_FILE=$(ls dist/pyrympro-0.0.0.dev${PR}-*.whl) + echo "basename=$(basename "$WHEEL_FILE")" >> "$GITHUB_OUTPUT" + + - name: Delete existing PR release (if any) + run: gh release delete "pr-${{ steps.pr.outputs.number }}" --cleanup-tag --yes || true + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + + - name: Create GitHub pre-release + run: | + gh release create "pr-${{ steps.pr.outputs.number }}" "dist/${{ steps.wheel.outputs.basename }}" \ + --prerelease \ + --target "${{ steps.pr.outputs.head_sha }}" \ + --repo "${{ github.repository }}" \ + --title "PR #${{ steps.pr.outputs.number }} test build" \ + --notes "Test build for PR #${{ steps.pr.outputs.number }}." + env: + GH_TOKEN: ${{ github.token }} + + - name: Post PR comment (first build only) + run: | + PR="${{ steps.pr.outputs.number }}" + TAG="pr-${PR}" + REPO="${{ github.repository }}" + BASE="https://github.com/${REPO}/releases/download/${TAG}" + WHEEL_URL="${BASE}/${{ steps.wheel.outputs.basename }}" + + EXISTING=$(gh api "repos/${REPO}/issues/${PR}/comments" \ + --paginate \ + --jq '.[] | select(.user.login == "github-actions[bot]") | select(.body | startswith("")) | .id' \ + | head -1) + + if [ -n "$EXISTING" ]; then + echo "Comment already exists, skipping." + exit 0 + fi + + BODY_FILE=$(mktemp) + printf '%s\n' \ + '' \ + '## Test build ready' \ + '' \ + '**pip:**' \ + '```' \ + "pip install ${WHEEL_URL}" \ + '```' \ + '' \ + "**Home Assistant** — temporarily update the \`rympro\` integration's \`manifest.json\`:" \ + '```json' \ + '{' \ + " \"requirements\": [\"pyrympro @ ${WHEEL_URL}\"]" \ + '}' \ + '```' \ + 'Revert to the pinned version after testing.' \ + > "$BODY_FILE" + gh pr comment "$PR" --repo "$REPO" --body-file "$BODY_FILE" + env: + GH_TOKEN: ${{ github.token }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..0db6eb0 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,49 @@ +name: Release + +on: + release: + types: [published] + +permissions: + contents: read + actions: write # needed by actions/upload-artifact in the build job + +jobs: + build: + name: Build distributions + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + fetch-depth: 0 # required for setuptools-scm to read git tags + + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: "3.x" + + - run: pip install build + + - run: python -m build + + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: dist + path: dist/ + + publish: + name: Publish to PyPI + needs: build + runs-on: ubuntu-latest + environment: + name: pypi + url: https://pypi.org/p/pyrympro + permissions: + id-token: write # OIDC for trusted publishing + PEP 740 attestations + actions: read # needed by actions/download-artifact + steps: + - uses: actions/download-artifact@95815c38cf2ff2164869cbab79da8d1f422bc89e # v4.2.1 + with: + name: dist + path: dist/ + + - uses: pypa/gh-action-pypi-publish@76f52bc884231f62b9a034ebfe128415bbaabdfc # v1.12.4 diff --git a/.gitignore b/.gitignore index 8569c75..3686b63 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,5 @@ __pycache__/ build/ dist/ -*.egg-info/ \ No newline at end of file +*.egg-info/ +.mypy_cache/ \ No newline at end of file diff --git a/README.md b/README.md index 5ea955b..0ac121b 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,6 @@ +## Status +[![Build Status](https://github.com/OnFreund/pyrympro/actions/workflows/ci.yml/badge.svg)](https://github.com/OnFreund/pyrympro/actions/workflows/ci.yml) + # pyrympro A python library to communitcate with [Read Your Meter Pro](https://rym-pro.com/). @@ -24,4 +27,25 @@ await rym.login("", "", "") info = await rym.account_info() meter_reads = await rym.last_read() ... -``` \ No newline at end of file +``` + +## Testing PRs + +Every pull request automatically publishes a test build as a GitHub pre-release. You can find the install command in the PR comment posted by the bot, or on the [Releases page](https://github.com/OnFreund/pyrympro/releases) (pre-releases are tagged `pr-{number}`). + +**pip:** +``` +pip install https://github.com/OnFreund/pyrympro/releases/download/pr-42/pyrympro-0.0.0.dev42-py3-none-any.whl +``` + +**Home Assistant** — temporarily update the `rympro` integration's `manifest.json` to use the PEP 508 URL form so HA doesn't overwrite it on restart: +```json +{ + "requirements": ["pyrympro @ https://github.com/OnFreund/pyrympro/releases/download/pr-42/pyrympro-0.0.0.dev42-py3-none-any.whl"] +} +``` +Replace `42` with the actual PR number. Revert to the pinned version (e.g. `pyrympro==0.0.9`) after testing. + +The install URL is stable for the lifetime of the PR — new commits to the same PR reuse the same tag and wheel name, so you don't need to update `manifest.json` if more commits are pushed. + +The pre-release and comment are deleted automatically when the PR is merged or closed. \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..e78d0e2 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,53 @@ +[build-system] +requires = ["setuptools>=61", "setuptools-scm>=8"] +build-backend = "setuptools.build_meta" + +[project] +name = "pyrympro" +dynamic = ["version"] +description = "A python library to communicate with Read Your Meter Pro (https://rym-pro.com/)." +readme = "README.md" +license = { text = "MIT" } +authors = [{ name = "On Freund", email = "onfreund@gmail.com" }] +requires-python = ">=3.10" +dependencies = [ + "aiohttp", +] +classifiers = [ + "Development Status :: 4 - Beta", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", +] + +[project.urls] +Homepage = "https://github.com/OnFreund/pyrympro" + +[tool.setuptools] +include-package-data = false + +[tool.setuptools.packages.find] +include = ["pyrympro*"] + +[tool.setuptools.package-data] +pyrympro = ["py.typed"] + +[tool.setuptools_scm] +# version derived from git tags (e.g. v0.0.9 -> 0.0.9) + +[tool.mypy] +python_version = "3.10" +disallow_any_decorated = true +disallow_any_generics = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +no_implicit_optional = true +show_error_codes = true +strict_equality = true +warn_no_return = true +warn_redundant_casts = true +warn_unreachable = true +warn_unused_configs = true +warn_unused_ignores = true diff --git a/pyrympro/py.typed b/pyrympro/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..ee4ba4f --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +aiohttp diff --git a/setup.py b/setup.py deleted file mode 100644 index 6a2d2ce..0000000 --- a/setup.py +++ /dev/null @@ -1,121 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- - -# Note: To use the 'upload' functionality of this file, you must: -# $ pipenv install twine --dev - -import io -import os -import sys -from shutil import rmtree - -from setuptools import find_packages, setup, Command - -# Package meta-data. -NAME = 'pyrympro' -DESCRIPTION = 'A python library to communitcate with Read Your Meter Pro (https://rym-pro.com/).' -URL = 'https://github.com/OnFreund/pyrympro' -EMAIL = 'onfreund@gmail.com' -AUTHOR = 'On Freund' -REQUIRES_PYTHON = '>=3.10.0' -VERSION = '0.0.9' - -REQUIRED = ['aiohttp'] - -EXTRAS = {} - - -here = os.path.abspath(os.path.dirname(__file__)) - -# Import the README and use it as the long-description. -# Note: this will only work if 'README.md' is present in your MANIFEST.in file! -try: - with io.open(os.path.join(here, 'README.md'), encoding='utf-8') as f: - long_description = '\n' + f.read() -except FileNotFoundError: - long_description = DESCRIPTION - -# Load the package's __version__.py module as a dictionary. -about = {} -if not VERSION: - project_slug = NAME.lower().replace("-", "_").replace(" ", "_") - with open(os.path.join(here, project_slug, '__version__.py')) as f: - exec(f.read(), about) -else: - about['__version__'] = VERSION - - -class UploadCommand(Command): - """Support setup.py upload.""" - - description = 'Build and publish the package.' - user_options = [] - - @staticmethod - def status(s): - """Prints things in bold.""" - print('\033[1m{0}\033[0m'.format(s)) - - def initialize_options(self): - pass - - def finalize_options(self): - pass - - def run(self): - try: - self.status('Removing previous builds…') - rmtree(os.path.join(here, 'dist')) - except OSError: - pass - - self.status('Building Source and Wheel distribution…') - os.system('{0} setup.py sdist bdist_wheel'.format(sys.executable)) - - self.status('Uploading the package to PyPI via Twine…') - os.system('twine upload dist/*') - - self.status('Pushing git tags…') - os.system('git tag v{0}'.format(about['__version__'])) - os.system('git push --tags') - - sys.exit() - - -# Where the magic happens: -setup( - name=NAME, - version=about['__version__'], - description=DESCRIPTION, - long_description=long_description, - long_description_content_type='text/markdown', - author=AUTHOR, - author_email=EMAIL, - python_requires=REQUIRES_PYTHON, - url=URL, - packages=find_packages(exclude=["tests", "*.tests", "*.tests.*", "tests.*"]), - # If your package is a single module, use this instead of 'packages': - # py_modules=['mypackage'], - - # entry_points={ - # 'console_scripts': ['mycli=mymodule:cli'], - # }, - install_requires=REQUIRED, - extras_require=EXTRAS, - include_package_data=True, - license='MIT', - classifiers=[ - # Trove classifiers - # Full list: https://pypi.python.org/pypi?%3Aaction=list_classifiers - 'License :: OSI Approved :: MIT License', - 'Programming Language :: Python', - 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.7', - 'Programming Language :: Python :: Implementation :: CPython', - 'Programming Language :: Python :: Implementation :: PyPy' - ], - # $ setup.py publish support. - cmdclass={ - 'upload': UploadCommand, - }, -)