From 024e8339250ca165e56372448f5b4c77bf175d10 Mon Sep 17 00:00:00 2001 From: "Jens W. Klein" Date: Fri, 7 Nov 2025 16:07:35 +0100 Subject: [PATCH 01/13] Migrate to GitHub Actions automated release process This commit modernizes the CI/CD pipeline to match the mxrepo workflow and implements automated releases via GitHub Actions. Based on refactor-package-layout branch. ## New Workflow Files - Add lint.yml: Automated code quality checks (ruff, isort) - Add typecheck.yml: Type checking with mypy - Add release.yml: Automated PyPI publishing workflow - Publishes to Test PyPI on every master push - Publishes to production PyPI on GitHub releases - Add dependabot.yml: Weekly GitHub Actions dependency updates ## Updated Workflow Files - Update test.yml: - Switch from actions/setup-python to astral-sh/setup-uv@v7 - Add workflow_call and workflow_dispatch triggers - Implement coverage artifact collection and reporting - Add dedicated coverage job with HTML report generation - Update docs.yml: - Switch to astral-sh/setup-uv@v7 for consistency - Update action versions (checkout@v5, setup-node@v4) - Update Node.js version from 16 to 20 - Add workflow_call trigger for reusability ## Build System Updates - Update pyproject.toml: - Add hatch-vcs for automatic versioning from git tags - Add hatch-fancy-pypi-readme for combined readme - Make version and readme dynamic - Add ruff.lint configuration for code quality - Add pytest and coverage configuration - Preserve existing stricter mypy settings - Preserve existing isort "plone" profile - Maintain backward compatibility with zest.releaser config ## Documentation - Add RELEASE.md: Comprehensive release process documentation - Automated GitHub release workflow - PyPI Trusted Publishing setup instructions - Version numbering guidelines - Troubleshooting guide - Migration notes from zest.releaser ## Related - Addresses #3: GitHub Actions for testing with coverage artifacts - Migrates release process from manual zest.releaser to automated GitHub releases - Follows same pattern as https://github.com/mxstack/mxrepo --- .github/dependabot.yml | 14 +++ .github/workflows/docs.yml | 22 +++- .github/workflows/lint.yml | 28 +++++ .github/workflows/release.yml | 85 +++++++++++++ .github/workflows/test.yml | 92 +++++++++++--- .github/workflows/typecheck.yml | 28 +++++ RELEASE.md | 213 ++++++++++++++++++++++++++++++++ pyproject.toml | 73 ++++++++++- 8 files changed, 531 insertions(+), 24 deletions(-) create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/lint.yml create mode 100644 .github/workflows/release.yml create mode 100644 .github/workflows/typecheck.yml create mode 100644 RELEASE.md diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..659de43 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,14 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for all configuration options: +# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file + +version: 2 +updates: + # Enable version updates for GitHub Actions + - package-ecosystem: "github-actions" + # Workflow files stored in the default location of `.github/workflows` + # You don't need to specify `/.github/workflows` for `directory`. You can use `directory: "/"`. + directory: "/" + schedule: + interval: "weekly" diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index d0e3c6f..981524f 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -1,17 +1,29 @@ name: Docs -on: [push, workflow_dispatch] +on: + push: + workflow_call: + workflow_dispatch: jobs: docs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - uses: actions/setup-python@v4 - - uses: actions/setup-node@v3 + - uses: actions/checkout@v5 + + - name: Set up uv + uses: astral-sh/setup-uv@v7 + with: + enable-cache: true + cache-dependency-glob: "pyproject.toml" + + - name: Set up Python 3.13 + run: uv python install 3.13 + + - uses: actions/setup-node@v4 with: - node-version: 16 + node-version: 20 - run: npm install -g @mermaid-js/mermaid-cli diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..31bfc6e --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,28 @@ +name: Lint + +on: + push: + workflow_call: + workflow_dispatch: + +jobs: + lint: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v5 + + - name: Set up uv + uses: astral-sh/setup-uv@v7 + with: + enable-cache: true + cache-dependency-glob: "pyproject.toml" + + - name: Set up Python 3.10 + run: uv python install 3.10 + + - name: Install Project + run: make install + + - name: Run checks + run: make check diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..b3cc228 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,85 @@ +--- +name: Build & upload PyPI package + +on: + push: + branches: [master] + tags: ["*"] + release: + types: + - published + workflow_dispatch: + +jobs: + tests: + uses: "./.github/workflows/test.yml" + lint: + uses: "./.github/workflows/lint.yml" + typecheck: + uses: "./.github/workflows/typecheck.yml" + + # Always build & lint package. + build-package: + name: Build & verify package + needs: + - lint + - tests + - typecheck + runs-on: ubuntu-latest + permissions: + attestations: write + id-token: write + + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + persist-credentials: false + + - uses: hynek/build-and-inspect-python-package@v2 + with: + attest-build-provenance-github: 'true' + + # Upload to Test PyPI on every commit on master. + release-test-pypi: + name: Publish in-dev package to test.pypi.org + environment: release-test-pypi + if: github.event_name == 'push' && github.ref == 'refs/heads/master' + runs-on: ubuntu-latest + needs: + - build-package + permissions: + id-token: write + + steps: + - name: Download packages built by build-and-inspect-python-package + uses: actions/download-artifact@v6 + with: + name: Packages + path: dist + + - name: Upload package to Test PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + repository-url: https://test.pypi.org/legacy/ + + # Upload to real PyPI on GitHub Releases. + release-pypi: + name: Publish released package to pypi.org + environment: release-pypi + if: github.event.action == 'published' + runs-on: ubuntu-latest + needs: + - build-package + permissions: + id-token: write + + steps: + - name: Download packages built by build-and-inspect-python-package + uses: actions/download-artifact@v6 + with: + name: Packages + path: dist + + - name: Upload package to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 0a0ee63..bd79b2f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,40 +1,100 @@ name: Tests -on: [push] +on: + push: + workflow_call: + workflow_dispatch: jobs: - test: - name: Test ${{ matrix.python }} - ${{ matrix.os }} + tests: + name: Test ${{ matrix.python-version }} - ${{ matrix.os }} runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: - os: - - ubuntu-latest - - windows-latest - - macos-latest - - python: + python-version: - "3.10" - "3.11" - "3.12" - "3.13" - "3.14" + os: + - ubuntu-latest + - macos-latest + - windows-latest steps: - uses: actions/checkout@v5 - - name: Set up Python - uses: actions/setup-python@v5 + - name: Set up uv + uses: astral-sh/setup-uv@v7 with: - python-version: ${{ matrix.python }} + enable-cache: true + cache-dependency-glob: "pyproject.toml" - - name: Show Python version - run: python -c "import sys; print(sys.version)" + - name: Set up Python ${{ matrix.python-version }} + run: uv python install ${{ matrix.python-version }} - - name: Install environment + - name: Install Project (Windows) + if: runner.os == 'Windows' + run: make MAKESHELL='C:/Program Files/Git/usr/bin/bash' install + + - name: Install Project (Unix) + if: runner.os != 'Windows' run: make install - - name: Run tests an collect code coverage + - name: Run Coverage (Windows) + if: runner.os == 'Windows' + run: make MAKESHELL='C:/Program Files/Git/usr/bin/bash' coverage + + - name: Run Coverage (Unix) + if: runner.os != 'Windows' run: make coverage + + - name: Upload coverage data + uses: actions/upload-artifact@v4 + with: + name: coverage-data-${{ matrix.os }}-${{ matrix.python-version }} + path: .coverage.* + if-no-files-found: ignore + include-hidden-files: true + + coverage: + name: Combine & check coverage + needs: tests + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v5 + + - name: Set up uv + uses: astral-sh/setup-uv@v7 + with: + enable-cache: true + cache-dependency-glob: "pyproject.toml" + + - name: Set up Python 3.13 + run: uv python install 3.13 + + - name: Install Project + run: make install + + - name: Download coverage data + uses: actions/download-artifact@v4 + with: + pattern: coverage-data-* + merge-multiple: true + + - name: Combine coverage & fail if it's <100% + run: | + uv run coverage combine + uv run coverage html --skip-covered --skip-empty + uv run coverage report --fail-under=100 + + - name: Upload HTML report if check failed + uses: actions/upload-artifact@v4 + with: + name: html-report + path: htmlcov + if: ${{ failure() }} diff --git a/.github/workflows/typecheck.yml b/.github/workflows/typecheck.yml new file mode 100644 index 0000000..3f924cb --- /dev/null +++ b/.github/workflows/typecheck.yml @@ -0,0 +1,28 @@ +name: Type checks + +on: + push: + workflow_call: + workflow_dispatch: + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v5 + + - name: Set up uv + uses: astral-sh/setup-uv@v7 + with: + enable-cache: true + cache-dependency-glob: "pyproject.toml" + + - name: Set up Python 3.13 + run: uv python install 3.13 + + - name: Install Project + run: make install + + - name: Run Typechecks + run: make typecheck diff --git a/RELEASE.md b/RELEASE.md new file mode 100644 index 0000000..78c8801 --- /dev/null +++ b/RELEASE.md @@ -0,0 +1,213 @@ +# Release Process + +This document describes the automated release process for webresource. + +## Overview + +The project uses an automated GitHub Actions workflow for building, testing, and publishing packages to PyPI. Version numbers are automatically determined from git tags using `hatch-vcs`. + +## Prerequisites + +### GitHub Environment Configuration + +Before your first release, ensure the following GitHub environments are configured in the repository settings: + +1. **release-test-pypi** - For publishing to Test PyPI +2. **release-pypi** - For publishing to production PyPI + +### PyPI Trusted Publishing (Recommended) + +Configure Trusted Publishing on both PyPI and Test PyPI: + +1. Go to https://test.pypi.org/manage/account/publishing/ (for Test PyPI) +2. Go to https://pypi.org/manage/account/publishing/ (for production PyPI) +3. Add a new trusted publisher with: + - **PyPI Project Name**: `webresource` + - **Owner**: `conestack` + - **Repository name**: `webresource` + - **Workflow name**: `release.yml` + - **Environment name**: `release-test-pypi` (or `release-pypi` for production) + +This eliminates the need for API tokens and is more secure. + +## Release Types + +### Development Releases (Automatic) + +**Trigger**: Every push to the `master` branch + +**What happens**: +1. All tests, linting, and type checks run +2. Package is built and verified with attestation +3. Package is automatically published to **Test PyPI** + +**Version format**: `X.Y.devN+gCOMMITHASH` (e.g., `1.3.dev42+g1a2b3c4`) + +**Purpose**: Allows testing of the package installation process before making a production release + +**Test installation**: +```bash +pip install --index-url https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple/ webresource +``` + +### Production Releases (Manual) + +**Trigger**: Creating a GitHub Release + +**What happens**: +1. All tests, linting, and type checks run +2. Package is built and verified with attestation +3. Package is automatically published to **production PyPI** + +**Version format**: `X.Y.Z` (e.g., `1.3.0`) + +## How to Create a Production Release + +### Step 1: Update CHANGES.rst + +Before creating a release, ensure `CHANGES.rst` has a section for the new version: + +```rst +1.3.0 (2025-01-15) +------------------ + +- Feature: Added support for new feature X +- Fix: Fixed bug Y +- Update: Improved documentation +``` + +Commit and push this change to master: + +```bash +git add CHANGES.rst +git commit -m "Prepare release 1.3.0" +git push origin master +``` + +Wait for the CI to pass and verify the dev package on Test PyPI if needed. + +### Step 2: Create a GitHub Release + +1. Go to https://github.com/conestack/webresource/releases/new +2. Click "Choose a tag" +3. Type the new version number with a `v` prefix (e.g., `v1.3.0`) +4. Click "Create new tag: v1.3.0 on publish" +5. Set the release title to the same version (e.g., `v1.3.0`) +6. In the description, add release notes (can copy from CHANGES.rst) +7. Click "Publish release" + +### Step 3: Automated Process + +Once you publish the release, GitHub Actions will automatically: + +1. Run all quality checks (tests, lint, typecheck) on Python 3.10-3.14 and all OS platforms +2. Build the package with build provenance attestation +3. Publish to production PyPI + +**Monitor the workflow**: https://github.com/conestack/webresource/actions + +### Step 4: Verify the Release + +After the workflow completes successfully: + +1. Check PyPI: https://pypi.org/project/webresource/ +2. Test installation: +```bash +pip install --upgrade webresource +python -c "import webresource; print(webresource.__version__)" +``` + +### Step 5: Update to Next Dev Version (Optional) + +If you want to explicitly mark the start of new development: + +```bash +git pull origin master # Get the tag +# Edit CHANGES.rst to add a new section like "1.4.0 (unreleased)" +git add CHANGES.rst +git commit -m "Back to development: 1.4.0" +git push origin master +``` + +Note: This step is optional since hatch-vcs automatically handles dev versions. + +## Version Numbering + +This project follows [Semantic Versioning](https://semver.org/): + +- **MAJOR version** (X.0.0): Incompatible API changes +- **MINOR version** (0.X.0): New functionality in a backwards compatible manner +- **PATCH version** (0.0.X): Backwards compatible bug fixes + +### Version Examples + +- `1.3.0` - Production release +- `1.3.dev42+g1a2b3c4` - Development version (42 commits after v1.2.0) +- `2.0.0b1` - Beta release (create tag like `v2.0.0b1`) +- `2.0.0rc1` - Release candidate (create tag like `v2.0.0rc1`) + +## Troubleshooting + +### Release Workflow Failed + +1. Check the GitHub Actions log: https://github.com/conestack/webresource/actions +2. Common issues: + - **Tests failed**: Fix the failing tests and push to master, then recreate the release + - **PyPI publish failed**: Check if the version already exists on PyPI (versions are immutable) + - **Permission denied**: Ensure Trusted Publishing is configured correctly + +### Version Not Updating + +If you see the old version after creating a release: + +1. Ensure the tag was created (check: https://github.com/conestack/webresource/tags) +2. Verify `hatch-vcs` is installed: `pip install hatch-vcs` +3. Check that `.git` directory exists (hatch-vcs reads from git) +4. For development installs, use: `pip install -e .` (not `pip install -e .[test]` from old setup.py) + +### Rollback a Release + +You **cannot** delete or modify a release on PyPI once published. If you need to fix a broken release: + +1. Fix the issue in master +2. Create a new patch release (e.g., if v1.3.0 is broken, release v1.3.1) + +## Migration from zest.releaser + +This project previously used `zest.releaser` for manual releases. Key differences: + +| Aspect | Old (zest.releaser) | New (GitHub Releases) | +|--------|---------------------|----------------------| +| Process | Manual command: `fullrelease` | Create GitHub Release | +| Version management | Hardcoded in setup.cfg | Automatic from git tags | +| PyPI upload | Manual or via zest.releaser | Automatic via GitHub Actions | +| Testing | Local only | Full CI/CD matrix (all Python versions & OS) | +| Verification | Manual | Automated with attestation | + +The `tool.zest-releaser` section in `pyproject.toml` is kept for backward compatibility but is no longer used. + +## Continuous Integration + +### On Every Push + +All pushes trigger: +- **Tests**: Python 3.10-3.14 on Ubuntu, macOS, and Windows +- **Lint**: Code quality checks with ruff and isort +- **Typecheck**: Static type checking with mypy +- **Docs**: Documentation build (deployed on manual trigger) +- **Coverage**: 100% coverage requirement with HTML report artifacts + +### On Master Branch Push + +Additionally publishes development version to Test PyPI. + +### On GitHub Release + +Additionally publishes production version to PyPI. + +## Additional Resources + +- [GitHub Actions Workflows](.github/workflows/) +- [hatch-vcs Documentation](https://github.com/ofek/hatch-vcs) +- [PyPI Trusted Publishing](https://docs.pypi.org/trusted-publishers/) +- [Semantic Versioning](https://semver.org/) diff --git a/pyproject.toml b/pyproject.toml index 828a92f..b73d888 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,12 +1,11 @@ [build-system] -requires = ["hatchling"] +requires = ["hatchling", "hatch-vcs", "hatch-fancy-pypi-readme"] build-backend = "hatchling.build" [project] name = "webresource" -version = "2.0.0.dev0" description = "A resource registry for web applications." -readme = {file = "README.rst", content-type = "text/x-rst"} +dynamic = ["version", "readme"] keywords = ["web", "resources", "dependencies", "javascript", "CSS"] authors = [{name = "Conestack Constributors", email = "dev@conestack.org"}] license = {text = "Simplified BSD"} @@ -41,6 +40,27 @@ ChangeLog = "https://github.com/conestack/webresource/blob/master/CHANGES.rst" "Issue Tracker" = "https://github.com/conestack/webresource/issues" "Source Code" = "https://github.com/conestack/webresource" +[tool.hatch.version] +source = "vcs" + +[tool.hatch.build.hooks.vcs] +version-file = "webresource/_version.py" + +[tool.hatch.metadata] +allow-direct-references = true + +[tool.hatch.metadata.hooks.fancy-pypi-readme] +content-type = "text/x-rst" + +[[tool.hatch.metadata.hooks.fancy-pypi-readme.fragments]] +path = "README.rst" + +[[tool.hatch.metadata.hooks.fancy-pypi-readme.fragments]] +path = "CHANGES.rst" + +[[tool.hatch.metadata.hooks.fancy-pypi-readme.fragments]] +path = "LICENSE.rst" + [tool.hatch.build.targets.sdist] exclude = ["/docs"] include = ["docs/source/overview.rst"] @@ -50,10 +70,33 @@ packages = ["webresource"] [tool.ruff] target-version = "py310" +line-length = 88 +exclude = [ + ".git", + ".venv", + "venv", + "__pycache__", + "build", + "dist", + "webresource/_version.py", +] [tool.ruff.format] quote-style = "single" +[tool.ruff.lint] +select = [ + "E", # pycodestyle errors + "F", # pyflakes + "B", # flake8-bugbear + "UP", # pyupgrade + "S", # bandit (security) +] +ignore = [] + +[tool.ruff.lint.per-file-ignores] +"*/tests.py" = ["S101"] # Allow assert in tests + [tool.isort] py_version = 310 profile = "plone" @@ -66,5 +109,29 @@ warn_unused_configs = true disallow_untyped_defs = true disallow_any_generics = false +[tool.pytest.ini_options] +testpaths = ["webresource"] +python_files = ["tests.py"] +addopts = [ + "--strict-markers", + "--strict-config", + "-ra", +] + +[tool.coverage.run] +source = ["webresource"] +omit = ["webresource/_version.py", "webresource/tests.py"] + +[tool.coverage.report] +fail_under = 100 +exclude_lines = [ + "pragma: no cover", + "def __repr__", + "raise AssertionError", + "raise NotImplementedError", + "if __name__ == .__main__.:", +] + +# Keep for backward compatibility, but can be removed later [tool.zest-releaser] create-wheel = true From 2feee7b6a271a127551177465e8ff87c2d26e543 Mon Sep 17 00:00:00 2001 From: "Jens W. Klein" Date: Fri, 7 Nov 2025 16:16:28 +0100 Subject: [PATCH 02/13] uvx ruff --fix --- tests/test_resources.py | 16 ++++++++-------- webresource/exceptions.py | 6 +++--- webresource/groups.py | 2 +- webresource/renderer.py | 4 ++-- webresource/resources.py | 16 ++++++---------- 5 files changed, 20 insertions(+), 24 deletions(-) diff --git a/tests/test_resources.py b/tests/test_resources.py index 6ce2bbb..5d9e828 100644 --- a/tests/test_resources.py +++ b/tests/test_resources.py @@ -97,7 +97,7 @@ def test_Resource(self, tempdir): wr.config.development = False with open(os.path.join(tempdir, 'res'), 'wb') as f: - f.write('Resource Content ä'.encode('utf8')) + f.write('Resource Content ä'.encode()) resource = Resource(name='res', resource='res', directory=tempdir) self.assertEqual(resource.file_data, b'Resource Content \xc3\xa4') @@ -115,7 +115,7 @@ def test_Resource(self, tempdir): resource.unique = True resource_url = resource.resource_url('https://tld.org') - self.assertEqual(resource_url, 'https://tld.org/{}/res'.format(unique_key)) + self.assertEqual(resource_url, f'https://tld.org/{unique_key}/res') with open(os.path.join(tempdir, 'res'), 'w') as f: f.write('Changed Content') @@ -124,13 +124,13 @@ def test_Resource(self, tempdir): self.assertEqual(resource.file_hash, hash_) resource_url = resource.resource_url('https://tld.org') - self.assertEqual(resource_url, 'https://tld.org/{}/res'.format(unique_key)) + self.assertEqual(resource_url, f'https://tld.org/{unique_key}/res') wr.config.development = True self.assertNotEqual(resource.file_hash, hash_) resource_url = resource.resource_url('https://tld.org') - self.assertNotEqual(resource_url, 'https://tld.org/{}/res'.format(unique_key)) + self.assertNotEqual(resource_url, f'https://tld.org/{unique_key}/res') resource = Resource(name='res', resource='res.ext', custom_attr='value') self.assertEqual(resource.additional_attrs, dict(custom_attr='value')) @@ -167,19 +167,19 @@ def test_ScriptResource(self, tempdir): ) hash_ = 'omjyXfsb+ti/5fpn4QjjSYjpKRnxWpzc6rIUE6mXxyDjbLS9AotgsLWQZtylXicX' self.assertEqual(script.file_hash, hash_) - self.assertEqual(script.integrity, 'sha384-{}'.format(hash_)) + self.assertEqual(script.integrity, f'sha384-{hash_}') rendered = script.render('https://tld.org') - expected = 'integrity="sha384-{}"'.format(hash_) + expected = f'integrity="sha384-{hash_}"' self.assertTrue(rendered.find(expected)) with open(os.path.join(tempdir, 'script.js'), 'w') as f: f.write('Changed Script') - self.assertEqual(script.integrity, 'sha384-{}'.format(hash_)) + self.assertEqual(script.integrity, f'sha384-{hash_}') wr.config.development = True - self.assertNotEqual(script.integrity, 'sha384-{}'.format(hash_)) + self.assertNotEqual(script.integrity, f'sha384-{hash_}') script = wr.ScriptResource(name='js_res', resource='res.js', custom='value') self.assertEqual( diff --git a/webresource/exceptions.py b/webresource/exceptions.py index 59785ab..1c4923c 100644 --- a/webresource/exceptions.py +++ b/webresource/exceptions.py @@ -20,7 +20,7 @@ def __init__(self, counter: Counter[str]) -> None: for name, count in counter.items(): if count > 1: conflicting.append(name) - msg = 'Conflicting resource names: {}'.format(sorted(conflicting)) + msg = f'Conflicting resource names: {sorted(conflicting)}' super(ResourceConflictError, self).__init__(msg) @@ -28,7 +28,7 @@ class ResourceCircularDependencyError(ResourceError): """Resources define circular dependencies.""" def __init__(self, resources: list[Resource]) -> None: - msg = 'Resources define circular dependencies: {}'.format(resources) + msg = f'Resources define circular dependencies: {resources}' super(ResourceCircularDependencyError, self).__init__(msg) @@ -36,5 +36,5 @@ class ResourceMissingDependencyError(ResourceError): """Resource depends on a missing resource.""" def __init__(self, resource: Resource) -> None: - msg = 'Resource defines missing dependency: {}'.format(resource) + msg = f'Resource defines missing dependency: {resource}' super(ResourceMissingDependencyError, self).__init__(msg) diff --git a/webresource/groups.py b/webresource/groups.py index 18b184d..d6605b0 100644 --- a/webresource/groups.py +++ b/webresource/groups.py @@ -101,4 +101,4 @@ def _filtered_resources( return resources def __repr__(self) -> str: - return '{} name="{}"'.format(self.__class__.__name__, self.name) + return f'{self.__class__.__name__} name="{self.name}"' diff --git a/webresource/renderer.py b/webresource/renderer.py index f038c10..c2d0cf5 100644 --- a/webresource/renderer.py +++ b/webresource/renderer.py @@ -34,7 +34,7 @@ def render(self) -> str: try: lines.append(resource.render(self.base_url)) except (ResourceError, FileNotFoundError): - msg = 'Failure to render resource "{}"'.format(resource.name) - lines.append(''.format(msg)) + msg = f'Failure to render resource "{resource.name}"' + lines.append(f'') logger.exception(msg) return '\n'.join(lines) diff --git a/webresource/resources.py b/webresource/resources.py index 1f5595e..bb5351a 100644 --- a/webresource/resources.py +++ b/webresource/resources.py @@ -148,9 +148,7 @@ def file_hash(self, hash_: str | None) -> None: @property def unique_key(self) -> str: - return '{}{}'.format( - self.unique_prefix, str(uuid.uuid5(namespace_uuid, self.file_hash)) - ) + return f'{self.unique_prefix}{str(uuid.uuid5(namespace_uuid, self.file_hash))}' def resource_url(self, base_url: str) -> str: """Create URL for resource. @@ -181,16 +179,14 @@ def _render_tag(self, tag: str, closing_tag: bool, **attrs: str | None) -> str: for name, val in attrs.items(): if val is None: continue - attrs_.append('{0}="{1}"'.format(name, val)) + attrs_.append(f'{name}="{val}"') attrs_str = ' {0}'.format(' '.join(sorted(attrs_))) if not closing_tag: - return '<{tag}{attrs} />'.format(tag=tag, attrs=attrs_str) - return '<{tag}{attrs}>'.format(tag=tag, attrs=attrs_str) + return f'<{tag}{attrs_str} />' + return f'<{tag}{attrs_str}>' def __repr__(self) -> str: - return ('{} name="{}", depends="{}"').format( - self.__class__.__name__, self.name, self.depends - ) + return (f'{self.__class__.__name__} name="{self.name}", depends="{self.depends}"') class ScriptResource(Resource): @@ -293,7 +289,7 @@ def integrity(self) -> str | None: if not config.development and self._integrity_hash is not None: return self._integrity_hash if self._integrity is True: - self._integrity_hash = '{}-{}'.format(self.hash_algorithm, self.file_hash) + self._integrity_hash = f'{self.hash_algorithm}-{self.file_hash}' return self._integrity_hash @integrity.setter From fb7dee0d5edfd80eca149fcf7e5a54ac7c08b832 Mon Sep 17 00:00:00 2001 From: "Jens W. Klein" Date: Fri, 7 Nov 2025 16:19:09 +0100 Subject: [PATCH 03/13] uvx ruff --fix --unsafe-fixes --- webresource/exceptions.py | 6 +++--- webresource/groups.py | 2 +- webresource/resources.py | 12 ++++++------ 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/webresource/exceptions.py b/webresource/exceptions.py index 1c4923c..c4dced3 100644 --- a/webresource/exceptions.py +++ b/webresource/exceptions.py @@ -21,7 +21,7 @@ def __init__(self, counter: Counter[str]) -> None: if count > 1: conflicting.append(name) msg = f'Conflicting resource names: {sorted(conflicting)}' - super(ResourceConflictError, self).__init__(msg) + super().__init__(msg) class ResourceCircularDependencyError(ResourceError): @@ -29,7 +29,7 @@ class ResourceCircularDependencyError(ResourceError): def __init__(self, resources: list[Resource]) -> None: msg = f'Resources define circular dependencies: {resources}' - super(ResourceCircularDependencyError, self).__init__(msg) + super().__init__(msg) class ResourceMissingDependencyError(ResourceError): @@ -37,4 +37,4 @@ class ResourceMissingDependencyError(ResourceError): def __init__(self, resource: Resource) -> None: msg = f'Resource defines missing dependency: {resource}' - super(ResourceMissingDependencyError, self).__init__(msg) + super().__init__(msg) diff --git a/webresource/groups.py b/webresource/groups.py index d6605b0..658c7c0 100644 --- a/webresource/groups.py +++ b/webresource/groups.py @@ -36,7 +36,7 @@ def __init__( include the resource group. :param group: Optional resource group instance. """ - super(ResourceGroup, self).__init__( + super().__init__( name=name, directory=directory, path=path, include=include, group=group ) self._members = [] diff --git a/webresource/resources.py b/webresource/resources.py index bb5351a..12af9da 100644 --- a/webresource/resources.py +++ b/webresource/resources.py @@ -85,7 +85,7 @@ def __init__( """ if resource is None and url is None: raise ResourceError('Either resource or url must be given') - super(Resource, self).__init__( + super().__init__( name=name, directory=directory, path=path, include=include, group=group ) if depends is None: @@ -180,7 +180,7 @@ def _render_tag(self, tag: str, closing_tag: bool, **attrs: str | None) -> str: if val is None: continue attrs_.append(f'{name}="{val}"') - attrs_str = ' {0}'.format(' '.join(sorted(attrs_))) + attrs_str = ' {}'.format(' '.join(sorted(attrs_))) if not closing_tag: return f'<{tag}{attrs_str} />' return f'<{tag}{attrs_str}>' @@ -259,7 +259,7 @@ def __init__( additional attributes on resource tag. :raise ResourceError: No resource and no url given. """ - super(ScriptResource, self).__init__( + super().__init__( name=name, depends=depends, directory=directory, @@ -355,7 +355,7 @@ def __init__( title: str | None = None, **kwargs: Any, ) -> None: - super(LinkMixin, self).__init__( + super().__init__( name=name, depends=depends, directory=directory, @@ -461,7 +461,7 @@ def __init__( additional attributes on resource tag. :raise ResourceError: No resource and no url given. """ - super(LinkResource, self).__init__( + super().__init__( name=name, depends=depends, directory=directory, @@ -543,7 +543,7 @@ def __init__( additional attributes on resource tag. :raise ResourceError: No resource and no url given. """ - super(StyleResource, self).__init__( + super().__init__( name=name, depends=depends, directory=directory, From f5262dbd32dbc07db85619f1113b05790211caf6 Mon Sep 17 00:00:00 2001 From: "Jens W. Klein" Date: Fri, 7 Nov 2025 16:29:04 +0100 Subject: [PATCH 04/13] hatch-vcs version --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 25243c7..431156f 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ /.coverage /.mxmake /.ruff_cache +/webresource/_version.py /CLAUDE.md /build /dist/ From 024dc4f42b9e7ebddad1a559c441b960f1727075 Mon Sep 17 00:00:00 2001 From: "Jens W. Klein" Date: Fri, 7 Nov 2025 16:32:27 +0100 Subject: [PATCH 05/13] make format --- webresource/resources.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webresource/resources.py b/webresource/resources.py index 12af9da..8bbbfbd 100644 --- a/webresource/resources.py +++ b/webresource/resources.py @@ -186,7 +186,7 @@ def _render_tag(self, tag: str, closing_tag: bool, **attrs: str | None) -> str: return f'<{tag}{attrs_str}>' def __repr__(self) -> str: - return (f'{self.__class__.__name__} name="{self.name}", depends="{self.depends}"') + return f'{self.__class__.__name__} name="{self.name}", depends="{self.depends}"' class ScriptResource(Resource): From 68b541fc3392bc503a9179b5196a01dfcb11f77a Mon Sep 17 00:00:00 2001 From: "Jens W. Klein" Date: Fri, 7 Nov 2025 16:34:33 +0100 Subject: [PATCH 06/13] make format --- tests/test_resources.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_resources.py b/tests/test_resources.py index 5d9e828..004c5b3 100644 --- a/tests/test_resources.py +++ b/tests/test_resources.py @@ -37,12 +37,12 @@ def test_Resource(self, tempdir): resource = Resource(name='res', url='http://tld.net/resource') with self.assertRaises(wr.ResourceError): - resource.file_name + resource.file_name # noqa: B018 resource = Resource(name='res', resource='res.ext') self.assertEqual(resource.file_name, 'res.ext') with self.assertRaises(wr.ResourceError): - resource.file_path + resource.file_path # noqa: B018 resource = Resource(name='res', directory='/dir', resource='res.ext') self.assertEqual(resource.file_name, 'res.ext') From d4c850a907befd838c29b3827e53c82028ea3db8 Mon Sep 17 00:00:00 2001 From: "Jens W. Klein" Date: Fri, 7 Nov 2025 17:01:01 +0100 Subject: [PATCH 07/13] Fix pytest configuration - Add tests/__init__.py to make tests a proper Python package - Add requires-python = '>=3.10' to pyproject.toml - Fixes import issues when running pytest --- pyproject.toml | 6 +++--- tests/__init__.py | 1 + 2 files changed, 4 insertions(+), 3 deletions(-) create mode 100644 tests/__init__.py diff --git a/pyproject.toml b/pyproject.toml index b73d888..bd99808 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,6 +6,7 @@ build-backend = "hatchling.build" name = "webresource" description = "A resource registry for web applications." dynamic = ["version", "readme"] +requires-python = ">=3.10" keywords = ["web", "resources", "dependencies", "javascript", "CSS"] authors = [{name = "Conestack Constributors", email = "dev@conestack.org"}] license = {text = "Simplified BSD"} @@ -110,8 +111,7 @@ disallow_untyped_defs = true disallow_any_generics = false [tool.pytest.ini_options] -testpaths = ["webresource"] -python_files = ["tests.py"] +testpaths = ["tests"] addopts = [ "--strict-markers", "--strict-config", @@ -120,7 +120,7 @@ addopts = [ [tool.coverage.run] source = ["webresource"] -omit = ["webresource/_version.py", "webresource/tests.py"] +omit = ["webresource/_version.py", "tests/*/py"] [tool.coverage.report] fail_under = 100 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..d4839a6 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +# Tests package From abe3c3709c68316ad42f76327ad8f970ea62b914 Mon Sep 17 00:00:00 2001 From: "Jens W. Klein" Date: Fri, 7 Nov 2025 17:03:42 +0100 Subject: [PATCH 08/13] Fix ruff linting issues - Add noqa: S101 comment for type guard assert in resolver.py - Fix per-file-ignores pattern to match tests/*.py instead of */tests.py - Addresses S101 (assert detection) and E501 (line length) issues --- pyproject.toml | 2 +- webresource/resolver.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index bd99808..1570d47 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -96,7 +96,7 @@ select = [ ignore = [] [tool.ruff.lint.per-file-ignores] -"*/tests.py" = ["S101"] # Allow assert in tests +"tests/*.py" = ["S101"] # Allow assert in tests [tool.isort] py_version = 310 diff --git a/webresource/resolver.py b/webresource/resolver.py index 43605fb..691e015 100644 --- a/webresource/resolver.py +++ b/webresource/resolver.py @@ -76,7 +76,8 @@ def resolve(self) -> list[Resource]: while count > 0: count -= 1 for resource in resources[:]: - assert resource.depends is not None # guaranteed by above loop + # guaranteed by above loop + assert resource.depends is not None # noqa: S101 hook_idx = 0 not_yet = False for dependency_name in resource.depends: From edca1ba8ad84ef4d1abdc4ce24ca68c6e95de845 Mon Sep 17 00:00:00 2001 From: "Jens W. Klein" Date: Fri, 7 Nov 2025 17:22:00 +0100 Subject: [PATCH 09/13] Fix coverage by excluding TYPE_CHECKING blocks - Add 'if TYPE_CHECKING:' to coverage exclude_lines - TYPE_CHECKING blocks contain type-only imports that never execute at runtime - Fixes coverage failure (99% -> 100%) --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 1570d47..a16dc6b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -130,6 +130,7 @@ exclude_lines = [ "raise AssertionError", "raise NotImplementedError", "if __name__ == .__main__.:", + "if TYPE_CHECKING:", ] # Keep for backward compatibility, but can be removed later From 6cf5f9fcf9a688781f2d965bf561bd30913612d8 Mon Sep 17 00:00:00 2001 From: "Jens W. Klein" Date: Fri, 7 Nov 2025 17:28:42 +0100 Subject: [PATCH 10/13] Fix coverage combine step to use installed venv - Use 'source venv/bin/activate' instead of 'uv run coverage' - uv run creates ephemeral environment without test dependencies - Use the venv created by make install which has coverage installed --- .github/workflows/test.yml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index bd79b2f..cf244bc 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -88,9 +88,10 @@ jobs: - name: Combine coverage & fail if it's <100% run: | - uv run coverage combine - uv run coverage html --skip-covered --skip-empty - uv run coverage report --fail-under=100 + source venv/bin/activate + coverage combine + coverage html --skip-covered --skip-empty + coverage report --fail-under=100 - name: Upload HTML report if check failed uses: actions/upload-artifact@v4 From aeb63c67c35c15bc10a3e7b3c35de4c3c59a3590 Mon Sep 17 00:00:00 2001 From: "Jens W. Klein" Date: Fri, 7 Nov 2025 17:36:41 +0100 Subject: [PATCH 11/13] Enable parallel coverage mode - Add 'parallel = true' to tool.coverage.run in pyproject.toml - Update Makefile COVERAGE_COMMAND to include 'coverage combine' - Fixes artifact upload: now creates .coverage.* files instead of single .coverage - Required for combining coverage from multiple test matrix runs --- Makefile | 1 + pyproject.toml | 1 + 2 files changed, 2 insertions(+) diff --git a/Makefile b/Makefile index 80de7c6..f39012d 100644 --- a/Makefile +++ b/Makefile @@ -172,6 +172,7 @@ COVERAGE_COMMAND?=\ coverage run \ --source webresource \ -m pytest tests \ + && coverage combine \ && coverage report --fail-under=100 ## qa.mypy diff --git a/pyproject.toml b/pyproject.toml index a16dc6b..b43bc4e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -121,6 +121,7 @@ addopts = [ [tool.coverage.run] source = ["webresource"] omit = ["webresource/_version.py", "tests/*/py"] +parallel = true [tool.coverage.report] fail_under = 100 From d6009e68aca01248fedef08738417b9738946d66 Mon Sep 17 00:00:00 2001 From: "Jens W. Klein" Date: Fri, 7 Nov 2025 17:39:25 +0100 Subject: [PATCH 12/13] Fix coverage combine to keep parallel files - Add --keep flag to 'coverage combine' in Makefile - Without --keep, combine deletes .coverage.* files after combining - Test jobs need .coverage.* files to upload as artifacts - Fixes 'No files were found with the provided path' error --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index f39012d..9e798ef 100644 --- a/Makefile +++ b/Makefile @@ -172,7 +172,7 @@ COVERAGE_COMMAND?=\ coverage run \ --source webresource \ -m pytest tests \ - && coverage combine \ + && coverage combine --keep \ && coverage report --fail-under=100 ## qa.mypy From 5cfcc1523e7bd11cda9b5e6fa9e0748131bfd79c Mon Sep 17 00:00:00 2001 From: "Jens W. Klein" Date: Fri, 7 Nov 2025 17:42:44 +0100 Subject: [PATCH 13/13] Fix cross-platform coverage with relative_files - Add 'relative_files = true' to tool.coverage.run - Makes coverage use relative paths instead of absolute paths - Fixes path mismatch when combining coverage from macOS/Windows/Ubuntu - Resolves 'No source for code' error when combining artifacts --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index b43bc4e..b632ccb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -122,6 +122,7 @@ addopts = [ source = ["webresource"] omit = ["webresource/_version.py", "tests/*/py"] parallel = true +relative_files = true [tool.coverage.report] fail_under = 100