From 01a6b6ceb753c2139a028edcf4f5844b7d57b159 Mon Sep 17 00:00:00 2001 From: Robert Niederreiter Date: Tue, 21 Oct 2025 13:32:59 +0200 Subject: [PATCH 01/17] Refactor package layout to modern Python packaging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Convert setup.py/setup.cfg to pyproject.toml - Implement PEP 420 implicit namespaces (remove namespace declarations) - Delete namespace-only __init__.py files - Add /build to .gitignore 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- pyproject.toml | 48 ++++++++++++++++++++++++++++++++++++++++++++++++ setup.cfg | 44 -------------------------------------------- setup.py | 4 ---- 3 files changed, 48 insertions(+), 48 deletions(-) create mode 100644 pyproject.toml delete mode 100644 setup.cfg delete mode 100644 setup.py diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..216f625 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,48 @@ +[build-system] +requires = ["setuptools>=61.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "webresource" +version = "1.3.dev0" +description = "A resource registry for web applications." +readme = {file = "README.rst", content-type = "text/x-rst"} +keywords = ["web", "resources", "dependencies", "javascript", "CSS"] +authors = [{name = "Conestack Constributors", email = "dev@conestack.org"}] +license = {text = "Simplified BSD"} +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Environment :: Web Environment", + "Intended Audience :: Developers", + "License :: OSI Approved :: BSD License", + "Operating System :: OS Independent", + "Programming Language :: Python", + "Programming Language :: Python :: 2.7", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Topic :: Internet :: WWW/HTTP :: Dynamic Content", +] + +[project.optional-dependencies] +docs = [ + "Sphinx", + "sphinx_conestack_theme", +] +test = [ + "coverage", +] + +[project.urls] +Documentation = "https://webresource.readthedocs.io/" +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.setuptools] +packages = ["webresource"] +include-package-data = true + +[tool.zest-releaser] +create-wheel = true diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 36efcb8..0000000 --- a/setup.cfg +++ /dev/null @@ -1,44 +0,0 @@ -[metadata] -version = 1.3.dev0 -name = webresource -description = A resource registry for web applications. -long_description = file: README.rst, docs/source/overview.rst, CHANGES.rst -keywords = web resources dependencies javascript CSS -author = Conestack Constributors -author_email = dev@conestack.org -license = Simplified BSD -license_files = file: LICENSE.rst -project_urls = - Documentation = https://webresource.readthedocs.io/ - 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 -classifiers = - Development Status :: 5 - Production/Stable - Environment :: Web Environment - Intended Audience :: Developers - License :: OSI Approved :: BSD License - Operating System :: OS Independent - Programming Language :: Python - Programming Language :: Python :: 2.7 - Programming Language :: Python :: 3.7 - Programming Language :: Python :: 3.8 - Programming Language :: Python :: 3.9 - Programming Language :: Python :: 3.10 - Topic :: Internet :: WWW/HTTP :: Dynamic Content - -[options] -packages = webresource -setup_requires = setuptools -include_package_data = True -zip_safe = False - -[options.extras_require] -docs = - Sphinx - sphinx_conestack_theme -test = - coverage - -[zest.releaser] -create-wheel = yes diff --git a/setup.py b/setup.py deleted file mode 100644 index b024da8..0000000 --- a/setup.py +++ /dev/null @@ -1,4 +0,0 @@ -from setuptools import setup - - -setup() From ca24984d68a2029349f2b4a859aefbe7cab6e985 Mon Sep 17 00:00:00 2001 From: Robert Niederreiter Date: Tue, 21 Oct 2025 15:12:00 +0200 Subject: [PATCH 02/17] Use hatchling instead of setuptools --- pyproject.toml | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 216f625..705e358 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [build-system] -requires = ["setuptools>=61.0"] -build-backend = "setuptools.build_meta" +requires = ["hatchling"] +build-backend = "hatchling.build" [project] name = "webresource" @@ -40,9 +40,5 @@ 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.setuptools] -packages = ["webresource"] -include-package-data = true - [tool.zest-releaser] create-wheel = true From 1f1c70d98e933dd6f90ece700217ab0acc5f045e Mon Sep 17 00:00:00 2001 From: Robert Niederreiter Date: Tue, 21 Oct 2025 15:49:43 +0200 Subject: [PATCH 03/17] add tool.hatch.build.targets.wheel sections --- pyproject.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 705e358..af538ca 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,5 +40,8 @@ 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.build.targets.wheel] +packages = ["webresource"] [tool.zest-releaser] create-wheel = true From d2c5de4a55aab177756f6674fb3f8306b7d24f3f Mon Sep 17 00:00:00 2001 From: Robert Niederreiter Date: Tue, 21 Oct 2025 16:09:40 +0200 Subject: [PATCH 04/17] Update makefile --- .github/workflows/test.yml | 4 +- Makefile | 175 ++++++++++++++++++++++++++----------- pyproject.toml | 7 +- 3 files changed, 128 insertions(+), 58 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2ee1cf9..6597c2f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -16,12 +16,12 @@ jobs: - macos-latest python: - - "3.7" - - "3.8" - "3.9" - "3.10" - "3.11" - "3.12" + - "3.13" + - "3.14" steps: - uses: actions/checkout@v4 diff --git a/Makefile b/Makefile index fa3e0a7..26de6a7 100644 --- a/Makefile +++ b/Makefile @@ -32,18 +32,42 @@ CLEAN_FS?= # Default: include.mk INCLUDE_MAKEFILE?=include.mk +# Optional additional directories to be added to PATH in format +# `/path/to/dir/:/path/to/other/dir`. Gets inserted first, thus gets searched +# first. +# No default value. +EXTRA_PATH?= + ## core.mxenv -# Python interpreter to use. +# Primary Python interpreter to use. It is used to create the +# virtual environment if `VENV_ENABLED` and `VENV_CREATE` are set to `true`. +# If global `uv` is used, this value is passed as `--python VALUE` to the venv creation. +# uv then downloads the Python interpreter if it is not available. +# for more on this feature read the [uv python documentation](https://docs.astral.sh/uv/concepts/python-versions/) # Default: python3 -PYTHON_BIN?=python3 +PRIMARY_PYTHON?=python3 # Minimum required Python version. -# Default: 3.7 -PYTHON_MIN_VERSION?=3.7 +# Default: 3.9 +PYTHON_MIN_VERSION?=3.9 + +# Install packages using the given package installer method. +# Supported are `pip` and `uv`. If uv is used, its global availability is +# checked. Otherwise, it is installed, either in the virtual environment or +# using the `PRIMARY_PYTHON`, dependent on the `VENV_ENABLED` setting. If +# `VENV_ENABLED` and uv is selected, uv is used to create the virtual +# environment. +# Default: pip +PYTHON_PACKAGE_INSTALLER?=uv + +# Flag whether to use a global installed 'uv' or install +# it in the virtual environment. +# Default: false +MXENV_UV_GLOBAL?=false # Flag whether to use virtual environment. If `false`, the -# interpreter according to `PYTHON_BIN` found in `PATH` is used. +# interpreter according to `PRIMARY_PYTHON` found in `PATH` is used. # Default: true VENV_ENABLED?=true @@ -58,7 +82,7 @@ VENV_CREATE?=true # target folder for the virtual environment. If `VENV_ENABLED` is `true` and # `VENV_CREATE` is false it is expected to point to an existing virtual # environment. If `VENV_ENABLED` is `false` it is ignored. -# Default: venv +# Default: .venv VENV_FOLDER?=venv # mxdev to install in virtual environment. @@ -89,6 +113,13 @@ DOCS_REQUIREMENTS?= # Default: mx.ini PROJECT_CONFIG?=mx.ini +## core.packages + +# Allow prerelease and development versions. +# By default, the package installer only finds stable versions. +# Default: false +PACKAGES_ALLOW_PRERELEASES?=false + ## qa.test # The command which gets executed. Defaults to the location the @@ -128,6 +159,8 @@ CHECK_TARGETS?= TYPECHECK_TARGETS?= FORMAT_TARGETS?= +export PATH:=$(if $(EXTRA_PATH),$(EXTRA_PATH):,)$(PATH) + # Defensive settings for make: https://tech.davis-hansson.com/p/make/ SHELL:=bash .ONESHELL: @@ -144,7 +177,7 @@ MXMAKE_FOLDER?=.mxmake # Sentinel files SENTINEL_FOLDER?=$(MXMAKE_FOLDER)/sentinels SENTINEL?=$(SENTINEL_FOLDER)/about.txt -$(SENTINEL): +$(SENTINEL): $(firstword $(MAKEFILE_LIST)) @mkdir -p $(SENTINEL_FOLDER) @echo "Sentinels for the Makefile process." > $(SENTINEL) @@ -152,40 +185,62 @@ $(SENTINEL): # mxenv ############################################################################## -# Check if given Python is installed -ifeq (,$(shell which $(PYTHON_BIN))) -$(error "PYTHON=$(PYTHON_BIN) not found in $(PATH)") -endif +OS?= -# Check if given Python version is ok -PYTHON_VERSION_OK=$(shell $(PYTHON_BIN) -c "import sys; print((int(sys.version_info[0]), int(sys.version_info[1])) >= tuple(map(int, '$(PYTHON_MIN_VERSION)'.split('.'))))") -ifeq ($(PYTHON_VERSION_OK),0) -$(error "Need Python >= $(PYTHON_MIN_VERSION)") +# Determine the executable path +ifeq ("$(VENV_ENABLED)", "true") +export VIRTUAL_ENV=$(abspath $(VENV_FOLDER)) +ifeq ("$(OS)", "Windows_NT") +VENV_EXECUTABLE_FOLDER=$(VIRTUAL_ENV)/Scripts +else +VENV_EXECUTABLE_FOLDER=$(VIRTUAL_ENV)/bin endif - -# Check if venv folder is configured if venv is enabled -ifeq ($(shell [[ "$(VENV_ENABLED)" == "true" && "$(VENV_FOLDER)" == "" ]] && echo "true"),"true") -$(error "VENV_FOLDER must be configured if VENV_ENABLED is true") +export PATH:=$(VENV_EXECUTABLE_FOLDER):$(PATH) +MXENV_PYTHON=python +else +MXENV_PYTHON=$(PRIMARY_PYTHON) endif -# determine the executable path -ifeq ("$(VENV_ENABLED)", "true") -MXENV_PATH=$(VENV_FOLDER)/bin/ +# Determine the package installer +ifeq ("$(PYTHON_PACKAGE_INSTALLER)","uv") +PYTHON_PACKAGE_COMMAND=uv pip else -MXENV_PATH= +PYTHON_PACKAGE_COMMAND=$(MXENV_PYTHON) -m pip endif MXENV_TARGET:=$(SENTINEL_FOLDER)/mxenv.sentinel $(MXENV_TARGET): $(SENTINEL) +ifneq ("$(PYTHON_PACKAGE_INSTALLER)$(MXENV_UV_GLOBAL)","uvfalse") + @$(PRIMARY_PYTHON) -c "import sys; vi = sys.version_info; sys.exit(1 if (int(vi[0]), int(vi[1])) >= tuple(map(int, '$(PYTHON_MIN_VERSION)'.split('.'))) else 0)" \ + && echo "Need Python >= $(PYTHON_MIN_VERSION)" && exit 1 || : +else + @echo "Use Python $(PYTHON_MIN_VERSION) over uv" +endif + @[[ "$(VENV_ENABLED)" == "true" && "$(VENV_FOLDER)" == "" ]] \ + && echo "VENV_FOLDER must be configured if VENV_ENABLED is true" && exit 1 || : + @[[ "$(VENV_ENABLED)$(PYTHON_PACKAGE_INSTALLER)" == "falseuv" ]] \ + && echo "Package installer uv does not work with a global Python interpreter." && exit 1 || : ifeq ("$(VENV_ENABLED)", "true") ifeq ("$(VENV_CREATE)", "true") - @echo "Setup Python Virtual Environment under '$(VENV_FOLDER)'" - @$(PYTHON_BIN) -m venv $(VENV_FOLDER) +ifeq ("$(PYTHON_PACKAGE_INSTALLER)$(MXENV_UV_GLOBAL)","uvtrue") + @echo "Setup Python Virtual Environment using package 'uv' at '$(VENV_FOLDER)'" + @uv venv -p $(PRIMARY_PYTHON) --seed $(VENV_FOLDER) +else + @echo "Setup Python Virtual Environment using module 'venv' at '$(VENV_FOLDER)'" + @$(PRIMARY_PYTHON) -m venv $(VENV_FOLDER) + @$(MXENV_PYTHON) -m ensurepip -U +endif +endif +else + @echo "Using system Python interpreter" endif +ifeq ("$(PYTHON_PACKAGE_INSTALLER)$(MXENV_UV_GLOBAL)","uvfalse") + @echo "Install uv" + @$(MXENV_PYTHON) -m pip install uv endif - @$(MXENV_PATH)pip install -U pip setuptools wheel - @$(MXENV_PATH)pip install -U $(MXDEV) - @$(MXENV_PATH)pip install -U $(MXMAKE) + @$(PYTHON_PACKAGE_COMMAND) install -U pip setuptools wheel + @echo "Install/Update MXStack Python packages" + @$(PYTHON_PACKAGE_COMMAND) install -U $(MXDEV) $(MXMAKE) @touch $(MXENV_TARGET) .PHONY: mxenv @@ -202,8 +257,8 @@ ifeq ("$(VENV_CREATE)", "true") @rm -rf $(VENV_FOLDER) endif else - @$(MXENV_PATH)pip uninstall -y $(MXDEV) - @$(MXENV_PATH)pip uninstall -y $(MXMAKE) + @$(PYTHON_PACKAGE_COMMAND) uninstall -y $(MXDEV) + @$(PYTHON_PACKAGE_COMMAND) uninstall -y $(MXMAKE) endif INSTALL_TARGETS+=mxenv @@ -217,13 +272,13 @@ CLEAN_TARGETS+=mxenv-clean # additional targets required for building docs. DOCS_TARGETS+= -SPHINX_BIN=$(MXENV_PATH)sphinx-build -SPHINX_AUTOBUILD_BIN=$(MXENV_PATH)sphinx-autobuild +SPHINX_BIN=sphinx-build +SPHINX_AUTOBUILD_BIN=sphinx-autobuild DOCS_TARGET:=$(SENTINEL_FOLDER)/sphinx.sentinel $(DOCS_TARGET): $(MXENV_TARGET) @echo "Install Sphinx" - @$(MXENV_PATH)pip install -U sphinx sphinx-autobuild $(DOCS_REQUIREMENTS) + @$(PYTHON_PACKAGE_COMMAND) install -U sphinx sphinx-autobuild $(DOCS_REQUIREMENTS) @touch $(DOCS_TARGET) .PHONY: docs @@ -242,6 +297,8 @@ docs-dirty: .PHONY: docs-clean docs-clean: docs-dirty + @test -e $(MXENV_PYTHON) && $(MXENV_PYTHON) -m pip uninstall -y \ + sphinx sphinx-autobuild $(DOCS_REQUIREMENTS) || : @rm -rf $(DOCS_TARGET_FOLDER) INSTALL_TARGETS+=$(DOCS_TARGET) @@ -260,13 +317,11 @@ MXMAKE_FILES?=$(MXMAKE_FOLDER)/files # set environment variables for mxmake define set_mxfiles_env - @export MXMAKE_MXENV_PATH=$(1) - @export MXMAKE_FILES=$(2) + @export MXMAKE_FILES=$(1) endef # unset environment variables for mxmake define unset_mxfiles_env - @unset MXMAKE_MXENV_PATH @unset MXMAKE_FILES endef @@ -283,9 +338,9 @@ FILES_TARGET:=requirements-mxdev.txt $(FILES_TARGET): $(PROJECT_CONFIG) $(MXENV_TARGET) $(SOURCES_TARGET) $(LOCAL_PACKAGE_FILES) @echo "Create project files" @mkdir -p $(MXMAKE_FILES) - $(call set_mxfiles_env,$(MXENV_PATH),$(MXMAKE_FILES)) - @$(MXENV_PATH)mxdev -n -c $(PROJECT_CONFIG) - $(call unset_mxfiles_env,$(MXENV_PATH),$(MXMAKE_FILES)) + $(call set_mxfiles_env,$(MXMAKE_FILES)) + @mxdev -n -c $(PROJECT_CONFIG) + $(call unset_mxfiles_env) @test -e $(MXMAKE_FILES)/pip.conf && cp $(MXMAKE_FILES)/pip.conf $(VENV_FOLDER)/pip.conf || : @touch $(FILES_TARGET) @@ -314,11 +369,21 @@ ADDITIONAL_SOURCES_TARGETS?= INSTALLED_PACKAGES=$(MXMAKE_FILES)/installed.txt +ifeq ("$(PACKAGES_ALLOW_PRERELEASES)","true") +ifeq ("$(PYTHON_PACKAGE_INSTALLER)","uv") +PACKAGES_PRERELEASES=--prerelease=allow +else +PACKAGES_PRERELEASES=--pre +endif +else +PACKAGES_PRERELEASES= +endif + PACKAGES_TARGET:=$(INSTALLED_PACKAGES) $(PACKAGES_TARGET): $(FILES_TARGET) $(ADDITIONAL_SOURCES_TARGETS) @echo "Install python packages" - @$(MXENV_PATH)pip install -r $(FILES_TARGET) - @$(MXENV_PATH)pip freeze > $(INSTALLED_PACKAGES) + @$(PYTHON_PACKAGE_COMMAND) install $(PACKAGES_PRERELEASES) -r $(FILES_TARGET) + @$(PYTHON_PACKAGE_COMMAND) freeze > $(INSTALLED_PACKAGES) @touch $(PACKAGES_TARGET) .PHONY: packages @@ -331,8 +396,8 @@ packages-dirty: .PHONY: packages-clean packages-clean: @test -e $(FILES_TARGET) \ - && test -e $(MXENV_PATH)pip \ - && $(MXENV_PATH)pip uninstall -y -r $(FILES_TARGET) \ + && test -e $(MXENV_PYTHON) \ + && $(MXENV_PYTHON) -m pip uninstall -y -r $(FILES_TARGET) \ || : @rm -f $(PACKAGES_TARGET) @@ -347,14 +412,14 @@ CLEAN_TARGETS+=packages-clean TEST_TARGET:=$(SENTINEL_FOLDER)/test.sentinel $(TEST_TARGET): $(MXENV_TARGET) @echo "Install $(TEST_REQUIREMENTS)" - @$(MXENV_PATH)pip install $(TEST_REQUIREMENTS) + @$(PYTHON_PACKAGE_COMMAND) install $(TEST_REQUIREMENTS) @touch $(TEST_TARGET) .PHONY: test test: $(FILES_TARGET) $(SOURCES_TARGET) $(PACKAGES_TARGET) $(TEST_TARGET) $(TEST_DEPENDENCY_TARGETS) - @echo "Run tests" - @test -z "$(TEST_COMMAND)" && echo "No test command defined" - @test -z "$(TEST_COMMAND)" || bash -c "$(TEST_COMMAND)" + @test -z "$(TEST_COMMAND)" && echo "No test command defined" && exit 1 || : + @echo "Run tests using $(TEST_COMMAND)" + @/usr/bin/env bash -c "$(TEST_COMMAND)" .PHONY: test-dirty test-dirty: @@ -362,7 +427,7 @@ test-dirty: .PHONY: test-clean test-clean: test-dirty - @test -e $(MXENV_PATH)pip && $(MXENV_PATH)pip uninstall -y $(TEST_REQUIREMENTS) || : + @test -e $(MXENV_PYTHON) && $(MXENV_PYTHON) -m pip uninstall -y $(TEST_REQUIREMENTS) || : @rm -rf .pytest_cache INSTALL_TARGETS+=$(TEST_TARGET) @@ -376,14 +441,14 @@ DIRTY_TARGETS+=test-dirty COVERAGE_TARGET:=$(SENTINEL_FOLDER)/coverage.sentinel $(COVERAGE_TARGET): $(TEST_TARGET) @echo "Install Coverage" - @$(MXENV_PATH)pip install -U coverage + @$(PYTHON_PACKAGE_COMMAND) install -U coverage @touch $(COVERAGE_TARGET) .PHONY: coverage coverage: $(FILES_TARGET) $(SOURCES_TARGET) $(PACKAGES_TARGET) $(COVERAGE_TARGET) - @echo "Run coverage" - @test -z "$(COVERAGE_COMMAND)" && echo "No coverage command defined" - @test -z "$(COVERAGE_COMMAND)" || bash -c "$(COVERAGE_COMMAND)" + @test -z "$(COVERAGE_COMMAND)" && echo "No coverage command defined" && exit 1 || : + @echo "Run coverage using $(COVERAGE_COMMAND)" + @/usr/bin/env bash -c "$(COVERAGE_COMMAND)" .PHONY: coverage-dirty coverage-dirty: @@ -391,13 +456,17 @@ coverage-dirty: .PHONY: coverage-clean coverage-clean: coverage-dirty - @test -e $(MXENV_PATH)pip && $(MXENV_PATH)pip uninstall -y coverage || : + @test -e $(MXENV_PYTHON) && $(MXENV_PYTHON) -m pip uninstall -y coverage || : @rm -rf .coverage htmlcov INSTALL_TARGETS+=$(COVERAGE_TARGET) DIRTY_TARGETS+=coverage-dirty CLEAN_TARGETS+=coverage-clean +############################################################################## +# Custom includes +############################################################################## + -include $(INCLUDE_MAKEFILE) ############################################################################## diff --git a/pyproject.toml b/pyproject.toml index af538ca..65e6d35 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,11 +17,12 @@ classifiers = [ "License :: OSI Approved :: BSD License", "Operating System :: OS Independent", "Programming Language :: Python", - "Programming Language :: Python :: 2.7", - "Programming Language :: Python :: 3.7", - "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "Topic :: Internet :: WWW/HTTP :: Dynamic Content", ] From 4a7e3e4bc3b6b8dcfa6292e67709b4813956ebdd Mon Sep 17 00:00:00 2001 From: Robert Niederreiter Date: Tue, 21 Oct 2025 16:20:28 +0200 Subject: [PATCH 05/17] Update Copyright Year --- LICENSE.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/LICENSE.rst b/LICENSE.rst index 6290aed..ad6e84c 100644 --- a/LICENSE.rst +++ b/LICENSE.rst @@ -1,7 +1,7 @@ License ======= -Copyright (c) 2021-2024, Cone Contributors +Copyright (c) 2021-2025, Cone Contributors All rights reserved. Redistribution and use in source and binary forms, with or without From 1643dfe8095305cc54036255e905605c58e940b1 Mon Sep 17 00:00:00 2001 From: Robert Niederreiter Date: Tue, 21 Oct 2025 16:21:44 +0200 Subject: [PATCH 06/17] Update changelog --- CHANGES.rst | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGES.rst b/CHANGES.rst index 919aac4..9d4266a 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -4,6 +4,9 @@ Changelog 1.3 (unreleased) ---------------- +- Official support from Python 3.9 to 3.14. + [rnix] + - Do not wrap resource ``__repr__`` output in ``<>`` to render tracebacks properly in browser. [lenadax] From f65a100dbe30f603446d510301ae83b4540ba7f6 Mon Sep 17 00:00:00 2001 From: Robert Niederreiter Date: Tue, 21 Oct 2025 16:50:07 +0200 Subject: [PATCH 07/17] include manifest info to pyproject.toml --- pyproject.toml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 65e6d35..35ee8a3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,8 +41,12 @@ 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.build.targets.sdist] +exclude = ["/docs"] +include = ["docs/source/overview.rst"] [tool.hatch.build.targets.wheel] packages = ["webresource"] + [tool.zest-releaser] create-wheel = true From a69d1e127735832c4c71596b52d7592fe5e8cc71 Mon Sep 17 00:00:00 2001 From: Robert Niederreiter Date: Wed, 22 Oct 2025 14:54:03 +0200 Subject: [PATCH 08/17] Remove MANIFEST.in and update to Python 3.10-3.14 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove obsolete MANIFEST.in file (now using hatchling) - Update Python classifiers to 3.10-3.14 - Update GitHub Actions workflows to test Python 3.10-3.14 - Remove PyPy from workflows 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .github/workflows/test.yml | 1 - MANIFEST.in | 8 -------- pyproject.toml | 6 +++++- 3 files changed, 5 insertions(+), 10 deletions(-) delete mode 100644 MANIFEST.in diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6597c2f..1ca16b1 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -16,7 +16,6 @@ jobs: - macos-latest python: - - "3.9" - "3.10" - "3.11" - "3.12" diff --git a/MANIFEST.in b/MANIFEST.in deleted file mode 100644 index d49ff79..0000000 --- a/MANIFEST.in +++ /dev/null @@ -1,8 +0,0 @@ -exclude *.ini -exclude *.txt -include *.rst -recursive-include src * -recursive-exclude src *.pyc *.pyo -recursive-exclude docs * -include docs/source/overview.rst -prune scripts diff --git a/pyproject.toml b/pyproject.toml index 35ee8a3..e6a0acd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,7 +17,11 @@ classifiers = [ "License :: OSI Approved :: BSD License", "Operating System :: OS Independent", "Programming Language :: Python", - "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", From 4f3fd504b4aa54530fce65d18692de09c4d4ece0 Mon Sep 17 00:00:00 2001 From: Robert Niederreiter Date: Mon, 3 Nov 2025 11:34:44 +0100 Subject: [PATCH 09/17] Update package versions. --- CHANGES.rst | 4 ++-- pyproject.toml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 9d4266a..70bc2aa 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,8 +1,8 @@ Changelog ========= -1.3 (unreleased) ----------------- +1.3.0 (unreleased) +------------------ - Official support from Python 3.9 to 3.14. [rnix] diff --git a/pyproject.toml b/pyproject.toml index e6a0acd..0c29bbc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "webresource" -version = "1.3.dev0" +version = "1.3.0.dev0" description = "A resource registry for web applications." readme = {file = "README.rst", content-type = "text/x-rst"} keywords = ["web", "resources", "dependencies", "javascript", "CSS"] From 8bd6d265bf0b3e6a3ccf7fab09442003de82e743 Mon Sep 17 00:00:00 2001 From: Robert Niederreiter Date: Tue, 4 Nov 2025 08:45:17 +0100 Subject: [PATCH 10/17] Move tests to tests folder on package root level --- .github/workflows/test.yml | 18 ++- .gitignore | 3 +- Makefile | 107 +++++++++++++----- pyproject.toml | 6 +- .../tests.py => tests/test_webresource.py | 0 5 files changed, 91 insertions(+), 43 deletions(-) rename webresource/tests.py => tests/test_webresource.py (100%) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1ca16b1..0a0ee63 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -23,22 +23,18 @@ jobs: - "3.14" steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Set up Python uses: actions/setup-python@v5 with: python-version: ${{ matrix.python }} - - name: Install - run: pip install -e .[test] + - name: Show Python version + run: python -c "import sys; print(sys.version)" - - name: Run tests - run: | - python --version - python -m webresource.tests + - name: Install environment + run: make install - - name: Run coverage - run: | - coverage run --source webresource -m webresource.tests - coverage report --fail-under=100 + - name: Run tests an collect code coverage + run: make coverage diff --git a/.gitignore b/.gitignore index 43b5e87..25243c7 100644 --- a/.gitignore +++ b/.gitignore @@ -3,9 +3,10 @@ /.coverage /.mxmake /.ruff_cache +/CLAUDE.md /build /dist/ /docs/html/ /htmlcov/ /requirements-mxdev.txt -/venv +/venv/ diff --git a/Makefile b/Makefile index 26de6a7..02fc0a0 100644 --- a/Makefile +++ b/Makefile @@ -38,6 +38,13 @@ INCLUDE_MAKEFILE?=include.mk # No default value. EXTRA_PATH?= +# Path to Python project relative to Makefile (repository root). +# Leave empty if Python project is in the same directory as Makefile. +# For monorepo setups, set to subdirectory name (e.g., `backend`). +# Future-proofed for multi-language monorepos (e.g., PROJECT_PATH_NODEJS). +# No default value. +PROJECT_PATH_PYTHON?= + ## core.mxenv # Primary Python interpreter to use. It is used to create the @@ -49,22 +56,23 @@ EXTRA_PATH?= PRIMARY_PYTHON?=python3 # Minimum required Python version. -# Default: 3.9 -PYTHON_MIN_VERSION?=3.9 +# Default: 3.10 +PYTHON_MIN_VERSION?=3.10 # Install packages using the given package installer method. -# Supported are `pip` and `uv`. If uv is used, its global availability is -# checked. Otherwise, it is installed, either in the virtual environment or -# using the `PRIMARY_PYTHON`, dependent on the `VENV_ENABLED` setting. If -# `VENV_ENABLED` and uv is selected, uv is used to create the virtual -# environment. +# Supported are `pip` and `uv`. When `uv` is selected, a global installation +# is auto-detected and used if available. Otherwise, uv is installed in the +# virtual environment or using `PRIMARY_PYTHON`, depending on the +# `VENV_ENABLED` setting. # Default: pip PYTHON_PACKAGE_INSTALLER?=uv -# Flag whether to use a global installed 'uv' or install -# it in the virtual environment. -# Default: false -MXENV_UV_GLOBAL?=false +# Python version for UV to install/use when creating virtual +# environments with global UV. Passed to `uv venv -p VALUE`. Supports version +# specs like `3.11`, `3.14`, `cpython@3.14`. Defaults to PRIMARY_PYTHON value +# for backward compatibility. +# Default: $(PRIMARY_PYTHON) +UV_PYTHON?=$(PRIMARY_PYTHON) # Flag whether to use virtual environment. If `false`, the # interpreter according to `PRIMARY_PYTHON` found in `PATH` is used. @@ -103,6 +111,10 @@ DOCS_SOURCE_FOLDER?=docs/source # Default: docs/html DOCS_TARGET_FOLDER?=docs/html +# Documentation linkcheck output folder. +# Default: docs/linkcheck +DOCS_LINKCHECK_FOLDER?=docs/linkcheck + # Documentation Python requirements to be installed (via pip). # No default value. DOCS_REQUIREMENTS?= @@ -125,7 +137,7 @@ PACKAGES_ALLOW_PRERELEASES?=false # The command which gets executed. Defaults to the location the # :ref:`run-tests` template gets rendered to if configured. # Default: .mxmake/files/run-tests.sh -TEST_COMMAND?=$(VENV_FOLDER)/bin/python -m webresource.tests +TEST_COMMAND?=pytest tests # Additional Python requirements for running tests to be # installed (via pip). @@ -142,10 +154,10 @@ TEST_DEPENDENCY_TARGETS?= # :ref:`run-coverage` template gets rendered to if configured. # Default: .mxmake/files/run-coverage.sh COVERAGE_COMMAND?=\ - $(VENV_FOLDER)/bin/coverage run \ + coverage run \ --source webresource \ - -m webresource.tests \ - && $(VENV_FOLDER)/bin/coverage report --fail-under=100 + -m pytest tests \ + && coverage report --fail-under=100 ############################################################################## # END SETTINGS - DO NOT EDIT BELOW THIS LINE @@ -161,6 +173,9 @@ FORMAT_TARGETS?= export PATH:=$(if $(EXTRA_PATH),$(EXTRA_PATH):,)$(PATH) +# Helper variable: adds trailing slash to PROJECT_PATH_PYTHON only if non-empty +PYTHON_PROJECT_PREFIX=$(if $(PROJECT_PATH_PYTHON),$(PROJECT_PATH_PYTHON)/,) + # Defensive settings for make: https://tech.davis-hansson.com/p/make/ SHELL:=bash .ONESHELL: @@ -201,30 +216,61 @@ else MXENV_PYTHON=$(PRIMARY_PYTHON) endif -# Determine the package installer +# Determine the package installer with non-interactive flags ifeq ("$(PYTHON_PACKAGE_INSTALLER)","uv") -PYTHON_PACKAGE_COMMAND=uv pip +PYTHON_PACKAGE_COMMAND=uv pip --no-progress else PYTHON_PACKAGE_COMMAND=$(MXENV_PYTHON) -m pip endif +# Auto-detect global uv availability (simple existence check) +ifeq ("$(PYTHON_PACKAGE_INSTALLER)","uv") +UV_AVAILABLE:=$(shell command -v uv >/dev/null 2>&1 && echo "true" || echo "false") +else +UV_AVAILABLE:=false +endif + +# Determine installation strategy +# depending on the PYTHON_PACKAGE_INSTALLER and UV_AVAILABLE +# - both vars can be false or +# - one of them can be true, +# - but never boths. +USE_GLOBAL_UV:=$(shell [[ "$(PYTHON_PACKAGE_INSTALLER)" == "uv" && "$(UV_AVAILABLE)" == "true" ]] && echo "true" || echo "false") +USE_LOCAL_UV:=$(shell [[ "$(PYTHON_PACKAGE_INSTALLER)" == "uv" && "$(UV_AVAILABLE)" == "false" ]] && echo "true" || echo "false") + +# Check if global UV is outdated (non-blocking warning) +ifeq ("$(USE_GLOBAL_UV)","true") +UV_OUTDATED:=$(shell uv self update --dry-run 2>&1 | grep -q "Would update" && echo "true" || echo "false") +else +UV_OUTDATED:=false +endif + MXENV_TARGET:=$(SENTINEL_FOLDER)/mxenv.sentinel $(MXENV_TARGET): $(SENTINEL) -ifneq ("$(PYTHON_PACKAGE_INSTALLER)$(MXENV_UV_GLOBAL)","uvfalse") + # Validation: Check Python version if not using global uv +ifneq ("$(USE_GLOBAL_UV)","true") @$(PRIMARY_PYTHON) -c "import sys; vi = sys.version_info; sys.exit(1 if (int(vi[0]), int(vi[1])) >= tuple(map(int, '$(PYTHON_MIN_VERSION)'.split('.'))) else 0)" \ && echo "Need Python >= $(PYTHON_MIN_VERSION)" && exit 1 || : else - @echo "Use Python $(PYTHON_MIN_VERSION) over uv" + @echo "Using global uv for Python $(UV_PYTHON)" endif + # Validation: Check VENV_FOLDER is set if venv enabled @[[ "$(VENV_ENABLED)" == "true" && "$(VENV_FOLDER)" == "" ]] \ && echo "VENV_FOLDER must be configured if VENV_ENABLED is true" && exit 1 || : - @[[ "$(VENV_ENABLED)$(PYTHON_PACKAGE_INSTALLER)" == "falseuv" ]] \ + # Validation: Check uv not used with system Python + @[[ "$(VENV_ENABLED)" == "false" && "$(PYTHON_PACKAGE_INSTALLER)" == "uv" ]] \ && echo "Package installer uv does not work with a global Python interpreter." && exit 1 || : + # Warning: Notify if global UV is outdated +ifeq ("$(UV_OUTDATED)","true") + @echo "WARNING: A newer version of uv is available. Run 'uv self update' to upgrade." +endif + + # Create virtual environment ifeq ("$(VENV_ENABLED)", "true") ifeq ("$(VENV_CREATE)", "true") -ifeq ("$(PYTHON_PACKAGE_INSTALLER)$(MXENV_UV_GLOBAL)","uvtrue") - @echo "Setup Python Virtual Environment using package 'uv' at '$(VENV_FOLDER)'" - @uv venv -p $(PRIMARY_PYTHON) --seed $(VENV_FOLDER) +ifeq ("$(USE_GLOBAL_UV)","true") + @echo "Setup Python Virtual Environment using global uv at '$(VENV_FOLDER)'" + @uv venv --allow-existing --no-progress -p $(UV_PYTHON) --seed $(VENV_FOLDER) else @echo "Setup Python Virtual Environment using module 'venv' at '$(VENV_FOLDER)'" @$(PRIMARY_PYTHON) -m venv $(VENV_FOLDER) @@ -234,10 +280,14 @@ endif else @echo "Using system Python interpreter" endif -ifeq ("$(PYTHON_PACKAGE_INSTALLER)$(MXENV_UV_GLOBAL)","uvfalse") - @echo "Install uv" + + # Install uv locally if needed +ifeq ("$(USE_LOCAL_UV)","true") + @echo "Install uv in virtual environment" @$(MXENV_PYTHON) -m pip install uv endif + + # Install/upgrade core packages @$(PYTHON_PACKAGE_COMMAND) install -U pip setuptools wheel @echo "Install/Update MXStack Python packages" @$(PYTHON_PACKAGE_COMMAND) install -U $(MXDEV) $(MXMAKE) @@ -291,6 +341,11 @@ docs-live: $(DOCS_TARGET) $(DOCS_TARGETS) @echo "Rebuild Sphinx documentation on changes, with live-reload in the browser" @$(SPHINX_AUTOBUILD_BIN) $(DOCS_SOURCE_FOLDER) $(DOCS_TARGET_FOLDER) +.PHONY: docs-linkcheck +docs-linkcheck: $(DOCS_TARGET) $(DOCS_TARGETS) + @echo "Run Sphinx linkcheck" + @$(SPHINX_BIN) -b linkcheck $(DOCS_SOURCE_FOLDER) $(DOCS_LINKCHECK_FOLDER) + .PHONY: docs-dirty docs-dirty: @rm -f $(DOCS_TARGET) @@ -332,7 +387,7 @@ else @echo "[settings]" > $(PROJECT_CONFIG) endif -LOCAL_PACKAGE_FILES:=$(wildcard pyproject.toml setup.cfg setup.py requirements.txt constraints.txt) +LOCAL_PACKAGE_FILES:=$(wildcard $(PYTHON_PROJECT_PREFIX)pyproject.toml $(PYTHON_PROJECT_PREFIX)setup.cfg $(PYTHON_PROJECT_PREFIX)setup.py $(PYTHON_PROJECT_PREFIX)requirements.txt $(PYTHON_PROJECT_PREFIX)constraints.txt) FILES_TARGET:=requirements-mxdev.txt $(FILES_TARGET): $(PROJECT_CONFIG) $(MXENV_TARGET) $(SOURCES_TARGET) $(LOCAL_PACKAGE_FILES) diff --git a/pyproject.toml b/pyproject.toml index 0c29bbc..2f037b0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,11 +22,6 @@ classifiers = [ "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", - "Programming Language :: Python :: 3.10", - "Programming Language :: Python :: 3.11", - "Programming Language :: Python :: 3.12", - "Programming Language :: Python :: 3.13", - "Programming Language :: Python :: 3.14", "Topic :: Internet :: WWW/HTTP :: Dynamic Content", ] @@ -37,6 +32,7 @@ docs = [ ] test = [ "coverage", + "pytest", ] [project.urls] diff --git a/webresource/tests.py b/tests/test_webresource.py similarity index 100% rename from webresource/tests.py rename to tests/test_webresource.py From a41012256d47b0358776093f0e294db6f2ac9358 Mon Sep 17 00:00:00 2001 From: Robert Niederreiter Date: Tue, 4 Nov 2025 08:59:30 +0100 Subject: [PATCH 11/17] Split up _api.py to deparate modules. --- tests/test_webresource.py | 10 +- webresource/__init__.py | 20 +- webresource/base.py | 65 ++++++ webresource/config.py | 24 ++ webresource/exceptions.py | 30 +++ webresource/groups.py | 97 ++++++++ webresource/renderer.py | 41 ++++ webresource/resolver.py | 92 ++++++++ webresource/{_api.py => resources.py} | 325 +------------------------- 9 files changed, 367 insertions(+), 337 deletions(-) create mode 100644 webresource/base.py create mode 100644 webresource/config.py create mode 100644 webresource/exceptions.py create mode 100644 webresource/groups.py create mode 100644 webresource/renderer.py create mode 100644 webresource/resolver.py rename webresource/{_api.py => resources.py} (63%) diff --git a/tests/test_webresource.py b/tests/test_webresource.py index 71f18c3..0a2515a 100644 --- a/tests/test_webresource.py +++ b/tests/test_webresource.py @@ -1,12 +1,8 @@ # -*- coding: utf-8 -*- from collections import Counter -from webresource._api import ( - is_py3, - LinkMixin, - Resource, - ResourceConfig, - ResourceMixin -) +from webresource.base import ResourceMixin +from webresource.config import is_py3, ResourceConfig +from webresource.resources import LinkMixin, Resource import os import shutil import tempfile diff --git a/webresource/__init__.py b/webresource/__init__.py index a30f86b..bb4cf79 100644 --- a/webresource/__init__.py +++ b/webresource/__init__.py @@ -1,15 +1,19 @@ -from webresource._api import ( # noqa - config, - LinkResource, - Resource, +from webresource.config import config # noqa +from webresource.exceptions import ( # noqa ResourceCircularDependencyError, ResourceConflictError, ResourceError, - ResourceGroup, - ResourceMissingDependencyError, - ResourceRenderer, + ResourceMissingDependencyError +) +from webresource.groups import ResourceGroup # noqa +from webresource.renderer import ( # noqa GracefulResourceRenderer, - ResourceResolver, + ResourceRenderer +) +from webresource.resolver import ResourceResolver # noqa +from webresource.resources import ( # noqa + LinkResource, + Resource, ScriptResource, StyleResource ) diff --git a/webresource/base.py b/webresource/base.py new file mode 100644 index 0000000..3df95ce --- /dev/null +++ b/webresource/base.py @@ -0,0 +1,65 @@ +import copy +import os + +from webresource.exceptions import ResourceError + + +class ResourceMixin(object): + """Mixin for ``Resource`` and ``ResourceGroup``.""" + + def __init__( + self, name='', directory=None, path=None, include=True, group=None + ): + self.name = name + self.directory = directory + self.path = path + self.include = include + self.parent = None + if group: + group.add(self) + + @property + def path(self): + if self._path is not None: + return self._path + if self.parent is not None: + return self.parent.path + + @path.setter + def path(self, path): + self._path = path + + @property + def directory(self): + if self._directory is not None: + return self._directory + if self.parent is not None: + return self.parent.directory + + @directory.setter + def directory(self, directory): + if directory is None: + self._directory = None + return + self._directory = os.path.abspath(directory) + + @property + def include(self): + if callable(self._include): + return self._include() + return self._include + + @include.setter + def include(self, include): + self._include = include + + def remove(self): + """Remove resource or resource group from parent group.""" + if not self.parent: + raise ResourceError('Object is no member of a resource group') + self.parent.members.remove(self) + self.parent = None + + def copy(self): + """Return a deep copy of this object.""" + return copy.deepcopy(self) diff --git a/webresource/config.py b/webresource/config.py new file mode 100644 index 0000000..1a25780 --- /dev/null +++ b/webresource/config.py @@ -0,0 +1,24 @@ +import logging +import sys +import uuid + + +try: + FileNotFoundError +except NameError: # pragma: nocover + FileNotFoundError = EnvironmentError + + +logger = logging.getLogger(__name__) +is_py3 = sys.version_info[0] >= 3 +namespace_uuid = uuid.UUID('f3341b2e-f97e-40d2-ad2f-10a08a778877') + + +class ResourceConfig(object): + """Config singleton for web resources.""" + + def __init__(self): + self.development = False + + +config = ResourceConfig() diff --git a/webresource/exceptions.py b/webresource/exceptions.py new file mode 100644 index 0000000..cc083bc --- /dev/null +++ b/webresource/exceptions.py @@ -0,0 +1,30 @@ +class ResourceError(ValueError): + """Resource related exception.""" + + +class ResourceConflictError(ResourceError): + """Multiple resources declared with the same name.""" + + def __init__(self, counter): + conflicting = list() + for name, count in counter.items(): + if count > 1: + conflicting.append(name) + msg = 'Conflicting resource names: {}'.format(sorted(conflicting)) + super(ResourceConflictError, self).__init__(msg) + + +class ResourceCircularDependencyError(ResourceError): + """Resources define circular dependencies.""" + + def __init__(self, resources): + msg = 'Resources define circular dependencies: {}'.format(resources) + super(ResourceCircularDependencyError, self).__init__(msg) + + +class ResourceMissingDependencyError(ResourceError): + """Resource depends on a missing resource.""" + + def __init__(self, resource): + msg = 'Resource defines missing dependency: {}'.format(resource) + super(ResourceMissingDependencyError, self).__init__(msg) diff --git a/webresource/groups.py b/webresource/groups.py new file mode 100644 index 0000000..51d64fa --- /dev/null +++ b/webresource/groups.py @@ -0,0 +1,97 @@ +from webresource.base import ResourceMixin +from webresource.exceptions import ResourceError +from webresource.resources import ( + LinkResource, + Resource, + ScriptResource, + StyleResource +) + + +class ResourceGroup(ResourceMixin): + """A resource group.""" + + def __init__( + self, name='', directory=None, path=None, include=True, group=None + ): + """Create resource group. + + :param name: The resource group name. + :param directory: Directory containing the resource files. + :param path: Optional URL path for HTML tag link creation. Takes + precedence over group members paths. + :param include: Flag or callback function returning a flag whether to + include the resource group. + :param group: Optional resource group instance. + """ + super(ResourceGroup, self).__init__( + name=name, directory=directory, path=path, + include=include, group=group + ) + self._members = [] + + @property + def members(self): + """List of group members. + + Group members are either instances of ``Resource`` or ``ResourceGroup``. + """ + return self._members + + @property + def scripts(self): + """List of all contained ``ScriptResource`` instances. + + Resources from subsequent resource groups are included. + """ + return self._filtered_resources(ScriptResource) + + @property + def styles(self): + """List of all contained ``StyleResource`` instances. + + Resources from subsequent resource groups are included. + """ + return self._filtered_resources(StyleResource) + + @property + def links(self): + """List of all contained ``LinkResource`` instances. + + Resources from subsequent resource groups are included. + """ + return self._filtered_resources(LinkResource) + + def add(self, member): + """Add member to resource group. + + :param member: Either ``ResourceGroup`` or ``Resource`` instance. + :raise ResourceError: Invalid member given. + """ + if not isinstance(member, (ResourceGroup, Resource)): + raise ResourceError( + 'Resource group can only contain instances ' + 'of ``ResourceGroup`` or ``Resource``' + ) + member.parent = self + self._members.append(member) + + def _filtered_resources(self, type_, members=None): + if members is None: + members = self.members + resources = [] + for member in members: + if isinstance(member, ResourceGroup): + resources += self._filtered_resources( + type_, + members=member.members + ) + elif isinstance(member, type_): + resources.append(member) + return resources + + def __repr__(self): + return '{} name="{}"'.format( + self.__class__.__name__, + self.name + ) diff --git a/webresource/renderer.py b/webresource/renderer.py new file mode 100644 index 0000000..7826d8f --- /dev/null +++ b/webresource/renderer.py @@ -0,0 +1,41 @@ +from webresource.config import logger +from webresource.exceptions import ResourceError + +try: + FileNotFoundError +except NameError: # pragma: nocover + FileNotFoundError = EnvironmentError + + +class ResourceRenderer(object): + """Resource renderer.""" + + def __init__(self, resolver, base_url='https://tld.org'): + """Create resource renderer. + + :param resolver: ``ResourceResolver`` instance. + :param base_url: Base URL to render resource HTML tags. + """ + self.resolver = resolver + self.base_url = base_url + + def render(self): + """Render resources.""" + return u'\n'.join([ + res.render(self.base_url) for res in self.resolver.resolve() + ]) + + +class GracefulResourceRenderer(ResourceRenderer): + """Resource renderer, which does not fail but logs an exception.""" + + def render(self): + lines = [] + for resource in self.resolver.resolve(): + try: + lines.append(resource.render(self.base_url)) + except (ResourceError, FileNotFoundError): + msg = u'Failure to render resource "{}"'.format(resource.name) + lines.append(u''.format(msg)) + logger.exception(msg) + return u'\n'.join(lines) diff --git a/webresource/resolver.py b/webresource/resolver.py new file mode 100644 index 0000000..1e4988e --- /dev/null +++ b/webresource/resolver.py @@ -0,0 +1,92 @@ +from collections import Counter + +from webresource.exceptions import ( + ResourceCircularDependencyError, + ResourceConflictError, + ResourceError, + ResourceMissingDependencyError +) +from webresource.groups import ResourceGroup +from webresource.resources import Resource + + +class ResourceResolver(object): + """Resource resolver.""" + + def __init__(self, members): + """Create resource resolver. + + :param members: Either single or list of ``Resource`` or + ``ResourceGroup`` instances. + :raise ResourceError: Members contain invalid member. + """ + if not isinstance(members, (list, tuple)): + members = [members] + for member in members: + if not isinstance(member, (Resource, ResourceGroup)): + raise ResourceError( + 'members can only contain instances ' + 'of ``ResourceGroup`` or ``Resource``' + ) + self.members = members + + def _flat_resources(self, members=None): + if members is None: + members = self.members + resources = [] + for member in members: + if not member.include: + continue + if isinstance(member, ResourceGroup): + resources += self._flat_resources(members=member.members) + else: + resources.append(member) + return resources + + def resolve(self): + """Return all resources from members as flat list ordered by + dependencies. + + :raise ResourceConflictError: Resource list contains conflicting names + :raise ResourceMissingDependencyError: Dependency resource not included + :raise ResourceCircularDependencyError: Circular dependency defined. + """ + resources = self._flat_resources() + names = [res.name for res in resources] + counter = Counter(names) + if len(resources) != len(counter): + raise ResourceConflictError(counter) + ret = [] + handled = {} + for resource in resources[:]: + if not resource.depends: + ret.append(resource) + handled[resource.name] = resource + resources.remove(resource) + else: + for dependency_name in resource.depends: + if dependency_name not in names: + raise ResourceMissingDependencyError(resource) + count = len(resources) + while count > 0: + count -= 1 + for resource in resources[:]: + hook_idx = 0 + not_yet = False + for dependency_name in resource.depends: + if dependency_name in handled: + dependency = handled[dependency_name] + dep_idx = ret.index(dependency) + hook_idx = dep_idx if dep_idx > hook_idx else hook_idx + else: + not_yet = True + break + if not_yet: + continue + ret.insert(hook_idx + 1, resource) + handled[resource.name] = resource + resources.remove(resource) + break + if resources: + raise ResourceCircularDependencyError(resources) + return ret diff --git a/webresource/_api.py b/webresource/resources.py similarity index 63% rename from webresource/_api.py rename to webresource/resources.py index a4be088..632f297 100644 --- a/webresource/_api.py +++ b/webresource/resources.py @@ -1,97 +1,11 @@ -from collections import Counter import base64 -import copy import hashlib -import logging import os -import sys import uuid - -try: - FileNotFoundError -except NameError: # pragma: nocover - FileNotFoundError = EnvironmentError - - -logger = logging.getLogger(__name__) -is_py3 = sys.version_info[0] >= 3 -namespace_uuid = uuid.UUID('f3341b2e-f97e-40d2-ad2f-10a08a778877') - - -class ResourceConfig(object): - """Config singleton for web resources.""" - - def __init__(self): - self.development = False - - -config = ResourceConfig() - - -class ResourceError(ValueError): - """Resource related exception.""" - - -class ResourceMixin(object): - """Mixin for ``Resource`` and ``ResourceGroup``.""" - - def __init__( - self, name='', directory=None, path=None, include=True, group=None - ): - self.name = name - self.directory = directory - self.path = path - self.include = include - self.parent = None - if group: - group.add(self) - - @property - def path(self): - if self._path is not None: - return self._path - if self.parent is not None: - return self.parent.path - - @path.setter - def path(self, path): - self._path = path - - @property - def directory(self): - if self._directory is not None: - return self._directory - if self.parent is not None: - return self.parent.directory - - @directory.setter - def directory(self, directory): - if directory is None: - self._directory = None - return - self._directory = os.path.abspath(directory) - - @property - def include(self): - if callable(self._include): - return self._include() - return self._include - - @include.setter - def include(self, include): - self._include = include - - def remove(self): - """Remove resource or resource group from parent group.""" - if not self.parent: - raise ResourceError('Object is no member of a resource group') - self.parent.members.remove(self) - self.parent = None - - def copy(self): - """Return a deep copy of this object.""" - return copy.deepcopy(self) +from webresource.base import ResourceMixin +from webresource.config import config, is_py3, namespace_uuid +from webresource.exceptions import ResourceError class Resource(ResourceMixin): @@ -500,236 +414,3 @@ def __init__( type_='text/css', hreflang=hreflang, media=media, rel=rel, sizes=None, title=title, **kwargs ) - - -class ResourceGroup(ResourceMixin): - """A resource group.""" - - def __init__( - self, name='', directory=None, path=None, include=True, group=None - ): - """Create resource group. - - :param name: The resource group name. - :param directory: Directory containing the resource files. - :param path: Optional URL path for HTML tag link creation. Takes - precedence over group members paths. - :param include: Flag or callback function returning a flag whether to - include the resource group. - :param group: Optional resource group instance. - """ - super(ResourceGroup, self).__init__( - name=name, directory=directory, path=path, - include=include, group=group - ) - self._members = [] - - @property - def members(self): - """List of group members. - - Group members are either instances of ``Resource`` or ``ResourceGroup``. - """ - return self._members - - @property - def scripts(self): - """List of all contained ``ScriptResource`` instances. - - Resources from subsequent resource groups are included. - """ - return self._filtered_resources(ScriptResource) - - @property - def styles(self): - """List of all contained ``StyleResource`` instances. - - Resources from subsequent resource groups are included. - """ - return self._filtered_resources(StyleResource) - - @property - def links(self): - """List of all contained ``LinkResource`` instances. - - Resources from subsequent resource groups are included. - """ - return self._filtered_resources(LinkResource) - - def add(self, member): - """Add member to resource group. - - :param member: Either ``ResourceGroup`` or ``Resource`` instance. - :raise ResourceError: Invalid member given. - """ - if not isinstance(member, (ResourceGroup, Resource)): - raise ResourceError( - 'Resource group can only contain instances ' - 'of ``ResourceGroup`` or ``Resource``' - ) - member.parent = self - self._members.append(member) - - def _filtered_resources(self, type_, members=None): - if members is None: - members = self.members - resources = [] - for member in members: - if isinstance(member, ResourceGroup): - resources += self._filtered_resources( - type_, - members=member.members - ) - elif isinstance(member, type_): - resources.append(member) - return resources - - def __repr__(self): - return '{} name="{}"'.format( - self.__class__.__name__, - self.name - ) - - -class ResourceConflictError(ResourceError): - """Multiple resources declared with the same name.""" - - def __init__(self, counter): - conflicting = list() - for name, count in counter.items(): - if count > 1: - conflicting.append(name) - msg = 'Conflicting resource names: {}'.format(sorted(conflicting)) - super(ResourceConflictError, self).__init__(msg) - - -class ResourceCircularDependencyError(ResourceError): - """Resources define circular dependencies.""" - - def __init__(self, resources): - msg = 'Resources define circular dependencies: {}'.format(resources) - super(ResourceCircularDependencyError, self).__init__(msg) - - -class ResourceMissingDependencyError(ResourceError): - """Resource depends on a missing resource.""" - - def __init__(self, resource): - msg = 'Resource defines missing dependency: {}'.format(resource) - super(ResourceMissingDependencyError, self).__init__(msg) - - -class ResourceResolver(object): - """Resource resolver.""" - - def __init__(self, members): - """Create resource resolver. - - :param members: Either single or list of ``Resource`` or - ``ResourceGroup`` instances. - :raise ResourceError: Members contain invalid member. - """ - if not isinstance(members, (list, tuple)): - members = [members] - for member in members: - if not isinstance(member, (Resource, ResourceGroup)): - raise ResourceError( - 'members can only contain instances ' - 'of ``ResourceGroup`` or ``Resource``' - ) - self.members = members - - def _flat_resources(self, members=None): - if members is None: - members = self.members - resources = [] - for member in members: - if not member.include: - continue - if isinstance(member, ResourceGroup): - resources += self._flat_resources(members=member.members) - else: - resources.append(member) - return resources - - def resolve(self): - """Return all resources from members as flat list ordered by - dependencies. - - :raise ResourceConflictError: Resource list contains conflicting names - :raise ResourceMissingDependencyError: Dependency resource not included - :raise ResourceCircularDependencyError: Circular dependency defined. - """ - resources = self._flat_resources() - names = [res.name for res in resources] - counter = Counter(names) - if len(resources) != len(counter): - raise ResourceConflictError(counter) - ret = [] - handled = {} - for resource in resources[:]: - if not resource.depends: - ret.append(resource) - handled[resource.name] = resource - resources.remove(resource) - else: - for dependency_name in resource.depends: - if dependency_name not in names: - raise ResourceMissingDependencyError(resource) - count = len(resources) - while count > 0: - count -= 1 - for resource in resources[:]: - hook_idx = 0 - not_yet = False - for dependency_name in resource.depends: - if dependency_name in handled: - dependency = handled[dependency_name] - dep_idx = ret.index(dependency) - hook_idx = dep_idx if dep_idx > hook_idx else hook_idx - else: - not_yet = True - break - if not_yet: - continue - ret.insert(hook_idx + 1, resource) - handled[resource.name] = resource - resources.remove(resource) - break - if resources: - raise ResourceCircularDependencyError(resources) - return ret - - -class ResourceRenderer(object): - """Resource renderer.""" - - def __init__(self, resolver, base_url='https://tld.org'): - """Create resource renderer. - - :param resolver: ``ResourceResolver`` instance. - :param base_url: Base URL to render resource HTML tags. - """ - self.resolver = resolver - self.base_url = base_url - - def render(self): - """Render resources.""" - return u'\n'.join([ - res.render(self.base_url) for res in self.resolver.resolve() - ]) - - -class GracefulResourceRenderer(ResourceRenderer): - """Resource renderer, which does not fail but logs an exception.""" - - def render(self): - lines = [] - for resource in self.resolver.resolve(): - try: - lines.append(resource.render(self.base_url)) - except (ResourceError, FileNotFoundError): - msg = u'Failure to render resource "{}"'.format(resource.name) - lines.append(u''.format(msg)) - logger.exception(msg) - return u'\n'.join(lines) From 527e3742077ed15383cdd87a90ed22847b9b72af Mon Sep 17 00:00:00 2001 From: Robert Niederreiter Date: Tue, 4 Nov 2025 09:06:20 +0100 Subject: [PATCH 12/17] Drop python 2 support --- tests/test_webresource.py | 27 +++++++++------------------ webresource/config.py | 8 -------- webresource/renderer.py | 5 ----- webresource/resources.py | 4 ++-- 4 files changed, 11 insertions(+), 33 deletions(-) diff --git a/tests/test_webresource.py b/tests/test_webresource.py index 0a2515a..c8f4379 100644 --- a/tests/test_webresource.py +++ b/tests/test_webresource.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- from collections import Counter from webresource.base import ResourceMixin -from webresource.config import is_py3, ResourceConfig +from webresource.config import ResourceConfig from webresource.resources import LinkMixin, Resource import os import shutil @@ -10,12 +10,6 @@ import webresource as wr -try: - FileNotFoundError -except NameError: # pragma: nocover - FileNotFoundError = EnvironmentError - - def temp_directory(fn): def wrapper(*a, **kw): tempdir = tempfile.mkdtemp() @@ -675,18 +669,15 @@ def test_GracefulResourceRenderer(self): depends="js", unique=True, ) - if is_py3: # pragma: nocover - with self.assertLogs() as captured: - rendered = renderer.render() - # check that there is only one log message - self.assertEqual(len(captured.records), 1) - # check if its ours - self.assertEqual( - captured.records[0].getMessage().split('\n')[0], - 'Failure to render resource "js2"', - ) - else: # pragma: nocover + with self.assertLogs() as captured: rendered = renderer.render() + # check that there is only one log message + self.assertEqual(len(captured.records), 1) + # check if its ours + self.assertEqual( + captured.records[0].getMessage().split('\n')[0], + 'Failure to render resource "js2"', + ) self.assertEqual(rendered, ( '\n' diff --git a/webresource/config.py b/webresource/config.py index 1a25780..35c7729 100644 --- a/webresource/config.py +++ b/webresource/config.py @@ -1,16 +1,8 @@ import logging -import sys import uuid -try: - FileNotFoundError -except NameError: # pragma: nocover - FileNotFoundError = EnvironmentError - - logger = logging.getLogger(__name__) -is_py3 = sys.version_info[0] >= 3 namespace_uuid = uuid.UUID('f3341b2e-f97e-40d2-ad2f-10a08a778877') diff --git a/webresource/renderer.py b/webresource/renderer.py index 7826d8f..ff52470 100644 --- a/webresource/renderer.py +++ b/webresource/renderer.py @@ -1,11 +1,6 @@ from webresource.config import logger from webresource.exceptions import ResourceError -try: - FileNotFoundError -except NameError: # pragma: nocover - FileNotFoundError = EnvironmentError - class ResourceRenderer(object): """Resource renderer.""" diff --git a/webresource/resources.py b/webresource/resources.py index 632f297..fe03c7d 100644 --- a/webresource/resources.py +++ b/webresource/resources.py @@ -4,7 +4,7 @@ import uuid from webresource.base import ResourceMixin -from webresource.config import config, is_py3, namespace_uuid +from webresource.config import config, namespace_uuid from webresource.exceptions import ResourceError @@ -99,7 +99,7 @@ def file_hash(self): return self._file_hash hash_func = self._hash_algorithms[self.hash_algorithm] hash_ = base64.b64encode(hash_func(self.file_data).digest()) - hash_ = hash_.decode() if is_py3 else hash_ + hash_ = hash_.decode() self.file_hash = hash_ return hash_ From 8dbf3f59ad8e82a0bc8684b71382ecab7508c72d Mon Sep 17 00:00:00 2001 From: Robert Niederreiter Date: Tue, 4 Nov 2025 09:23:39 +0100 Subject: [PATCH 13/17] Split up tests to reflect new module structure. --- tests/test_base.py | 53 +++ tests/test_config.py | 20 ++ tests/test_exceptions.py | 34 ++ tests/test_groups.py | 82 +++++ tests/test_renderer.py | 151 +++++++++ tests/test_resolver.py | 111 ++++++ tests/test_resources.py | 301 +++++++++++++++++ tests/test_utils.py | 22 ++ tests/test_webresource.py | 694 -------------------------------------- 9 files changed, 774 insertions(+), 694 deletions(-) create mode 100644 tests/test_base.py create mode 100644 tests/test_config.py create mode 100644 tests/test_exceptions.py create mode 100644 tests/test_groups.py create mode 100644 tests/test_renderer.py create mode 100644 tests/test_resolver.py create mode 100644 tests/test_resources.py create mode 100644 tests/test_utils.py delete mode 100644 tests/test_webresource.py diff --git a/tests/test_base.py b/tests/test_base.py new file mode 100644 index 0000000..eae4fa4 --- /dev/null +++ b/tests/test_base.py @@ -0,0 +1,53 @@ +# -*- coding: utf-8 -*- +"""Tests for webresource.base module.""" +import os +import unittest +from webresource.base import ResourceMixin +from tests.test_utils import np + + +class TestBase(unittest.TestCase): + + def test_ResourceMixin(self): + mixin = ResourceMixin( + name='name', path='path', include=True + ) + self.assertEqual(mixin.name, 'name') + self.assertEqual(mixin.path, 'path') + self.assertEqual(mixin.include, True) + self.assertEqual(mixin.directory, None) + self.assertEqual(mixin.parent, None) + + mixin.parent = ResourceMixin(name='other', path='other') + mixin.path = None + self.assertEqual(mixin.path, 'other') + + mixin.parent.parent = ResourceMixin(name='root', path='root') + mixin.parent.path = None + self.assertEqual(mixin.path, 'root') + + mixin.directory = '/dir' + self.assertTrue(mixin.directory.endswith(os.path.join(os.path.sep, 'dir'))) + + mixin.directory = '/resources/dir/../other' + self.assertTrue(mixin.directory.endswith(np('/resources/other'))) + + mixin.parent = ResourceMixin(name='other', directory='/other') + mixin.directory = None + self.assertTrue(mixin.directory.endswith(os.path.join(os.path.sep, 'other'))) + + mixin.parent.parent = ResourceMixin(name='root', directory='/root') + mixin.parent.directory = None + self.assertTrue(mixin.directory.endswith(os.path.join(os.path.sep, 'root'))) + + def include(): + return False + + mixin = ResourceMixin(name='name', path='path', include=include) + self.assertFalse(mixin.include) + + self.assertFalse(mixin.copy() is mixin) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..52c2da7 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +"""Tests for webresource.config module.""" +import unittest +import webresource as wr +from webresource.config import ResourceConfig + + +class TestConfig(unittest.TestCase): + + def test_ResourceConfig(self): + config = ResourceConfig() + self.assertIsInstance(wr.config, ResourceConfig) + self.assertFalse(config.development) + + config.development = True + self.assertTrue(config.development) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py new file mode 100644 index 0000000..c22e9a0 --- /dev/null +++ b/tests/test_exceptions.py @@ -0,0 +1,34 @@ +# -*- coding: utf-8 -*- +"""Tests for webresource.exceptions module.""" +from collections import Counter +import unittest +import webresource as wr +from webresource.resources import Resource + + +class TestExceptions(unittest.TestCase): + + def test_ResourceConflictError(self): + counter = Counter(['a', 'b', 'b', 'c', 'c']) + err = wr.ResourceConflictError(counter) + self.assertEqual(str(err), 'Conflicting resource names: [\'b\', \'c\']') + + def test_ResourceCircularDependencyError(self): + resource = Resource(name='res1', resource='res1.ext', depends='res2') + err = wr.ResourceCircularDependencyError([resource]) + self.assertEqual(str(err), ( + 'Resources define circular dependencies: ' + '[Resource name="res1", depends="[\'res2\']"]' + )) + + def test_ResourceMissingDependencyError(self): + resource = Resource(name='res', resource='res.ext', depends='missing') + err = wr.ResourceMissingDependencyError(resource) + self.assertEqual(str(err), ( + 'Resource defines missing dependency: ' + 'Resource name="res", depends="[\'missing\']"' + )) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_groups.py b/tests/test_groups.py new file mode 100644 index 0000000..f34bd52 --- /dev/null +++ b/tests/test_groups.py @@ -0,0 +1,82 @@ +# -*- coding: utf-8 -*- +"""Tests for webresource.groups module.""" +import unittest +import webresource as wr +from webresource.base import ResourceMixin + + +class TestGroups(unittest.TestCase): + + def test_ResourceGroup(self): + group = wr.ResourceGroup(name='groupname') + self.assertIsInstance(group, ResourceMixin) + self.assertEqual(group.name, 'groupname') + self.assertEqual(group.members, []) + self.assertEqual(repr(group), 'ResourceGroup name="groupname"') + + res = wr.ScriptResource(name='name', resource='name.js') + group.add(res) + other = wr.ResourceGroup(name='other') + group.add(other) + self.assertEqual(group.members, [res, other]) + self.assertRaises(wr.ResourceError, group.add, object()) + + root_group = wr.ResourceGroup(name='root') + member_group = wr.ResourceGroup(name='member', group=root_group) + member_res = wr.ScriptResource( + name='res', + resource='res.js', + group=member_group + ) + self.assertTrue(member_group.parent is root_group) + self.assertTrue(member_res.parent is member_group) + + group = wr.ResourceGroup( + name='groupname', + path='group_path', + directory='/path/to/dir') + group.add(wr.ResourceGroup(name='group1')) + wr.ResourceGroup(name='group2', group=group) + + self.assertEqual(group.path, group.members[0].path) + self.assertEqual(group.path, group.members[1].path) + self.assertEqual(group.directory, group.members[0].directory) + self.assertEqual(group.directory, group.members[1].directory) + + root = wr.ResourceGroup(name='root') + wr.StyleResource(name='root-style', resource='root.css', group=root) + wr.ScriptResource(name='root-script', resource='root.js', group=root) + wr.LinkResource(name='root-link', resource='root.link', group=root) + + group = wr.ResourceGroup(name='group', group=root) + wr.StyleResource(name='group-style', resource='group.css', group=group) + wr.ScriptResource(name='group-script', resource='group.js', group=group) + wr.LinkResource(name='group-link', resource='group.link', group=group) + + self.assertEqual( + sorted([res.name for res in root.scripts]), + ['group-script', 'root-script'] + ) + self.assertEqual( + sorted([res.name for res in root.styles]), + ['group-style', 'root-style'] + ) + self.assertEqual( + sorted([res.name for res in root.links]), + ['group-link', 'root-link'] + ) + + resource = wr.Resource(resource='res') + with self.assertRaises(wr.ResourceError): + resource.remove() + + group = wr.ResourceGroup() + resource = wr.Resource(resource='res', group=group) + self.assertEqual(group.members, [resource]) + resource.remove() + self.assertEqual(group.members, []) + self.assertEqual(resource.parent, None) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_renderer.py b/tests/test_renderer.py new file mode 100644 index 0000000..fe52757 --- /dev/null +++ b/tests/test_renderer.py @@ -0,0 +1,151 @@ +# -*- coding: utf-8 -*- +"""Tests for webresource.renderer module.""" +import unittest +import webresource as wr + + +class TestRenderer(unittest.TestCase): + + def tearDown(self): + wr.config.development = False + + def test_ResourceRenderer(self): + resources = wr.ResourceGroup('res', path='res') + wr.LinkResource( + name='icon', + resource='icon.png', + group=resources, + rel='icon', + type_='image/png' + ) + wr.StyleResource(name='css', resource='styles.css', group=resources) + wr.StyleResource( + name='ext_css', + url='https://ext.org/styles.css', + group=resources + ) + wr.ScriptResource( + name='js', + resource='script.js', + compressed='script.min.js', + group=resources + ) + resolver = wr.ResourceResolver(resources) + renderer = wr.ResourceRenderer(resolver, base_url='https://tld.org') + + rendered = renderer.render() + self.assertEqual(rendered, ( + '\n' + '\n' + '\n' + '' + )) + + wr.config.development = True + rendered = renderer.render() + self.assertEqual(rendered, ( + '\n' + '\n' + '\n' + '' + )) + + # check if unique raises on render b/c file does not exist. + wr.ScriptResource( + name='js2', + directory='', + resource='script2.js', + compressed='script2.min.js', + group=resources, + unique=True, + ) + with self.assertRaises(FileNotFoundError): + renderer.render() + + def test_GracefulResourceRenderer(self): + resources = wr.ResourceGroup('res', path='res') + wr.LinkResource( + name='icon', + resource='icon.png', + group=resources, + rel='icon', + type_='image/png', + ) + wr.StyleResource(name='css', resource='styles.css', group=resources) + wr.StyleResource( + name='ext_css', + url='https://ext.org/styles.css', + group=resources, + ) + wr.ScriptResource( + name='js', + resource='script.js', + compressed='script.min.js', + group=resources, + ) + resolver = wr.ResourceResolver(resources) + renderer = wr.GracefulResourceRenderer( + resolver, + base_url='https://tld.org', + ) + rendered = renderer.render() + self.assertEqual(rendered, ( + '\n' + '\n' + '\n' + '' + )) + + wr.config.development = True + rendered = renderer.render() + self.assertEqual(rendered, ( + '\n' + '\n' + '\n' + '' + )) + # check if unique raises on is catched on render and turned into + wr.ScriptResource( + name='js2', + directory='', + resource='script2.js', + compressed='script2.min.js', + group=resources, + depends="js", + unique=True, + ) + with self.assertLogs() as captured: + rendered = renderer.render() + # check that there is only one log message + self.assertEqual(len(captured.records), 1) + # check if its ours + self.assertEqual( + captured.records[0].getMessage().split('\n')[0], + 'Failure to render resource "js2"', + ) + self.assertEqual(rendered, ( + '\n' + '\n' + '\n' + '\n' + '' + )) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_resolver.py b/tests/test_resolver.py new file mode 100644 index 0000000..118c039 --- /dev/null +++ b/tests/test_resolver.py @@ -0,0 +1,111 @@ +# -*- coding: utf-8 -*- +"""Tests for webresource.resolver module.""" +import unittest +import webresource as wr +from webresource.resources import Resource + + +class TestResolver(unittest.TestCase): + + def test_ResourceResolver__flat_resources(self): + self.assertRaises(wr.ResourceError, wr.ResourceResolver, object()) + + res1 = Resource(name='res1', resource='res1.ext') + resolver = wr.ResourceResolver(res1) + self.assertEqual(resolver.members, [res1]) + self.assertEqual(resolver._flat_resources(), [res1]) + + res2 = Resource(name='res2', resource='res2.ext') + resolver = wr.ResourceResolver([res1, res2]) + self.assertEqual(resolver.members, [res1, res2]) + self.assertEqual(resolver._flat_resources(), [res1, res2]) + + res3 = Resource(name='res3', resource='res3.ext') + + group1 = wr.ResourceGroup(name='group1') + group1.add(res1) + + group2 = wr.ResourceGroup(name='group2') + group2.add(res2) + + group3 = wr.ResourceGroup(name='group3') + group3.add(res3) + group3.add(group2) + + resolver = wr.ResourceResolver([group1, group3]) + self.assertEqual(resolver._flat_resources(), [res1, res3, res2]) + + res3.include = False + self.assertEqual(resolver._flat_resources(), [res1, res2]) + + res3.include = True + group3.include = False + self.assertEqual(resolver._flat_resources(), [res1]) + + def test_ResourceResolver_resolve(self): + resolver = wr.ResourceResolver([ + Resource(name='res', resource='res.ext'), + Resource(name='res', resource='res.ext') + ]) + self.assertRaises(wr.ResourceConflictError, resolver.resolve) + + res1 = Resource(name='res1', resource='res1.ext', depends='res2') + res2 = Resource(name='res2', resource='res2.ext', depends='res3') + res3 = Resource(name='res3', resource='res3.ext') + + resolver = wr.ResourceResolver([res1, res2, res3]) + self.assertEqual(resolver.resolve(), [res3, res2, res1]) + + resolver = wr.ResourceResolver([res2, res1, res3]) + self.assertEqual(resolver.resolve(), [res3, res2, res1]) + + resolver = wr.ResourceResolver([res1, res3, res2]) + self.assertEqual(resolver.resolve(), [res3, res2, res1]) + + res1 = Resource(name='res1', resource='res1.ext', depends='res2') + res2 = Resource(name='res2', resource='res2.ext', depends='res1') + + resolver = wr.ResourceResolver([res1, res2]) + self.assertRaises(wr.ResourceCircularDependencyError, resolver.resolve) + + res1 = Resource(name='res1', resource='res1.ext', depends='res2') + res2 = Resource(name='res2', resource='res2.ext', depends='missing') + + resolver = wr.ResourceResolver([res1, res2]) + self.assertRaises(wr.ResourceMissingDependencyError, resolver.resolve) + + res1 = Resource(name='res1', resource='res1.ext', depends=['res2', 'res4']) + res2 = Resource(name='res2', resource='res2.ext', depends=['res3', 'res4']) + res3 = Resource(name='res3', resource='res3.ext', depends=['res4', 'res5']) + res4 = Resource(name='res4', resource='res4.ext', depends='res5') + res5 = Resource(name='res5', resource='res5.ext') + + resolver = wr.ResourceResolver([res1, res2, res3, res4, res5]) + self.assertEqual(resolver.resolve(), [res5, res4, res3, res2, res1]) + + resolver = wr.ResourceResolver([res5, res4, res3, res2, res1]) + self.assertEqual(resolver.resolve(), [res5, res4, res3, res2, res1]) + + resolver = wr.ResourceResolver([res4, res5, res2, res3, res1]) + self.assertEqual(resolver.resolve(), [res5, res4, res3, res2, res1]) + + resolver = wr.ResourceResolver([res1, res3, res2, res5, res4]) + self.assertEqual(resolver.resolve(), [res5, res4, res3, res2, res1]) + + res1 = Resource(name='res1', resource='res1.ext', depends=['res2', 'res3']) + res2 = Resource(name='res2', resource='res2.ext', depends=['res1', 'res3']) + res3 = Resource(name='res3', resource='res3.ext', depends=['res1', 'res2']) + + resolver = wr.ResourceResolver([res1, res2, res3]) + self.assertRaises(wr.ResourceCircularDependencyError, resolver.resolve) + + res1 = Resource(name='res1', resource='res1.ext', depends=['res2', 'res3']) + res2 = Resource(name='res2', resource='res2.ext', depends=['res1', 'res3']) + res3 = Resource(name='res3', resource='res3.ext', depends=['res1', 'res4']) + + resolver = wr.ResourceResolver([res1, res2, res3]) + self.assertRaises(wr.ResourceMissingDependencyError, resolver.resolve) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_resources.py b/tests/test_resources.py new file mode 100644 index 0000000..c655a1e --- /dev/null +++ b/tests/test_resources.py @@ -0,0 +1,301 @@ +# -*- coding: utf-8 -*- +"""Tests for webresource.resources module.""" +import os +import unittest +import webresource as wr +from webresource.base import ResourceMixin +from webresource.resources import LinkMixin, Resource +from tests.test_utils import np, temp_directory + + +class TestResources(unittest.TestCase): + + def tearDown(self): + wr.config.development = False + + @temp_directory + def test_Resource(self, tempdir): + self.assertRaises(wr.ResourceError, Resource, 'res') + + resource = Resource(name='res', resource='res.ext') + self.assertIsInstance(resource, ResourceMixin) + self.assertEqual(resource.name, 'res') + self.assertEqual(resource.depends, None) + self.assertEqual(resource.directory, None) + self.assertEqual(resource.path, None) + self.assertEqual(resource.resource, 'res.ext') + self.assertEqual(resource.compressed, None) + self.assertEqual(resource.include, True) + self.assertEqual(resource.unique, False) + self.assertEqual(resource.unique_prefix, '++webresource++') + self.assertEqual(resource.hash_algorithm, 'sha384') + self.assertEqual(resource.url, None) + self.assertEqual(resource.crossorigin, None) + self.assertEqual(resource.referrerpolicy, None) + self.assertEqual(resource.type_, None) + self.assertEqual( + repr(resource), + 'Resource name="res", depends="None"' + ) + + resource = Resource(name='res', resource='res.ext') + self.assertEqual(resource.file_name, 'res.ext') + with self.assertRaises(wr.ResourceError): + resource.file_path + + resource = Resource(name='res', directory='/dir', resource='res.ext') + self.assertEqual(resource.file_name, 'res.ext') + self.assertTrue(resource.file_path.endswith(np('/dir/res.ext'))) + + resource.compressed = 'res.min.ext' + self.assertEqual(resource.file_name, 'res.min.ext') + self.assertTrue(resource.file_path.endswith(np('/dir/res.min.ext'))) + + wr.config.development = True + self.assertEqual(resource.file_name, 'res.ext') + self.assertTrue(resource.file_path.endswith(np('/dir/res.ext'))) + wr.config.development = False + + group = wr.ResourceGroup(name='group') + resource = Resource(name='res', resource='res.ext', group=group) + self.assertTrue(group.members[0] is resource) + + rendered = resource._render_tag('tag', False, foo='bar', baz=None) + self.assertEqual(rendered, u'') + + rendered = resource._render_tag('tag', True, foo='bar', baz=None) + self.assertEqual(rendered, u'') + + self.assertRaises(NotImplementedError, resource.render, '') + + resource = Resource(name='res', resource='res.ext') + resource_url = resource.resource_url('https://tld.org/') + self.assertEqual(resource_url, 'https://tld.org/res.ext') + + resource = Resource(name='res', resource='res.ext', path='/resources') + resource_url = resource.resource_url('https://tld.org') + self.assertEqual(resource_url, 'https://tld.org/resources/res.ext') + + resource = Resource(name='res', resource='res.ext', path='resources') + resource_url = resource.resource_url('https://tld.org') + self.assertEqual(resource_url, 'https://tld.org/resources/res.ext') + + resource = Resource( + name='res', + resource='res.ext', + compressed='res.min', + path='/resources' + ) + resource_url = resource.resource_url('https://tld.org') + self.assertEqual(resource_url, 'https://tld.org/resources/res.min') + + wr.config.development = True + resource_url = resource.resource_url('https://tld.org') + self.assertEqual(resource_url, 'https://tld.org/resources/res.ext') + + resource = Resource(name='res', url='https://ext.org/res') + resource_url = resource.resource_url('') + self.assertEqual(resource_url, 'https://ext.org/res') + + wr.config.development = False + with open(os.path.join(tempdir, 'res'), 'wb') as f: + f.write(u'Resource Content ä'.encode('utf8')) + + resource = Resource(name='res', resource='res', directory=tempdir) + self.assertEqual(resource.file_data, b'Resource Content \xc3\xa4') + + hash_ = ( + 'VwEVpw/Hy4OlSeTX7oDQ/lzkncnWgKEV' + '0zOX9OXa9Uy+qypLkrBrJxPtNsax1HJo' + ) + self.assertEqual(resource.file_hash, hash_) + + resource_url = resource.resource_url('https://tld.org') + self.assertEqual(resource_url, 'https://tld.org/res') + + unique_key = resource.unique_key + self.assertEqual( + unique_key, + '++webresource++4be37419-d3f6-5ec5-99e8-92565ede87d0' + ) + + resource.unique = True + resource_url = resource.resource_url('https://tld.org') + self.assertEqual( + resource_url, + 'https://tld.org/{}/res'.format(unique_key) + ) + + with open(os.path.join(tempdir, 'res'), 'w') as f: + f.write('Changed Content') + + self.assertEqual(resource.file_data, b'Changed Content') + 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) + ) + + 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) + ) + + resource = Resource( + name='res', + resource='res.ext', + custom_attr='value' + ) + self.assertEqual(resource.additional_attrs, dict(custom_attr='value')) + + @temp_directory + def test_ScriptResource(self, tempdir): + script = wr.ScriptResource(name='js_res', resource='res.js') + self.assertEqual(script.async_, None) + self.assertEqual(script.defer, None) + self.assertEqual(script.integrity, None) + self.assertEqual(script.nomodule, None) + self.assertEqual( + repr(script), + 'ScriptResource name="js_res", depends="None"' + ) + self.assertEqual( + script.render('https://tld.org'), + '' + ) + script.type_ = 'module' + self.assertEqual( + script.render('https://tld.org'), + '' + ) + + script.url = 'https://ext.org/script.js' + self.assertRaises(wr.ResourceError, setattr, script, 'integrity', True) + + script.integrity = 'sha384-ABC' + self.assertEqual(script.integrity, 'sha384-ABC') + + with open(os.path.join(tempdir, 'script.js'), 'w') as f: + f.write('Script Content') + + script = wr.ScriptResource( + name='script', + resource='script.js', + directory=tempdir, + integrity=True + ) + hash_ = 'omjyXfsb+ti/5fpn4QjjSYjpKRnxWpzc6rIUE6mXxyDjbLS9AotgsLWQZtylXicX' + self.assertEqual(script.file_hash, hash_) + self.assertEqual(script.integrity, 'sha384-{}'.format(hash_)) + + rendered = script.render('https://tld.org') + expected = 'integrity="sha384-{}"'.format(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_)) + + wr.config.development = True + self.assertNotEqual(script.integrity, 'sha384-{}'.format(hash_)) + + script = wr.ScriptResource( + name='js_res', + resource='res.js', + custom='value' + ) + self.assertEqual( + script.render('https://tld.org'), + '' + ) + + def test_LinkMixin(self): + link = LinkMixin(name='link_res', resource='resource.md') + self.assertEqual(link.hreflang, None) + self.assertEqual(link.media, None) + self.assertEqual(link.rel, None) + self.assertEqual(link.sizes, None) + self.assertEqual(link.title, None) + self.assertEqual( + repr(link), + 'LinkMixin name="link_res", depends="None"' + ) + link.hreflang = 'en' + link.media = 'screen' + link.rel = 'alternate' + link.type_ = 'text/markdown' + self.assertEqual(link.render('https://tld.org'), ( + '' + )) + + link = LinkMixin( + name='link_res', + resource='resource.md', + custom='value' + ) + self.assertEqual( + link.render('https://tld.org'), + '' + ) + + def test_LinkResource(self): + link = wr.LinkResource(name='icon_res', resource='icon.png') + self.assertIsInstance(link, LinkMixin) + self.assertEqual( + repr(link), + 'LinkResource name="icon_res", depends="None"' + ) + link.rel = 'icon' + link.type_ = 'image/png' + link.sizes = '16x16' + self.assertEqual(link.render('https://tld.org'), ( + '' + )) + + link = wr.LinkResource( + name='icon_res', + resource='icon.png', + custom='value' + ) + self.assertEqual( + link.render('https://tld.org'), + '' + ) + + def test_StyleResource(self): + style = wr.StyleResource(name='css_res', resource='res.css') + self.assertIsInstance(style, LinkMixin) + self.assertEqual(style.type_, 'text/css') + self.assertEqual(style.media, 'all') + self.assertEqual(style.rel, 'stylesheet') + self.assertEqual( + repr(style), + 'StyleResource name="css_res", depends="None"' + ) + self.assertEqual(style.render('https://tld.org'), ( + '' + )) + + style = wr.StyleResource( + name='css_res', + resource='res.css', + custom='value' + ) + self.assertEqual(style.render('https://tld.org'), ( + '' + )) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 0000000..fdec034 --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,22 @@ +# -*- coding: utf-8 -*- +"""Shared test utilities for webresource tests.""" +import os +import shutil +import tempfile + + +def temp_directory(fn): + """Decorator that provides a temporary directory to test functions.""" + def wrapper(*a, **kw): + tempdir = tempfile.mkdtemp() + kw['tempdir'] = tempdir + try: + fn(*a, **kw) + finally: + shutil.rmtree(tempdir) + return wrapper + + +def np(path): + """Normalize path for cross-platform compatibility.""" + return path.replace('/', os.path.sep) diff --git a/tests/test_webresource.py b/tests/test_webresource.py deleted file mode 100644 index c8f4379..0000000 --- a/tests/test_webresource.py +++ /dev/null @@ -1,694 +0,0 @@ -# -*- coding: utf-8 -*- -from collections import Counter -from webresource.base import ResourceMixin -from webresource.config import ResourceConfig -from webresource.resources import LinkMixin, Resource -import os -import shutil -import tempfile -import unittest -import webresource as wr - - -def temp_directory(fn): - def wrapper(*a, **kw): - tempdir = tempfile.mkdtemp() - kw['tempdir'] = tempdir - try: - fn(*a, **kw) - finally: - shutil.rmtree(tempdir) - return wrapper - - -def np(path): - """Normalize path.""" - return path.replace('/', os.path.sep) - - -class TestWebresource(unittest.TestCase): - - def tearDown(self): - wr.config.development = False - - def test_ResourceConfig(self): - config = ResourceConfig() - self.assertIsInstance(wr.config, ResourceConfig) - self.assertFalse(config.development) - - config.development = True - self.assertTrue(config.development) - - def test_ResourceMixin(self): - mixin = ResourceMixin( - name='name', path='path', include=True - ) - self.assertEqual(mixin.name, 'name') - self.assertEqual(mixin.path, 'path') - self.assertEqual(mixin.include, True) - self.assertEqual(mixin.directory, None) - self.assertEqual(mixin.parent, None) - - mixin.parent = ResourceMixin(name='other', path='other') - mixin.path = None - self.assertEqual(mixin.path, 'other') - - mixin.parent.parent = ResourceMixin(name='root', path='root') - mixin.parent.path = None - self.assertEqual(mixin.path, 'root') - - mixin.directory = '/dir' - self.assertTrue(mixin.directory.endswith(os.path.join(os.path.sep, 'dir'))) - - mixin.directory = '/resources/dir/../other' - self.assertTrue(mixin.directory.endswith(np('/resources/other'))) - - mixin.parent = ResourceMixin(name='other', directory='/other') - mixin.directory = None - self.assertTrue(mixin.directory.endswith(os.path.join(os.path.sep, 'other'))) - - mixin.parent.parent = ResourceMixin(name='root', directory='/root') - mixin.parent.directory = None - self.assertTrue(mixin.directory.endswith(os.path.join(os.path.sep, 'root'))) - - def include(): - return False - - mixin = ResourceMixin(name='name', path='path', include=include) - self.assertFalse(mixin.include) - - self.assertFalse(mixin.copy() is mixin) - - @temp_directory - def test_Resource(self, tempdir): - self.assertRaises(wr.ResourceError, Resource, 'res') - - resource = Resource(name='res', resource='res.ext') - self.assertIsInstance(resource, ResourceMixin) - self.assertEqual(resource.name, 'res') - self.assertEqual(resource.depends, None) - self.assertEqual(resource.directory, None) - self.assertEqual(resource.path, None) - self.assertEqual(resource.resource, 'res.ext') - self.assertEqual(resource.compressed, None) - self.assertEqual(resource.include, True) - self.assertEqual(resource.unique, False) - self.assertEqual(resource.unique_prefix, '++webresource++') - self.assertEqual(resource.hash_algorithm, 'sha384') - self.assertEqual(resource.url, None) - self.assertEqual(resource.crossorigin, None) - self.assertEqual(resource.referrerpolicy, None) - self.assertEqual(resource.type_, None) - self.assertEqual( - repr(resource), - 'Resource name="res", depends="None"' - ) - - resource = Resource(name='res', resource='res.ext') - self.assertEqual(resource.file_name, 'res.ext') - with self.assertRaises(wr.ResourceError): - resource.file_path - - resource = Resource(name='res', directory='/dir', resource='res.ext') - self.assertEqual(resource.file_name, 'res.ext') - self.assertTrue(resource.file_path.endswith(np('/dir/res.ext'))) - - resource.compressed = 'res.min.ext' - self.assertEqual(resource.file_name, 'res.min.ext') - self.assertTrue(resource.file_path.endswith(np('/dir/res.min.ext'))) - - wr.config.development = True - self.assertEqual(resource.file_name, 'res.ext') - self.assertTrue(resource.file_path.endswith(np('/dir/res.ext'))) - wr.config.development = False - - group = wr.ResourceGroup(name='group') - resource = Resource(name='res', resource='res.ext', group=group) - self.assertTrue(group.members[0] is resource) - - rendered = resource._render_tag('tag', False, foo='bar', baz=None) - self.assertEqual(rendered, u'') - - rendered = resource._render_tag('tag', True, foo='bar', baz=None) - self.assertEqual(rendered, u'') - - self.assertRaises(NotImplementedError, resource.render, '') - - resource = Resource(name='res', resource='res.ext') - resource_url = resource.resource_url('https://tld.org/') - self.assertEqual(resource_url, 'https://tld.org/res.ext') - - resource = Resource(name='res', resource='res.ext', path='/resources') - resource_url = resource.resource_url('https://tld.org') - self.assertEqual(resource_url, 'https://tld.org/resources/res.ext') - - resource = Resource(name='res', resource='res.ext', path='resources') - resource_url = resource.resource_url('https://tld.org') - self.assertEqual(resource_url, 'https://tld.org/resources/res.ext') - - resource = Resource( - name='res', - resource='res.ext', - compressed='res.min', - path='/resources' - ) - resource_url = resource.resource_url('https://tld.org') - self.assertEqual(resource_url, 'https://tld.org/resources/res.min') - - wr.config.development = True - resource_url = resource.resource_url('https://tld.org') - self.assertEqual(resource_url, 'https://tld.org/resources/res.ext') - - resource = Resource(name='res', url='https://ext.org/res') - resource_url = resource.resource_url('') - self.assertEqual(resource_url, 'https://ext.org/res') - - wr.config.development = False - with open(os.path.join(tempdir, 'res'), 'wb') as f: - f.write(u'Resource Content ä'.encode('utf8')) - - resource = Resource(name='res', resource='res', directory=tempdir) - self.assertEqual(resource.file_data, b'Resource Content \xc3\xa4') - - hash_ = ( - 'VwEVpw/Hy4OlSeTX7oDQ/lzkncnWgKEV' - '0zOX9OXa9Uy+qypLkrBrJxPtNsax1HJo' - ) - self.assertEqual(resource.file_hash, hash_) - - resource_url = resource.resource_url('https://tld.org') - self.assertEqual(resource_url, 'https://tld.org/res') - - unique_key = resource.unique_key - self.assertEqual( - unique_key, - '++webresource++4be37419-d3f6-5ec5-99e8-92565ede87d0' - ) - - resource.unique = True - resource_url = resource.resource_url('https://tld.org') - self.assertEqual( - resource_url, - 'https://tld.org/{}/res'.format(unique_key) - ) - - with open(os.path.join(tempdir, 'res'), 'w') as f: - f.write('Changed Content') - - self.assertEqual(resource.file_data, b'Changed Content') - 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) - ) - - 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) - ) - - resource = Resource( - name='res', - resource='res.ext', - custom_attr='value' - ) - self.assertEqual(resource.additional_attrs, dict(custom_attr='value')) - - @temp_directory - def test_ScriptResource(self, tempdir): - script = wr.ScriptResource(name='js_res', resource='res.js') - self.assertEqual(script.async_, None) - self.assertEqual(script.defer, None) - self.assertEqual(script.integrity, None) - self.assertEqual(script.nomodule, None) - self.assertEqual( - repr(script), - 'ScriptResource name="js_res", depends="None"' - ) - self.assertEqual( - script.render('https://tld.org'), - '' - ) - script.type_ = 'module' - self.assertEqual( - script.render('https://tld.org'), - '' - ) - - script.url = 'https://ext.org/script.js' - self.assertRaises(wr.ResourceError, setattr, script, 'integrity', True) - - script.integrity = 'sha384-ABC' - self.assertEqual(script.integrity, 'sha384-ABC') - - with open(os.path.join(tempdir, 'script.js'), 'w') as f: - f.write('Script Content') - - script = wr.ScriptResource( - name='script', - resource='script.js', - directory=tempdir, - integrity=True - ) - hash_ = 'omjyXfsb+ti/5fpn4QjjSYjpKRnxWpzc6rIUE6mXxyDjbLS9AotgsLWQZtylXicX' - self.assertEqual(script.file_hash, hash_) - self.assertEqual(script.integrity, 'sha384-{}'.format(hash_)) - - rendered = script.render('https://tld.org') - expected = 'integrity="sha384-{}"'.format(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_)) - - wr.config.development = True - self.assertNotEqual(script.integrity, 'sha384-{}'.format(hash_)) - - script = wr.ScriptResource( - name='js_res', - resource='res.js', - custom='value' - ) - self.assertEqual( - script.render('https://tld.org'), - '' - ) - - def test_LinkMixin(self): - link = LinkMixin(name='link_res', resource='resource.md') - self.assertEqual(link.hreflang, None) - self.assertEqual(link.media, None) - self.assertEqual(link.rel, None) - self.assertEqual(link.sizes, None) - self.assertEqual(link.title, None) - self.assertEqual( - repr(link), - 'LinkMixin name="link_res", depends="None"' - ) - link.hreflang = 'en' - link.media = 'screen' - link.rel = 'alternate' - link.type_ = 'text/markdown' - self.assertEqual(link.render('https://tld.org'), ( - '' - )) - - link = LinkMixin( - name='link_res', - resource='resource.md', - custom='value' - ) - self.assertEqual( - link.render('https://tld.org'), - '' - ) - - def test_LinkResource(self): - link = wr.LinkResource(name='icon_res', resource='icon.png') - self.assertIsInstance(link, LinkMixin) - self.assertEqual( - repr(link), - 'LinkResource name="icon_res", depends="None"' - ) - link.rel = 'icon' - link.type_ = 'image/png' - link.sizes = '16x16' - self.assertEqual(link.render('https://tld.org'), ( - '' - )) - - link = wr.LinkResource( - name='icon_res', - resource='icon.png', - custom='value' - ) - self.assertEqual( - link.render('https://tld.org'), - '' - ) - - def test_StyleResource(self): - style = wr.StyleResource(name='css_res', resource='res.css') - self.assertIsInstance(style, LinkMixin) - self.assertEqual(style.type_, 'text/css') - self.assertEqual(style.media, 'all') - self.assertEqual(style.rel, 'stylesheet') - self.assertEqual( - repr(style), - 'StyleResource name="css_res", depends="None"' - ) - self.assertEqual(style.render('https://tld.org'), ( - '' - )) - - style = wr.StyleResource( - name='css_res', - resource='res.css', - custom='value' - ) - self.assertEqual(style.render('https://tld.org'), ( - '' - )) - - def test_ResourceGroup(self): - group = wr.ResourceGroup(name='groupname') - self.assertIsInstance(group, ResourceMixin) - self.assertEqual(group.name, 'groupname') - self.assertEqual(group.members, []) - self.assertEqual(repr(group), 'ResourceGroup name="groupname"') - - res = wr.ScriptResource(name='name', resource='name.js') - group.add(res) - other = wr.ResourceGroup(name='other') - group.add(other) - self.assertEqual(group.members, [res, other]) - self.assertRaises(wr.ResourceError, group.add, object()) - - root_group = wr.ResourceGroup(name='root') - member_group = wr.ResourceGroup(name='member', group=root_group) - member_res = wr.ScriptResource( - name='res', - resource='res.js', - group=member_group - ) - self.assertTrue(member_group.parent is root_group) - self.assertTrue(member_res.parent is member_group) - - group = wr.ResourceGroup( - name='groupname', - path='group_path', - directory='/path/to/dir') - group.add(wr.ResourceGroup(name='group1')) - wr.ResourceGroup(name='group2', group=group) - - self.assertEqual(group.path, group.members[0].path) - self.assertEqual(group.path, group.members[1].path) - self.assertEqual(group.directory, group.members[0].directory) - self.assertEqual(group.directory, group.members[1].directory) - - root = wr.ResourceGroup(name='root') - wr.StyleResource(name='root-style', resource='root.css', group=root) - wr.ScriptResource(name='root-script', resource='root.js', group=root) - wr.LinkResource(name='root-link', resource='root.link', group=root) - - group = wr.ResourceGroup(name='group', group=root) - wr.StyleResource(name='group-style', resource='group.css', group=group) - wr.ScriptResource(name='group-script', resource='group.js', group=group) - wr.LinkResource(name='group-link', resource='group.link', group=group) - - self.assertEqual( - sorted([res.name for res in root.scripts]), - ['group-script', 'root-script'] - ) - self.assertEqual( - sorted([res.name for res in root.styles]), - ['group-style', 'root-style'] - ) - self.assertEqual( - sorted([res.name for res in root.links]), - ['group-link', 'root-link'] - ) - - resource = wr.Resource(resource='res') - with self.assertRaises(wr.ResourceError): - resource.remove() - - group = wr.ResourceGroup() - resource = wr.Resource(resource='res', group=group) - self.assertEqual(group.members, [resource]) - resource.remove() - self.assertEqual(group.members, []) - self.assertEqual(resource.parent, None) - - def test_ResourceConflictError(self): - counter = Counter(['a', 'b', 'b', 'c', 'c']) - err = wr.ResourceConflictError(counter) - self.assertEqual(str(err), 'Conflicting resource names: [\'b\', \'c\']') - - def test_ResourceCircularDependencyError(self): - resource = Resource(name='res1', resource='res1.ext', depends='res2') - err = wr.ResourceCircularDependencyError([resource]) - self.assertEqual(str(err), ( - 'Resources define circular dependencies: ' - '[Resource name="res1", depends="[\'res2\']"]' - )) - - def test_ResourceMissingDependencyError(self): - resource = Resource(name='res', resource='res.ext', depends='missing') - err = wr.ResourceMissingDependencyError(resource) - self.assertEqual(str(err), ( - 'Resource defines missing dependency: ' - 'Resource name="res", depends="[\'missing\']"' - )) - - def test_ResourceResolver__flat_resources(self): - self.assertRaises(wr.ResourceError, wr.ResourceResolver, object()) - - res1 = Resource(name='res1', resource='res1.ext') - resolver = wr.ResourceResolver(res1) - self.assertEqual(resolver.members, [res1]) - self.assertEqual(resolver._flat_resources(), [res1]) - - res2 = Resource(name='res2', resource='res2.ext') - resolver = wr.ResourceResolver([res1, res2]) - self.assertEqual(resolver.members, [res1, res2]) - self.assertEqual(resolver._flat_resources(), [res1, res2]) - - res3 = Resource(name='res3', resource='res3.ext') - - group1 = wr.ResourceGroup(name='group1') - group1.add(res1) - - group2 = wr.ResourceGroup(name='group2') - group2.add(res2) - - group3 = wr.ResourceGroup(name='group3') - group3.add(res3) - group3.add(group2) - - resolver = wr.ResourceResolver([group1, group3]) - self.assertEqual(resolver._flat_resources(), [res1, res3, res2]) - - res3.include = False - self.assertEqual(resolver._flat_resources(), [res1, res2]) - - res3.include = True - group3.include = False - self.assertEqual(resolver._flat_resources(), [res1]) - - def test_ResourceResolver_resolve(self): - resolver = wr.ResourceResolver([ - Resource(name='res', resource='res.ext'), - Resource(name='res', resource='res.ext') - ]) - self.assertRaises(wr.ResourceConflictError, resolver.resolve) - - res1 = Resource(name='res1', resource='res1.ext', depends='res2') - res2 = Resource(name='res2', resource='res2.ext', depends='res3') - res3 = Resource(name='res3', resource='res3.ext') - - resolver = wr.ResourceResolver([res1, res2, res3]) - self.assertEqual(resolver.resolve(), [res3, res2, res1]) - - resolver = wr.ResourceResolver([res2, res1, res3]) - self.assertEqual(resolver.resolve(), [res3, res2, res1]) - - resolver = wr.ResourceResolver([res1, res3, res2]) - self.assertEqual(resolver.resolve(), [res3, res2, res1]) - - res1 = Resource(name='res1', resource='res1.ext', depends='res2') - res2 = Resource(name='res2', resource='res2.ext', depends='res1') - - resolver = wr.ResourceResolver([res1, res2]) - self.assertRaises(wr.ResourceCircularDependencyError, resolver.resolve) - - res1 = Resource(name='res1', resource='res1.ext', depends='res2') - res2 = Resource(name='res2', resource='res2.ext', depends='missing') - - resolver = wr.ResourceResolver([res1, res2]) - self.assertRaises(wr.ResourceMissingDependencyError, resolver.resolve) - - res1 = Resource(name='res1', resource='res1.ext', depends=['res2', 'res4']) - res2 = Resource(name='res2', resource='res2.ext', depends=['res3', 'res4']) - res3 = Resource(name='res3', resource='res3.ext', depends=['res4', 'res5']) - res4 = Resource(name='res4', resource='res4.ext', depends='res5') - res5 = Resource(name='res5', resource='res5.ext') - - resolver = wr.ResourceResolver([res1, res2, res3, res4, res5]) - self.assertEqual(resolver.resolve(), [res5, res4, res3, res2, res1]) - - resolver = wr.ResourceResolver([res5, res4, res3, res2, res1]) - self.assertEqual(resolver.resolve(), [res5, res4, res3, res2, res1]) - - resolver = wr.ResourceResolver([res4, res5, res2, res3, res1]) - self.assertEqual(resolver.resolve(), [res5, res4, res3, res2, res1]) - - resolver = wr.ResourceResolver([res1, res3, res2, res5, res4]) - self.assertEqual(resolver.resolve(), [res5, res4, res3, res2, res1]) - - res1 = Resource(name='res1', resource='res1.ext', depends=['res2', 'res3']) - res2 = Resource(name='res2', resource='res2.ext', depends=['res1', 'res3']) - res3 = Resource(name='res3', resource='res3.ext', depends=['res1', 'res2']) - - resolver = wr.ResourceResolver([res1, res2, res3]) - self.assertRaises(wr.ResourceCircularDependencyError, resolver.resolve) - - res1 = Resource(name='res1', resource='res1.ext', depends=['res2', 'res3']) - res2 = Resource(name='res2', resource='res2.ext', depends=['res1', 'res3']) - res3 = Resource(name='res3', resource='res3.ext', depends=['res1', 'res4']) - - resolver = wr.ResourceResolver([res1, res2, res3]) - self.assertRaises(wr.ResourceMissingDependencyError, resolver.resolve) - - def test_ResourceRenderer(self): - resources = wr.ResourceGroup('res', path='res') - wr.LinkResource( - name='icon', - resource='icon.png', - group=resources, - rel='icon', - type_='image/png' - ) - wr.StyleResource(name='css', resource='styles.css', group=resources) - wr.StyleResource( - name='ext_css', - url='https://ext.org/styles.css', - group=resources - ) - wr.ScriptResource( - name='js', - resource='script.js', - compressed='script.min.js', - group=resources - ) - resolver = wr.ResourceResolver(resources) - renderer = wr.ResourceRenderer(resolver, base_url='https://tld.org') - - rendered = renderer.render() - self.assertEqual(rendered, ( - '\n' - '\n' - '\n' - '' - )) - - wr.config.development = True - rendered = renderer.render() - self.assertEqual(rendered, ( - '\n' - '\n' - '\n' - '' - )) - - # check if unique raises on render b/c file does not exist. - wr.ScriptResource( - name='js2', - directory='', - resource='script2.js', - compressed='script2.min.js', - group=resources, - unique=True, - ) - with self.assertRaises(FileNotFoundError): - renderer.render() - - def test_GracefulResourceRenderer(self): - resources = wr.ResourceGroup('res', path='res') - wr.LinkResource( - name='icon', - resource='icon.png', - group=resources, - rel='icon', - type_='image/png', - ) - wr.StyleResource(name='css', resource='styles.css', group=resources) - wr.StyleResource( - name='ext_css', - url='https://ext.org/styles.css', - group=resources, - ) - wr.ScriptResource( - name='js', - resource='script.js', - compressed='script.min.js', - group=resources, - ) - resolver = wr.ResourceResolver(resources) - renderer = wr.GracefulResourceRenderer( - resolver, - base_url='https://tld.org', - ) - rendered = renderer.render() - self.assertEqual(rendered, ( - '\n' - '\n' - '\n' - '' - )) - - wr.config.development = True - rendered = renderer.render() - self.assertEqual(rendered, ( - '\n' - '\n' - '\n' - '' - )) - # check if unique raises on is catched on render and turned into - wr.ScriptResource( - name='js2', - directory='', - resource='script2.js', - compressed='script2.min.js', - group=resources, - depends="js", - unique=True, - ) - with self.assertLogs() as captured: - rendered = renderer.render() - # check that there is only one log message - self.assertEqual(len(captured.records), 1) - # check if its ours - self.assertEqual( - captured.records[0].getMessage().split('\n')[0], - 'Failure to render resource "js2"', - ) - self.assertEqual(rendered, ( - '\n' - '\n' - '\n' - '\n' - '' - )) - - -if __name__ == '__main__': - unittest.main() From b2179c69551604d6c99bf8fc07340a77e1eb602c Mon Sep 17 00:00:00 2001 From: Robert Niederreiter Date: Tue, 4 Nov 2025 09:51:07 +0100 Subject: [PATCH 14/17] run isort and ruff --- Makefile | 138 +++++++++++++++++++ pyproject.toml | 10 ++ tests/test_base.py | 12 +- tests/test_config.py | 6 +- tests/test_exceptions.py | 30 +++-- tests/test_groups.py | 24 ++-- tests/test_renderer.py | 123 +++++++++-------- tests/test_resolver.py | 16 +-- tests/test_resources.py | 156 ++++++++-------------- tests/test_utils.py | 4 +- webresource/__init__.py | 26 ++-- webresource/base.py | 8 +- webresource/groups.py | 27 ++-- webresource/renderer.py | 10 +- webresource/resolver.py | 11 +- webresource/resources.py | 277 ++++++++++++++++++++++++++++----------- 16 files changed, 537 insertions(+), 341 deletions(-) diff --git a/Makefile b/Makefile index 02fc0a0..533abc8 100644 --- a/Makefile +++ b/Makefile @@ -8,6 +8,9 @@ #: core.packages #: docs.sphinx #: qa.coverage +#: qa.isort +#: qa.mypy +#: qa.ruff #: qa.test # # SETTINGS (ALL CHANGES MADE BELOW SETTINGS WILL BE LOST) @@ -101,6 +104,18 @@ MXDEV?=mxdev # Default: mxmake MXMAKE?=mxmake +## qa.ruff + +# Source folder to scan for Python files to run ruff on. +# Default: src +RUFF_SRC?=webresource tests + +## qa.isort + +# Source folder to scan for Python files to run isort on. +# Default: src +ISORT_SRC?=webresource tests + ## docs.sphinx # Documentation source folder. @@ -159,6 +174,16 @@ COVERAGE_COMMAND?=\ -m pytest tests \ && coverage report --fail-under=100 +## qa.mypy + +# Source folder for code analysis. +# Default: src +MYPY_SRC?=webresource tests + +# Mypy Python requirements to be installed (via pip). +# Default: types-setuptools +MYPY_REQUIREMENTS?= + ############################################################################## # END SETTINGS - DO NOT EDIT BELOW THIS LINE ############################################################################## @@ -315,6 +340,85 @@ INSTALL_TARGETS+=mxenv DIRTY_TARGETS+=mxenv-dirty CLEAN_TARGETS+=mxenv-clean +############################################################################## +# ruff +############################################################################## + +# Adjust RUFF_SRC to respect PROJECT_PATH_PYTHON if still at default +ifeq ($(RUFF_SRC),src) +RUFF_SRC:=$(PYTHON_PROJECT_PREFIX)src +endif + +RUFF_TARGET:=$(SENTINEL_FOLDER)/ruff.sentinel +$(RUFF_TARGET): $(MXENV_TARGET) + @echo "Install Ruff" + @$(PYTHON_PACKAGE_COMMAND) install ruff + @touch $(RUFF_TARGET) + +.PHONY: ruff-check +ruff-check: $(RUFF_TARGET) + @echo "Run ruff check" + @ruff check $(RUFF_SRC) + +.PHONY: ruff-format +ruff-format: $(RUFF_TARGET) + @echo "Run ruff format" + @ruff format $(RUFF_SRC) + +.PHONY: ruff-dirty +ruff-dirty: + @rm -f $(RUFF_TARGET) + +.PHONY: ruff-clean +ruff-clean: ruff-dirty + @test -e $(MXENV_PYTHON) && $(MXENV_PYTHON) -m pip uninstall -y ruff || : + @rm -rf .ruff_cache + +INSTALL_TARGETS+=$(RUFF_TARGET) +CHECK_TARGETS+=ruff-check +FORMAT_TARGETS+=ruff-format +DIRTY_TARGETS+=ruff-dirty +CLEAN_TARGETS+=ruff-clean + +############################################################################## +# isort +############################################################################## + +# Adjust ISORT_SRC to respect PROJECT_PATH_PYTHON if still at default +ifeq ($(ISORT_SRC),src) +ISORT_SRC:=$(PYTHON_PROJECT_PREFIX)src +endif + +ISORT_TARGET:=$(SENTINEL_FOLDER)/isort.sentinel +$(ISORT_TARGET): $(MXENV_TARGET) + @echo "Install isort" + @$(PYTHON_PACKAGE_COMMAND) install isort + @touch $(ISORT_TARGET) + +.PHONY: isort-check +isort-check: $(ISORT_TARGET) + @echo "Run isort check" + @isort --check $(ISORT_SRC) + +.PHONY: isort-format +isort-format: $(ISORT_TARGET) + @echo "Run isort format" + @isort $(ISORT_SRC) + +.PHONY: isort-dirty +isort-dirty: + @rm -f $(ISORT_TARGET) + +.PHONY: isort-clean +isort-clean: isort-dirty + @test -e $(MXENV_PYTHON) && $(MXENV_PYTHON) -m pip uninstall -y isort || : + +INSTALL_TARGETS+=$(ISORT_TARGET) +CHECK_TARGETS+=isort-check +FORMAT_TARGETS+=isort-format +DIRTY_TARGETS+=isort-dirty +CLEAN_TARGETS+=isort-clean + ############################################################################## # sphinx ############################################################################## @@ -518,6 +622,40 @@ INSTALL_TARGETS+=$(COVERAGE_TARGET) DIRTY_TARGETS+=coverage-dirty CLEAN_TARGETS+=coverage-clean +############################################################################## +# mypy +############################################################################## + +# Adjust MYPY_SRC to respect PROJECT_PATH_PYTHON if still at default +ifeq ($(MYPY_SRC),src) +MYPY_SRC:=$(PYTHON_PROJECT_PREFIX)src +endif + +MYPY_TARGET:=$(SENTINEL_FOLDER)/mypy.sentinel +$(MYPY_TARGET): $(MXENV_TARGET) + @echo "Install mypy" + @$(PYTHON_PACKAGE_COMMAND) install mypy $(MYPY_REQUIREMENTS) + @touch $(MYPY_TARGET) + +.PHONY: mypy +mypy: $(PACKAGES_TARGET) $(MYPY_TARGET) + @echo "Run mypy" + @mypy $(MYPY_SRC) + +.PHONY: mypy-dirty +mypy-dirty: + @rm -f $(MYPY_TARGET) + +.PHONY: mypy-clean +mypy-clean: mypy-dirty + @test -e $(MXENV_PYTHON) && $(MXENV_PYTHON) -m pip uninstall -y mypy || : + @rm -rf .mypy_cache + +INSTALL_TARGETS+=$(MYPY_TARGET) +TYPECHECK_TARGETS+=mypy +CLEAN_TARGETS+=mypy-clean +DIRTY_TARGETS+=mypy-dirty + ############################################################################## # Custom includes ############################################################################## diff --git a/pyproject.toml b/pyproject.toml index 2f037b0..56aca7d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,5 +48,15 @@ include = ["docs/source/overview.rst"] [tool.hatch.build.targets.wheel] packages = ["webresource"] +[tool.ruff] +target-version = "py310" + +[tool.ruff.format] +quote-style = "single" + +[tool.isort] +py_version = 310 +profile = "plone" + [tool.zest-releaser] create-wheel = true diff --git a/tests/test_base.py b/tests/test_base.py index eae4fa4..249598c 100644 --- a/tests/test_base.py +++ b/tests/test_base.py @@ -1,17 +1,13 @@ -# -*- coding: utf-8 -*- -"""Tests for webresource.base module.""" +from tests.test_utils import np +from webresource.base import ResourceMixin + import os import unittest -from webresource.base import ResourceMixin -from tests.test_utils import np class TestBase(unittest.TestCase): - def test_ResourceMixin(self): - mixin = ResourceMixin( - name='name', path='path', include=True - ) + mixin = ResourceMixin(name='name', path='path', include=True) self.assertEqual(mixin.name, 'name') self.assertEqual(mixin.path, 'path') self.assertEqual(mixin.include, True) diff --git a/tests/test_config.py b/tests/test_config.py index 52c2da7..b0b0503 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,12 +1,10 @@ -# -*- coding: utf-8 -*- -"""Tests for webresource.config module.""" +from webresource.config import ResourceConfig + import unittest import webresource as wr -from webresource.config import ResourceConfig class TestConfig(unittest.TestCase): - def test_ResourceConfig(self): config = ResourceConfig() self.assertIsInstance(wr.config, ResourceConfig) diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py index c22e9a0..2dba82b 100644 --- a/tests/test_exceptions.py +++ b/tests/test_exceptions.py @@ -1,33 +1,37 @@ -# -*- coding: utf-8 -*- -"""Tests for webresource.exceptions module.""" from collections import Counter +from webresource.resources import Resource + import unittest import webresource as wr -from webresource.resources import Resource class TestExceptions(unittest.TestCase): - def test_ResourceConflictError(self): counter = Counter(['a', 'b', 'b', 'c', 'c']) err = wr.ResourceConflictError(counter) - self.assertEqual(str(err), 'Conflicting resource names: [\'b\', \'c\']') + self.assertEqual(str(err), "Conflicting resource names: ['b', 'c']") def test_ResourceCircularDependencyError(self): resource = Resource(name='res1', resource='res1.ext', depends='res2') err = wr.ResourceCircularDependencyError([resource]) - self.assertEqual(str(err), ( - 'Resources define circular dependencies: ' - '[Resource name="res1", depends="[\'res2\']"]' - )) + self.assertEqual( + str(err), + ( + 'Resources define circular dependencies: ' + '[Resource name="res1", depends="[\'res2\']"]' + ), + ) def test_ResourceMissingDependencyError(self): resource = Resource(name='res', resource='res.ext', depends='missing') err = wr.ResourceMissingDependencyError(resource) - self.assertEqual(str(err), ( - 'Resource defines missing dependency: ' - 'Resource name="res", depends="[\'missing\']"' - )) + self.assertEqual( + str(err), + ( + 'Resource defines missing dependency: ' + 'Resource name="res", depends="[\'missing\']"' + ), + ) if __name__ == '__main__': diff --git a/tests/test_groups.py b/tests/test_groups.py index f34bd52..581cea7 100644 --- a/tests/test_groups.py +++ b/tests/test_groups.py @@ -1,12 +1,10 @@ -# -*- coding: utf-8 -*- -"""Tests for webresource.groups module.""" +from webresource.base import ResourceMixin + import unittest import webresource as wr -from webresource.base import ResourceMixin class TestGroups(unittest.TestCase): - def test_ResourceGroup(self): group = wr.ResourceGroup(name='groupname') self.assertIsInstance(group, ResourceMixin) @@ -24,17 +22,14 @@ def test_ResourceGroup(self): root_group = wr.ResourceGroup(name='root') member_group = wr.ResourceGroup(name='member', group=root_group) member_res = wr.ScriptResource( - name='res', - resource='res.js', - group=member_group + name='res', resource='res.js', group=member_group ) self.assertTrue(member_group.parent is root_group) self.assertTrue(member_res.parent is member_group) group = wr.ResourceGroup( - name='groupname', - path='group_path', - directory='/path/to/dir') + name='groupname', path='group_path', directory='/path/to/dir' + ) group.add(wr.ResourceGroup(name='group1')) wr.ResourceGroup(name='group2', group=group) @@ -54,16 +49,13 @@ def test_ResourceGroup(self): wr.LinkResource(name='group-link', resource='group.link', group=group) self.assertEqual( - sorted([res.name for res in root.scripts]), - ['group-script', 'root-script'] + sorted([res.name for res in root.scripts]), ['group-script', 'root-script'] ) self.assertEqual( - sorted([res.name for res in root.styles]), - ['group-style', 'root-style'] + sorted([res.name for res in root.styles]), ['group-style', 'root-style'] ) self.assertEqual( - sorted([res.name for res in root.links]), - ['group-link', 'root-link'] + sorted([res.name for res in root.links]), ['group-link', 'root-link'] ) resource = wr.Resource(resource='res') diff --git a/tests/test_renderer.py b/tests/test_renderer.py index fe52757..3ad70b4 100644 --- a/tests/test_renderer.py +++ b/tests/test_renderer.py @@ -1,11 +1,8 @@ -# -*- coding: utf-8 -*- -"""Tests for webresource.renderer module.""" import unittest import webresource as wr class TestRenderer(unittest.TestCase): - def tearDown(self): wr.config.development = False @@ -16,45 +13,46 @@ def test_ResourceRenderer(self): resource='icon.png', group=resources, rel='icon', - type_='image/png' + type_='image/png', ) wr.StyleResource(name='css', resource='styles.css', group=resources) wr.StyleResource( - name='ext_css', - url='https://ext.org/styles.css', - group=resources + name='ext_css', url='https://ext.org/styles.css', group=resources ) wr.ScriptResource( - name='js', - resource='script.js', - compressed='script.min.js', - group=resources + name='js', resource='script.js', compressed='script.min.js', group=resources ) resolver = wr.ResourceResolver(resources) renderer = wr.ResourceRenderer(resolver, base_url='https://tld.org') rendered = renderer.render() - self.assertEqual(rendered, ( - '\n' - '\n' - '\n' - '' - )) + self.assertEqual( + rendered, + ( + '\n' + '\n' + '\n' + '' + ), + ) wr.config.development = True rendered = renderer.render() - self.assertEqual(rendered, ( - '\n' - '\n' - '\n' - '' - )) + self.assertEqual( + rendered, + ( + '\n' + '\n' + '\n' + '' + ), + ) # check if unique raises on render b/c file does not exist. wr.ScriptResource( @@ -95,27 +93,33 @@ def test_GracefulResourceRenderer(self): base_url='https://tld.org', ) rendered = renderer.render() - self.assertEqual(rendered, ( - '\n' - '\n' - '\n' - '' - )) + self.assertEqual( + rendered, + ( + '\n' + '\n' + '\n' + '' + ), + ) wr.config.development = True rendered = renderer.render() - self.assertEqual(rendered, ( - '\n' - '\n' - '\n' - '' - )) + self.assertEqual( + rendered, + ( + '\n' + '\n' + '\n' + '' + ), + ) # check if unique raises on is catched on render and turned into wr.ScriptResource( name='js2', @@ -123,7 +127,7 @@ def test_GracefulResourceRenderer(self): resource='script2.js', compressed='script2.min.js', group=resources, - depends="js", + depends='js', unique=True, ) with self.assertLogs() as captured: @@ -135,16 +139,19 @@ def test_GracefulResourceRenderer(self): captured.records[0].getMessage().split('\n')[0], 'Failure to render resource "js2"', ) - self.assertEqual(rendered, ( - '\n' - '\n' - '\n' - '\n' - '' - )) + self.assertEqual( + rendered, + ( + '\n' + '\n' + '\n' + '\n' + '' + ), + ) if __name__ == '__main__': diff --git a/tests/test_resolver.py b/tests/test_resolver.py index 118c039..7e55d6b 100644 --- a/tests/test_resolver.py +++ b/tests/test_resolver.py @@ -1,12 +1,10 @@ -# -*- coding: utf-8 -*- -"""Tests for webresource.resolver module.""" +from webresource.resources import Resource + import unittest import webresource as wr -from webresource.resources import Resource class TestResolver(unittest.TestCase): - def test_ResourceResolver__flat_resources(self): self.assertRaises(wr.ResourceError, wr.ResourceResolver, object()) @@ -43,10 +41,12 @@ def test_ResourceResolver__flat_resources(self): self.assertEqual(resolver._flat_resources(), [res1]) def test_ResourceResolver_resolve(self): - resolver = wr.ResourceResolver([ - Resource(name='res', resource='res.ext'), - Resource(name='res', resource='res.ext') - ]) + resolver = wr.ResourceResolver( + [ + Resource(name='res', resource='res.ext'), + Resource(name='res', resource='res.ext'), + ] + ) self.assertRaises(wr.ResourceConflictError, resolver.resolve) res1 = Resource(name='res1', resource='res1.ext', depends='res2') diff --git a/tests/test_resources.py b/tests/test_resources.py index c655a1e..328c3f9 100644 --- a/tests/test_resources.py +++ b/tests/test_resources.py @@ -1,15 +1,15 @@ -# -*- coding: utf-8 -*- -"""Tests for webresource.resources module.""" +from tests.test_utils import np +from tests.test_utils import temp_directory +from webresource.base import ResourceMixin +from webresource.resources import LinkMixin +from webresource.resources import Resource + import os import unittest import webresource as wr -from webresource.base import ResourceMixin -from webresource.resources import LinkMixin, Resource -from tests.test_utils import np, temp_directory class TestResources(unittest.TestCase): - def tearDown(self): wr.config.development = False @@ -33,10 +33,7 @@ def test_Resource(self, tempdir): self.assertEqual(resource.crossorigin, None) self.assertEqual(resource.referrerpolicy, None) self.assertEqual(resource.type_, None) - self.assertEqual( - repr(resource), - 'Resource name="res", depends="None"' - ) + self.assertEqual(repr(resource), 'Resource name="res", depends="None"') resource = Resource(name='res', resource='res.ext') self.assertEqual(resource.file_name, 'res.ext') @@ -61,10 +58,10 @@ def test_Resource(self, tempdir): self.assertTrue(group.members[0] is resource) rendered = resource._render_tag('tag', False, foo='bar', baz=None) - self.assertEqual(rendered, u'') + self.assertEqual(rendered, '') rendered = resource._render_tag('tag', True, foo='bar', baz=None) - self.assertEqual(rendered, u'') + self.assertEqual(rendered, '') self.assertRaises(NotImplementedError, resource.render, '') @@ -81,10 +78,7 @@ def test_Resource(self, tempdir): self.assertEqual(resource_url, 'https://tld.org/resources/res.ext') resource = Resource( - name='res', - resource='res.ext', - compressed='res.min', - path='/resources' + name='res', resource='res.ext', compressed='res.min', path='/resources' ) resource_url = resource.resource_url('https://tld.org') self.assertEqual(resource_url, 'https://tld.org/resources/res.min') @@ -99,15 +93,12 @@ def test_Resource(self, tempdir): wr.config.development = False with open(os.path.join(tempdir, 'res'), 'wb') as f: - f.write(u'Resource Content ä'.encode('utf8')) + f.write('Resource Content ä'.encode('utf8')) resource = Resource(name='res', resource='res', directory=tempdir) self.assertEqual(resource.file_data, b'Resource Content \xc3\xa4') - hash_ = ( - 'VwEVpw/Hy4OlSeTX7oDQ/lzkncnWgKEV' - '0zOX9OXa9Uy+qypLkrBrJxPtNsax1HJo' - ) + hash_ = 'VwEVpw/Hy4OlSeTX7oDQ/lzkncnWgKEV0zOX9OXa9Uy+qypLkrBrJxPtNsax1HJo' self.assertEqual(resource.file_hash, hash_) resource_url = resource.resource_url('https://tld.org') @@ -115,16 +106,12 @@ def test_Resource(self, tempdir): unique_key = resource.unique_key self.assertEqual( - unique_key, - '++webresource++4be37419-d3f6-5ec5-99e8-92565ede87d0' + unique_key, '++webresource++4be37419-d3f6-5ec5-99e8-92565ede87d0' ) 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, 'https://tld.org/{}/res'.format(unique_key)) with open(os.path.join(tempdir, 'res'), 'w') as f: f.write('Changed Content') @@ -133,25 +120,15 @@ 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, 'https://tld.org/{}/res'.format(unique_key)) 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, 'https://tld.org/{}/res'.format(unique_key)) - resource = Resource( - name='res', - resource='res.ext', - custom_attr='value' - ) + resource = Resource(name='res', resource='res.ext', custom_attr='value') self.assertEqual(resource.additional_attrs, dict(custom_attr='value')) @temp_directory @@ -161,18 +138,15 @@ def test_ScriptResource(self, tempdir): self.assertEqual(script.defer, None) self.assertEqual(script.integrity, None) self.assertEqual(script.nomodule, None) - self.assertEqual( - repr(script), - 'ScriptResource name="js_res", depends="None"' - ) + self.assertEqual(repr(script), 'ScriptResource name="js_res", depends="None"') self.assertEqual( script.render('https://tld.org'), - '' + '', ) script.type_ = 'module' self.assertEqual( script.render('https://tld.org'), - '' + '', ) script.url = 'https://ext.org/script.js' @@ -185,10 +159,7 @@ def test_ScriptResource(self, tempdir): f.write('Script Content') script = wr.ScriptResource( - name='script', - resource='script.js', - directory=tempdir, - integrity=True + name='script', resource='script.js', directory=tempdir, integrity=True ) hash_ = 'omjyXfsb+ti/5fpn4QjjSYjpKRnxWpzc6rIUE6mXxyDjbLS9AotgsLWQZtylXicX' self.assertEqual(script.file_hash, hash_) @@ -206,14 +177,10 @@ def test_ScriptResource(self, tempdir): wr.config.development = True self.assertNotEqual(script.integrity, 'sha384-{}'.format(hash_)) - script = wr.ScriptResource( - name='js_res', - resource='res.js', - custom='value' - ) + script = wr.ScriptResource(name='js_res', resource='res.js', custom='value') self.assertEqual( script.render('https://tld.org'), - '' + '', ) def test_LinkMixin(self): @@ -223,52 +190,44 @@ def test_LinkMixin(self): self.assertEqual(link.rel, None) self.assertEqual(link.sizes, None) self.assertEqual(link.title, None) - self.assertEqual( - repr(link), - 'LinkMixin name="link_res", depends="None"' - ) + self.assertEqual(repr(link), 'LinkMixin name="link_res", depends="None"') link.hreflang = 'en' link.media = 'screen' link.rel = 'alternate' link.type_ = 'text/markdown' - self.assertEqual(link.render('https://tld.org'), ( - '' - )) - - link = LinkMixin( - name='link_res', - resource='resource.md', - custom='value' + self.assertEqual( + link.render('https://tld.org'), + ( + '' + ), ) + + link = LinkMixin(name='link_res', resource='resource.md', custom='value') self.assertEqual( link.render('https://tld.org'), - '' + '', ) def test_LinkResource(self): link = wr.LinkResource(name='icon_res', resource='icon.png') self.assertIsInstance(link, LinkMixin) - self.assertEqual( - repr(link), - 'LinkResource name="icon_res", depends="None"' - ) + self.assertEqual(repr(link), 'LinkResource name="icon_res", depends="None"') link.rel = 'icon' link.type_ = 'image/png' link.sizes = '16x16' - self.assertEqual(link.render('https://tld.org'), ( - '' - )) - - link = wr.LinkResource( - name='icon_res', - resource='icon.png', - custom='value' + self.assertEqual( + link.render('https://tld.org'), + ( + '' + ), ) + + link = wr.LinkResource(name='icon_res', resource='icon.png', custom='value') self.assertEqual( link.render('https://tld.org'), - '' + '', ) def test_StyleResource(self): @@ -277,24 +236,23 @@ def test_StyleResource(self): self.assertEqual(style.type_, 'text/css') self.assertEqual(style.media, 'all') self.assertEqual(style.rel, 'stylesheet') + self.assertEqual(repr(style), 'StyleResource name="css_res", depends="None"') self.assertEqual( - repr(style), - 'StyleResource name="css_res", depends="None"' + style.render('https://tld.org'), + ( + '' + ), ) - self.assertEqual(style.render('https://tld.org'), ( - '' - )) - - style = wr.StyleResource( - name='css_res', - resource='res.css', - custom='value' + + style = wr.StyleResource(name='css_res', resource='res.css', custom='value') + self.assertEqual( + style.render('https://tld.org'), + ( + '' + ), ) - self.assertEqual(style.render('https://tld.org'), ( - '' - )) if __name__ == '__main__': diff --git a/tests/test_utils.py b/tests/test_utils.py index fdec034..4f8ea16 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- -"""Shared test utilities for webresource tests.""" import os import shutil import tempfile @@ -7,6 +5,7 @@ def temp_directory(fn): """Decorator that provides a temporary directory to test functions.""" + def wrapper(*a, **kw): tempdir = tempfile.mkdtemp() kw['tempdir'] = tempdir @@ -14,6 +13,7 @@ def wrapper(*a, **kw): fn(*a, **kw) finally: shutil.rmtree(tempdir) + return wrapper diff --git a/webresource/__init__.py b/webresource/__init__.py index bb4cf79..49bc380 100644 --- a/webresource/__init__.py +++ b/webresource/__init__.py @@ -1,19 +1,13 @@ from webresource.config import config # noqa -from webresource.exceptions import ( # noqa - ResourceCircularDependencyError, - ResourceConflictError, - ResourceError, - ResourceMissingDependencyError -) +from webresource.exceptions import ResourceCircularDependencyError # noqa +from webresource.exceptions import ResourceConflictError # noqa +from webresource.exceptions import ResourceError # noqa +from webresource.exceptions import ResourceMissingDependencyError # noqa from webresource.groups import ResourceGroup # noqa -from webresource.renderer import ( # noqa - GracefulResourceRenderer, - ResourceRenderer -) +from webresource.renderer import GracefulResourceRenderer # noqa +from webresource.renderer import ResourceRenderer # noqa from webresource.resolver import ResourceResolver # noqa -from webresource.resources import ( # noqa - LinkResource, - Resource, - ScriptResource, - StyleResource -) +from webresource.resources import LinkResource # noqa +from webresource.resources import Resource # noqa +from webresource.resources import ScriptResource # noqa +from webresource.resources import StyleResource # noqa diff --git a/webresource/base.py b/webresource/base.py index 3df95ce..df074db 100644 --- a/webresource/base.py +++ b/webresource/base.py @@ -1,15 +1,13 @@ +from webresource.exceptions import ResourceError + import copy import os -from webresource.exceptions import ResourceError - class ResourceMixin(object): """Mixin for ``Resource`` and ``ResourceGroup``.""" - def __init__( - self, name='', directory=None, path=None, include=True, group=None - ): + def __init__(self, name='', directory=None, path=None, include=True, group=None): self.name = name self.directory = directory self.path = path diff --git a/webresource/groups.py b/webresource/groups.py index 51d64fa..7a7c25e 100644 --- a/webresource/groups.py +++ b/webresource/groups.py @@ -1,19 +1,15 @@ from webresource.base import ResourceMixin from webresource.exceptions import ResourceError -from webresource.resources import ( - LinkResource, - Resource, - ScriptResource, - StyleResource -) +from webresource.resources import LinkResource +from webresource.resources import Resource +from webresource.resources import ScriptResource +from webresource.resources import StyleResource class ResourceGroup(ResourceMixin): """A resource group.""" - def __init__( - self, name='', directory=None, path=None, include=True, group=None - ): + def __init__(self, name='', directory=None, path=None, include=True, group=None): """Create resource group. :param name: The resource group name. @@ -25,8 +21,7 @@ def __init__( :param group: Optional resource group instance. """ super(ResourceGroup, self).__init__( - name=name, directory=directory, path=path, - include=include, group=group + name=name, directory=directory, path=path, include=include, group=group ) self._members = [] @@ -82,16 +77,10 @@ def _filtered_resources(self, type_, members=None): resources = [] for member in members: if isinstance(member, ResourceGroup): - resources += self._filtered_resources( - type_, - members=member.members - ) + resources += self._filtered_resources(type_, members=member.members) elif isinstance(member, type_): resources.append(member) return resources def __repr__(self): - return '{} name="{}"'.format( - self.__class__.__name__, - self.name - ) + return '{} name="{}"'.format(self.__class__.__name__, self.name) diff --git a/webresource/renderer.py b/webresource/renderer.py index ff52470..573e7da 100644 --- a/webresource/renderer.py +++ b/webresource/renderer.py @@ -16,9 +16,7 @@ def __init__(self, resolver, base_url='https://tld.org'): def render(self): """Render resources.""" - return u'\n'.join([ - res.render(self.base_url) for res in self.resolver.resolve() - ]) + return '\n'.join([res.render(self.base_url) for res in self.resolver.resolve()]) class GracefulResourceRenderer(ResourceRenderer): @@ -30,7 +28,7 @@ def render(self): try: lines.append(resource.render(self.base_url)) except (ResourceError, FileNotFoundError): - msg = u'Failure to render resource "{}"'.format(resource.name) - lines.append(u''.format(msg)) + msg = 'Failure to render resource "{}"'.format(resource.name) + lines.append(''.format(msg)) logger.exception(msg) - return u'\n'.join(lines) + return '\n'.join(lines) diff --git a/webresource/resolver.py b/webresource/resolver.py index 1e4988e..d0a9390 100644 --- a/webresource/resolver.py +++ b/webresource/resolver.py @@ -1,11 +1,8 @@ from collections import Counter - -from webresource.exceptions import ( - ResourceCircularDependencyError, - ResourceConflictError, - ResourceError, - ResourceMissingDependencyError -) +from webresource.exceptions import ResourceCircularDependencyError +from webresource.exceptions import ResourceConflictError +from webresource.exceptions import ResourceError +from webresource.exceptions import ResourceMissingDependencyError from webresource.groups import ResourceGroup from webresource.resources import Resource diff --git a/webresource/resources.py b/webresource/resources.py index fe03c7d..7f45040 100644 --- a/webresource/resources.py +++ b/webresource/resources.py @@ -1,27 +1,39 @@ +from webresource.base import ResourceMixin +from webresource.config import config +from webresource.config import namespace_uuid +from webresource.exceptions import ResourceError + import base64 import hashlib import os import uuid -from webresource.base import ResourceMixin -from webresource.config import config, namespace_uuid -from webresource.exceptions import ResourceError - class Resource(ResourceMixin): """A web resource.""" _hash_algorithms = dict( - sha256=hashlib.sha256, - sha384=hashlib.sha384, - sha512=hashlib.sha512 + sha256=hashlib.sha256, sha384=hashlib.sha384, sha512=hashlib.sha512 ) def __init__( - self, name='', depends=None, directory=None, path=None, - resource=None, compressed=None, include=True, unique=False, - unique_prefix='++webresource++', hash_algorithm='sha384', group=None, - url=None, crossorigin=None, referrerpolicy=None, type_=None, **kwargs + self, + name='', + depends=None, + directory=None, + path=None, + resource=None, + compressed=None, + include=True, + unique=False, + unique_prefix='++webresource++', + hash_algorithm='sha384', + group=None, + url=None, + crossorigin=None, + referrerpolicy=None, + type_=None, + **kwargs, ): """Base class for resources. @@ -52,12 +64,12 @@ def __init__( if resource is None and url is None: raise ResourceError('Either resource or url must be given') super(Resource, self).__init__( - name=name, directory=directory, path=path, - include=include, group=group + name=name, directory=directory, path=path, include=include, group=group ) self.depends = ( (depends if isinstance(depends, (list, tuple)) else [depends]) - if depends else None + if depends + else None ) self.resource = resource self.compressed = compressed @@ -109,9 +121,8 @@ def file_hash(self, hash_): @property def unique_key(self): - return u'{}{}'.format( - self.unique_prefix, - str(uuid.uuid5(namespace_uuid, self.file_hash)) + return '{}{}'.format( + self.unique_prefix, str(uuid.uuid5(namespace_uuid, self.file_hash)) ) def resource_url(self, base_url): @@ -128,7 +139,7 @@ def resource_url(self, base_url): if self.unique: parts.append(self.unique_key) parts.append(self.file_name) - return u'/'.join(parts) + return '/'.join(parts) def render(self, base_url): """Renders the resource HTML tag. must be implemented on subclass. @@ -143,19 +154,15 @@ def _render_tag(self, tag, closing_tag, **attrs): for name, val in attrs.items(): if val is None: continue - attrs_.append(u'{0}="{1}"'.format(name, val)) - attrs_ = u' {0}'.format(u' '.join(sorted(attrs_))) + attrs_.append('{0}="{1}"'.format(name, val)) + attrs_ = ' {0}'.format(' '.join(sorted(attrs_))) if not closing_tag: - return u'<{tag}{attrs} />'.format(tag=tag, attrs=attrs_) - return u'<{tag}{attrs}>'.format(tag=tag, attrs=attrs_) + return '<{tag}{attrs} />'.format(tag=tag, attrs=attrs_) + return '<{tag}{attrs}>'.format(tag=tag, attrs=attrs_) def __repr__(self): - return ( - '{} name="{}", depends="{}"' - ).format( - self.__class__.__name__, - self.name, - self.depends + return ('{} name="{}", depends="{}"').format( + self.__class__.__name__, self.name, self.depends ) @@ -163,11 +170,27 @@ class ScriptResource(Resource): """A Javascript resource.""" def __init__( - self, name='', depends=None, directory=None, path=None, - resource=None, compressed=None, include=True, unique=False, - unique_prefix='++webresource++', hash_algorithm='sha384', group=None, - url=None, crossorigin=None, referrerpolicy=None, type_=None, - async_=None, defer=None, integrity=None, nomodule=None, **kwargs + self, + name='', + depends=None, + directory=None, + path=None, + resource=None, + compressed=None, + include=True, + unique=False, + unique_prefix='++webresource++', + hash_algorithm='sha384', + group=None, + url=None, + crossorigin=None, + referrerpolicy=None, + type_=None, + async_=None, + defer=None, + integrity=None, + nomodule=None, + **kwargs, ): """Create script resource. @@ -208,12 +231,22 @@ def __init__( :raise ResourceError: No resource and no url given. """ super(ScriptResource, self).__init__( - name=name, depends=depends, directory=directory, path=path, - resource=resource, compressed=compressed, include=include, - unique=unique, unique_prefix=unique_prefix, - hash_algorithm=hash_algorithm, group=group, url=url, - crossorigin=crossorigin, referrerpolicy=referrerpolicy, - type_=type_, **kwargs + name=name, + depends=depends, + directory=directory, + path=path, + resource=resource, + compressed=compressed, + include=include, + unique=unique, + unique_prefix=unique_prefix, + hash_algorithm=hash_algorithm, + group=group, + url=url, + crossorigin=crossorigin, + referrerpolicy=referrerpolicy, + type_=type_, + **kwargs, ) self.async_ = async_ self.defer = defer @@ -227,10 +260,7 @@ def integrity(self): if not config.development and self._integrity_hash is not None: return self._integrity_hash if self._integrity is True: - self._integrity_hash = u'{}-{}'.format( - self.hash_algorithm, - self.file_hash - ) + self._integrity_hash = '{}-{}'.format(self.hash_algorithm, self.file_hash) return self._integrity_hash @integrity.setter @@ -257,7 +287,7 @@ def render(self, base_url): 'async': self.async_, 'defer': self.defer, 'integrity': self.integrity, - 'nomodule': self.nomodule + 'nomodule': self.nomodule, } attrs.update(self.additional_attrs) return self._render_tag('script', True, **attrs) @@ -267,19 +297,46 @@ class LinkMixin(Resource): """Mixin class for link resources.""" def __init__( - self, name='', depends=None, directory=None, path=None, - resource=None, compressed=None, include=True, unique=False, - unique_prefix='++webresource++', hash_algorithm='sha384', group=None, - url=None, crossorigin=None, referrerpolicy=None, type_=None, - hreflang=None, media=None, rel=None, sizes=None, title=None, **kwargs + self, + name='', + depends=None, + directory=None, + path=None, + resource=None, + compressed=None, + include=True, + unique=False, + unique_prefix='++webresource++', + hash_algorithm='sha384', + group=None, + url=None, + crossorigin=None, + referrerpolicy=None, + type_=None, + hreflang=None, + media=None, + rel=None, + sizes=None, + title=None, + **kwargs, ): super(LinkMixin, self).__init__( - name=name, depends=depends, directory=directory, path=path, - resource=resource, compressed=compressed, include=include, - unique=unique, unique_prefix=unique_prefix, - hash_algorithm=hash_algorithm, group=group, url=url, - crossorigin=crossorigin, referrerpolicy=referrerpolicy, - type_=type_, **kwargs + name=name, + depends=depends, + directory=directory, + path=path, + resource=resource, + compressed=compressed, + include=include, + unique=unique, + unique_prefix=unique_prefix, + hash_algorithm=hash_algorithm, + group=group, + url=url, + crossorigin=crossorigin, + referrerpolicy=referrerpolicy, + type_=type_, + **kwargs, ) self.hreflang = hreflang self.media = media @@ -301,7 +358,7 @@ def render(self, base_url): 'media': self.media, 'rel': self.rel, 'sizes': self.sizes, - 'title': self.title + 'title': self.title, } attrs.update(self.additional_attrs) return self._render_tag('link', False, **attrs) @@ -311,11 +368,28 @@ class LinkResource(LinkMixin): """A Link Resource.""" def __init__( - self, name='', depends=None, directory=None, path=None, - resource=None, compressed=None, include=True, unique=False, - unique_prefix='++webresource++', hash_algorithm='sha384', group=None, - url=None, crossorigin=None, referrerpolicy=None, type_=None, - hreflang=None, media=None, rel=None, sizes=None, title=None, **kwargs + self, + name='', + depends=None, + directory=None, + path=None, + resource=None, + compressed=None, + include=True, + unique=False, + unique_prefix='++webresource++', + hash_algorithm='sha384', + group=None, + url=None, + crossorigin=None, + referrerpolicy=None, + type_=None, + hreflang=None, + media=None, + rel=None, + sizes=None, + title=None, + **kwargs, ): """Create link resource. @@ -353,13 +427,27 @@ def __init__( :raise ResourceError: No resource and no url given. """ super(LinkResource, self).__init__( - name=name, depends=depends, directory=directory, path=path, - resource=resource, compressed=compressed, include=include, - unique=unique, unique_prefix=unique_prefix, - hash_algorithm=hash_algorithm, group=group, url=url, - crossorigin=crossorigin, referrerpolicy=referrerpolicy, - type_=type_, hreflang=hreflang, media=media, rel=rel, sizes=sizes, - title=title, **kwargs + name=name, + depends=depends, + directory=directory, + path=path, + resource=resource, + compressed=compressed, + include=include, + unique=unique, + unique_prefix=unique_prefix, + hash_algorithm=hash_algorithm, + group=group, + url=url, + crossorigin=crossorigin, + referrerpolicy=referrerpolicy, + type_=type_, + hreflang=hreflang, + media=media, + rel=rel, + sizes=sizes, + title=title, + **kwargs, ) @@ -367,11 +455,26 @@ class StyleResource(LinkMixin): """A Stylesheet Resource.""" def __init__( - self, name='', depends=None, directory=None, path=None, - resource=None, compressed=None, include=True, unique=False, - unique_prefix='++webresource++', hash_algorithm='sha384', group=None, - url=None, crossorigin=None, referrerpolicy=None, hreflang=None, - media='all', rel='stylesheet', title=None, **kwargs + self, + name='', + depends=None, + directory=None, + path=None, + resource=None, + compressed=None, + include=True, + unique=False, + unique_prefix='++webresource++', + hash_algorithm='sha384', + group=None, + url=None, + crossorigin=None, + referrerpolicy=None, + hreflang=None, + media='all', + rel='stylesheet', + title=None, + **kwargs, ): """Create link resource. @@ -406,11 +509,25 @@ def __init__( :raise ResourceError: No resource and no url given. """ super(StyleResource, self).__init__( - name=name, depends=depends, directory=directory, path=path, - resource=resource, compressed=compressed, include=include, - unique=unique, unique_prefix=unique_prefix, - hash_algorithm=hash_algorithm, group=group, url=url, - crossorigin=crossorigin, referrerpolicy=referrerpolicy, - type_='text/css', hreflang=hreflang, media=media, rel=rel, - sizes=None, title=title, **kwargs + name=name, + depends=depends, + directory=directory, + path=path, + resource=resource, + compressed=compressed, + include=include, + unique=unique, + unique_prefix=unique_prefix, + hash_algorithm=hash_algorithm, + group=group, + url=url, + crossorigin=crossorigin, + referrerpolicy=referrerpolicy, + type_='text/css', + hreflang=hreflang, + media=media, + rel=rel, + sizes=None, + title=title, + **kwargs, ) From 5f301602c1cded93e4f1b4c4d283073ea736a4a6 Mon Sep 17 00:00:00 2001 From: Robert Niederreiter Date: Tue, 4 Nov 2025 10:39:21 +0100 Subject: [PATCH 15/17] Add typehints --- Makefile | 2 +- pyproject.toml | 8 + tests/test_resolver.py | 6 +- tests/test_resources.py | 4 + webresource/__init__.py | 43 ++++-- webresource/base.py | 47 ++++-- webresource/config.py | 12 +- webresource/exceptions.py | 16 +- webresource/groups.py | 36 +++-- webresource/renderer.py | 14 +- webresource/resolver.py | 19 ++- webresource/resources.py | 299 +++++++++++++++++++++----------------- 12 files changed, 321 insertions(+), 185 deletions(-) diff --git a/Makefile b/Makefile index 533abc8..80de7c6 100644 --- a/Makefile +++ b/Makefile @@ -178,7 +178,7 @@ COVERAGE_COMMAND?=\ # Source folder for code analysis. # Default: src -MYPY_SRC?=webresource tests +MYPY_SRC?=webresource # Mypy Python requirements to be installed (via pip). # Default: types-setuptools diff --git a/pyproject.toml b/pyproject.toml index 56aca7d..de4b505 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -58,5 +58,13 @@ quote-style = "single" py_version = 310 profile = "plone" +[tool.mypy] +python_version = "3.10" +strict = true +warn_return_any = true +warn_unused_configs = true +disallow_untyped_defs = true +disallow_any_generics = false + [tool.zest-releaser] create-wheel = true diff --git a/tests/test_resolver.py b/tests/test_resolver.py index 7e55d6b..807083e 100644 --- a/tests/test_resolver.py +++ b/tests/test_resolver.py @@ -99,9 +99,9 @@ def test_ResourceResolver_resolve(self): resolver = wr.ResourceResolver([res1, res2, res3]) self.assertRaises(wr.ResourceCircularDependencyError, resolver.resolve) - res1 = Resource(name='res1', resource='res1.ext', depends=['res2', 'res3']) - res2 = Resource(name='res2', resource='res2.ext', depends=['res1', 'res3']) - res3 = Resource(name='res3', resource='res3.ext', depends=['res1', 'res4']) + res1 = Resource(name='res1', resource='res1.ext', depends=('res2', 'res3')) + res2 = Resource(name='res2', resource='res2.ext', depends=('res1', 'res3')) + res3 = Resource(name='res3', resource='res3.ext', depends=('res1', 'res4')) resolver = wr.ResourceResolver([res1, res2, res3]) self.assertRaises(wr.ResourceMissingDependencyError, resolver.resolve) diff --git a/tests/test_resources.py b/tests/test_resources.py index 328c3f9..6ce2bbb 100644 --- a/tests/test_resources.py +++ b/tests/test_resources.py @@ -35,6 +35,10 @@ def test_Resource(self, tempdir): self.assertEqual(resource.type_, None) self.assertEqual(repr(resource), 'Resource name="res", depends="None"') + resource = Resource(name='res', url='http://tld.net/resource') + with self.assertRaises(wr.ResourceError): + resource.file_name + resource = Resource(name='res', resource='res.ext') self.assertEqual(resource.file_name, 'res.ext') with self.assertRaises(wr.ResourceError): diff --git a/webresource/__init__.py b/webresource/__init__.py index 49bc380..d97185a 100644 --- a/webresource/__init__.py +++ b/webresource/__init__.py @@ -1,13 +1,30 @@ -from webresource.config import config # noqa -from webresource.exceptions import ResourceCircularDependencyError # noqa -from webresource.exceptions import ResourceConflictError # noqa -from webresource.exceptions import ResourceError # noqa -from webresource.exceptions import ResourceMissingDependencyError # noqa -from webresource.groups import ResourceGroup # noqa -from webresource.renderer import GracefulResourceRenderer # noqa -from webresource.renderer import ResourceRenderer # noqa -from webresource.resolver import ResourceResolver # noqa -from webresource.resources import LinkResource # noqa -from webresource.resources import Resource # noqa -from webresource.resources import ScriptResource # noqa -from webresource.resources import StyleResource # noqa +from webresource.config import config +from webresource.exceptions import ResourceCircularDependencyError +from webresource.exceptions import ResourceConflictError +from webresource.exceptions import ResourceError +from webresource.exceptions import ResourceMissingDependencyError +from webresource.groups import ResourceGroup +from webresource.renderer import GracefulResourceRenderer +from webresource.renderer import ResourceRenderer +from webresource.resolver import ResourceResolver +from webresource.resources import LinkResource +from webresource.resources import Resource +from webresource.resources import ScriptResource +from webresource.resources import StyleResource + + +__all__ = [ + 'config', + 'ResourceCircularDependencyError', + 'ResourceConflictError', + 'ResourceError', + 'ResourceMissingDependencyError', + 'ResourceGroup', + 'GracefulResourceRenderer', + 'ResourceRenderer', + 'ResourceResolver', + 'LinkResource', + 'Resource', + 'ScriptResource', + 'StyleResource', +] diff --git a/webresource/base.py b/webresource/base.py index df074db..a81da82 100644 --- a/webresource/base.py +++ b/webresource/base.py @@ -1,63 +1,86 @@ +from __future__ import annotations + +from collections.abc import Callable +from typing import TYPE_CHECKING from webresource.exceptions import ResourceError import copy import os -class ResourceMixin(object): +if TYPE_CHECKING: + from webresource.groups import ResourceGroup + + +class ResourceMixin: """Mixin for ``Resource`` and ``ResourceGroup``.""" - def __init__(self, name='', directory=None, path=None, include=True, group=None): + name: str + parent: ResourceGroup | None + _path: str | None + _directory: str | None + _include: bool | Callable[[], bool] + + def __init__( + self, + name: str = '', + directory: str | None = None, + path: str | None = None, + include: bool | Callable[[], bool] = True, + group: ResourceGroup | None = None, + ) -> None: self.name = name self.directory = directory self.path = path self.include = include self.parent = None if group: - group.add(self) + group.add(self) # type: ignore[arg-type] @property - def path(self): + def path(self) -> str | None: if self._path is not None: return self._path if self.parent is not None: return self.parent.path + return None @path.setter - def path(self, path): + def path(self, path: str | None) -> None: self._path = path @property - def directory(self): + def directory(self) -> str | None: if self._directory is not None: return self._directory if self.parent is not None: return self.parent.directory + return None @directory.setter - def directory(self, directory): + def directory(self, directory: str | None) -> None: if directory is None: self._directory = None return self._directory = os.path.abspath(directory) @property - def include(self): + def include(self) -> bool: if callable(self._include): return self._include() return self._include @include.setter - def include(self, include): + def include(self, include: bool | Callable[[], bool]) -> None: self._include = include - def remove(self): + def remove(self) -> None: """Remove resource or resource group from parent group.""" if not self.parent: raise ResourceError('Object is no member of a resource group') - self.parent.members.remove(self) + self.parent.members.remove(self) # type: ignore[arg-type] self.parent = None - def copy(self): + def copy(self) -> ResourceMixin: """Return a deep copy of this object.""" return copy.deepcopy(self) diff --git a/webresource/config.py b/webresource/config.py index 35c7729..101cd12 100644 --- a/webresource/config.py +++ b/webresource/config.py @@ -2,15 +2,17 @@ import uuid -logger = logging.getLogger(__name__) -namespace_uuid = uuid.UUID('f3341b2e-f97e-40d2-ad2f-10a08a778877') +logger: logging.Logger = logging.getLogger(__name__) +namespace_uuid: uuid.UUID = uuid.UUID('f3341b2e-f97e-40d2-ad2f-10a08a778877') -class ResourceConfig(object): +class ResourceConfig: """Config singleton for web resources.""" - def __init__(self): + development: bool + + def __init__(self) -> None: self.development = False -config = ResourceConfig() +config: ResourceConfig = ResourceConfig() diff --git a/webresource/exceptions.py b/webresource/exceptions.py index cc083bc..59785ab 100644 --- a/webresource/exceptions.py +++ b/webresource/exceptions.py @@ -1,3 +1,13 @@ +from __future__ import annotations + +from collections import Counter +from typing import TYPE_CHECKING + + +if TYPE_CHECKING: + from webresource.resources import Resource + + class ResourceError(ValueError): """Resource related exception.""" @@ -5,7 +15,7 @@ class ResourceError(ValueError): class ResourceConflictError(ResourceError): """Multiple resources declared with the same name.""" - def __init__(self, counter): + def __init__(self, counter: Counter[str]) -> None: conflicting = list() for name, count in counter.items(): if count > 1: @@ -17,7 +27,7 @@ def __init__(self, counter): class ResourceCircularDependencyError(ResourceError): """Resources define circular dependencies.""" - def __init__(self, resources): + def __init__(self, resources: list[Resource]) -> None: msg = 'Resources define circular dependencies: {}'.format(resources) super(ResourceCircularDependencyError, self).__init__(msg) @@ -25,6 +35,6 @@ def __init__(self, resources): class ResourceMissingDependencyError(ResourceError): """Resource depends on a missing resource.""" - def __init__(self, resource): + def __init__(self, resource: Resource) -> None: msg = 'Resource defines missing dependency: {}'.format(resource) super(ResourceMissingDependencyError, self).__init__(msg) diff --git a/webresource/groups.py b/webresource/groups.py index 7a7c25e..18b184d 100644 --- a/webresource/groups.py +++ b/webresource/groups.py @@ -1,3 +1,7 @@ +from __future__ import annotations + +from collections.abc import Callable +from typing import TypeVar from webresource.base import ResourceMixin from webresource.exceptions import ResourceError from webresource.resources import LinkResource @@ -6,10 +10,22 @@ from webresource.resources import StyleResource +T = TypeVar('T', bound=Resource) + + class ResourceGroup(ResourceMixin): """A resource group.""" - def __init__(self, name='', directory=None, path=None, include=True, group=None): + _members: list[Resource | ResourceGroup] + + def __init__( + self, + name: str = '', + directory: str | None = None, + path: str | None = None, + include: bool | Callable[[], bool] = True, + group: ResourceGroup | None = None, + ) -> None: """Create resource group. :param name: The resource group name. @@ -26,7 +42,7 @@ def __init__(self, name='', directory=None, path=None, include=True, group=None) self._members = [] @property - def members(self): + def members(self) -> list[Resource | ResourceGroup]: """List of group members. Group members are either instances of ``Resource`` or ``ResourceGroup``. @@ -34,7 +50,7 @@ def members(self): return self._members @property - def scripts(self): + def scripts(self) -> list[ScriptResource]: """List of all contained ``ScriptResource`` instances. Resources from subsequent resource groups are included. @@ -42,7 +58,7 @@ def scripts(self): return self._filtered_resources(ScriptResource) @property - def styles(self): + def styles(self) -> list[StyleResource]: """List of all contained ``StyleResource`` instances. Resources from subsequent resource groups are included. @@ -50,14 +66,14 @@ def styles(self): return self._filtered_resources(StyleResource) @property - def links(self): + def links(self) -> list[LinkResource]: """List of all contained ``LinkResource`` instances. Resources from subsequent resource groups are included. """ return self._filtered_resources(LinkResource) - def add(self, member): + def add(self, member: Resource | ResourceGroup) -> None: """Add member to resource group. :param member: Either ``ResourceGroup`` or ``Resource`` instance. @@ -71,10 +87,12 @@ def add(self, member): member.parent = self self._members.append(member) - def _filtered_resources(self, type_, members=None): + def _filtered_resources( + self, type_: type[T], members: list[Resource | ResourceGroup] | None = None + ) -> list[T]: if members is None: members = self.members - resources = [] + resources: list[T] = [] for member in members: if isinstance(member, ResourceGroup): resources += self._filtered_resources(type_, members=member.members) @@ -82,5 +100,5 @@ def _filtered_resources(self, type_, members=None): resources.append(member) return resources - def __repr__(self): + def __repr__(self) -> str: return '{} name="{}"'.format(self.__class__.__name__, self.name) diff --git a/webresource/renderer.py b/webresource/renderer.py index 573e7da..f038c10 100644 --- a/webresource/renderer.py +++ b/webresource/renderer.py @@ -1,11 +1,17 @@ from webresource.config import logger from webresource.exceptions import ResourceError +from webresource.resolver import ResourceResolver -class ResourceRenderer(object): +class ResourceRenderer: """Resource renderer.""" - def __init__(self, resolver, base_url='https://tld.org'): + resolver: ResourceResolver + base_url: str + + def __init__( + self, resolver: ResourceResolver, base_url: str = 'https://tld.org' + ) -> None: """Create resource renderer. :param resolver: ``ResourceResolver`` instance. @@ -14,7 +20,7 @@ def __init__(self, resolver, base_url='https://tld.org'): self.resolver = resolver self.base_url = base_url - def render(self): + def render(self) -> str: """Render resources.""" return '\n'.join([res.render(self.base_url) for res in self.resolver.resolve()]) @@ -22,7 +28,7 @@ def render(self): class GracefulResourceRenderer(ResourceRenderer): """Resource renderer, which does not fail but logs an exception.""" - def render(self): + def render(self) -> str: lines = [] for resource in self.resolver.resolve(): try: diff --git a/webresource/resolver.py b/webresource/resolver.py index d0a9390..43605fb 100644 --- a/webresource/resolver.py +++ b/webresource/resolver.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from collections import Counter from webresource.exceptions import ResourceCircularDependencyError from webresource.exceptions import ResourceConflictError @@ -7,10 +9,14 @@ from webresource.resources import Resource -class ResourceResolver(object): +class ResourceResolver: """Resource resolver.""" - def __init__(self, members): + members: list[Resource | ResourceGroup] + + def __init__( + self, members: Resource | ResourceGroup | list[Resource | ResourceGroup] + ) -> None: """Create resource resolver. :param members: Either single or list of ``Resource`` or @@ -27,10 +33,12 @@ def __init__(self, members): ) self.members = members - def _flat_resources(self, members=None): + def _flat_resources( + self, members: list[Resource | ResourceGroup] | None = None + ) -> list[Resource]: if members is None: members = self.members - resources = [] + resources: list[Resource] = [] for member in members: if not member.include: continue @@ -40,7 +48,7 @@ def _flat_resources(self, members=None): resources.append(member) return resources - def resolve(self): + def resolve(self) -> list[Resource]: """Return all resources from members as flat list ordered by dependencies. @@ -68,6 +76,7 @@ def resolve(self): while count > 0: count -= 1 for resource in resources[:]: + assert resource.depends is not None # guaranteed by above loop hook_idx = 0 not_yet = False for dependency_name in resource.depends: diff --git a/webresource/resources.py b/webresource/resources.py index 7f45040..1f5595e 100644 --- a/webresource/resources.py +++ b/webresource/resources.py @@ -1,3 +1,8 @@ +from __future__ import annotations + +from collections.abc import Callable +from typing import Any +from typing import TYPE_CHECKING from webresource.base import ResourceMixin from webresource.config import config from webresource.config import namespace_uuid @@ -9,32 +14,49 @@ import uuid +if TYPE_CHECKING: + from webresource.groups import ResourceGroup + + class Resource(ResourceMixin): """A web resource.""" - _hash_algorithms = dict( + _hash_algorithms: dict[str, Callable[[bytes], Any]] = dict( sha256=hashlib.sha256, sha384=hashlib.sha384, sha512=hashlib.sha512 ) + depends: list[str] | None + resource: str | None + compressed: str | None + unique: bool + unique_prefix: str + hash_algorithm: str + _file_hash: str | None + url: str | None + crossorigin: str | None + referrerpolicy: str | None + type_: str | None + additional_attrs: dict[str, Any] + def __init__( self, - name='', - depends=None, - directory=None, - path=None, - resource=None, - compressed=None, - include=True, - unique=False, - unique_prefix='++webresource++', - hash_algorithm='sha384', - group=None, - url=None, - crossorigin=None, - referrerpolicy=None, - type_=None, - **kwargs, - ): + name: str = '', + depends: str | list[str] | tuple[str, ...] | None = None, + directory: str | None = None, + path: str | None = None, + resource: str | None = None, + compressed: str | None = None, + include: bool | Callable[[], bool] = True, + unique: bool = False, + unique_prefix: str = '++webresource++', + hash_algorithm: str = 'sha384', + group: ResourceGroup | None = None, + url: str | None = None, + crossorigin: str | None = None, + referrerpolicy: str | None = None, + type_: str | None = None, + **kwargs: Any, + ) -> None: """Base class for resources. :param name: The resource unique name. @@ -66,11 +88,14 @@ def __init__( super(Resource, self).__init__( name=name, directory=directory, path=path, include=include, group=group ) - self.depends = ( - (depends if isinstance(depends, (list, tuple)) else [depends]) - if depends - else None - ) + if depends is None: + self.depends = None + elif isinstance(depends, list): + self.depends = depends + elif isinstance(depends, tuple): + self.depends = list(depends) + else: + self.depends = [depends] self.resource = resource self.compressed = compressed self.unique = unique @@ -84,14 +109,16 @@ def __init__( self.additional_attrs = kwargs @property - def file_name(self): + def file_name(self) -> str: """Resource file name depending on operation mode.""" if not config.development and self.compressed: return self.compressed + if self.resource is None: + raise ResourceError('No resource file name available') return self.resource @property - def file_path(self): + def file_path(self) -> str: """Absolute resource file path depending on operation mode.""" directory = self.directory if not directory: @@ -99,33 +126,33 @@ def file_path(self): return os.path.join(directory, self.file_name) @property - def file_data(self): + def file_data(self) -> bytes: """File content of resource depending on operation mode.""" with open(self.file_path, 'rb') as f: return f.read() @property - def file_hash(self): + def file_hash(self) -> str: """Hash of resource file content.""" if not config.development and self._file_hash is not None: return self._file_hash hash_func = self._hash_algorithms[self.hash_algorithm] hash_ = base64.b64encode(hash_func(self.file_data).digest()) - hash_ = hash_.decode() - self.file_hash = hash_ - return hash_ + hash_str = hash_.decode() + self.file_hash = hash_str + return hash_str @file_hash.setter - def file_hash(self, hash_): + def file_hash(self, hash_: str | None) -> None: self._file_hash = hash_ @property - def unique_key(self): + def unique_key(self) -> str: return '{}{}'.format( self.unique_prefix, str(uuid.uuid5(namespace_uuid, self.file_hash)) ) - def resource_url(self, base_url): + def resource_url(self, base_url: str) -> str: """Create URL for resource. :param base_url: The base URL to create the URL resource. @@ -141,7 +168,7 @@ def resource_url(self, base_url): parts.append(self.file_name) return '/'.join(parts) - def render(self, base_url): + def render(self, base_url: str) -> str: """Renders the resource HTML tag. must be implemented on subclass. :param base_url: The base URL to create the URL resource. @@ -149,18 +176,18 @@ def render(self, base_url): """ raise NotImplementedError('Abstract resource not implements ``render``') - def _render_tag(self, tag, closing_tag, **attrs): + def _render_tag(self, tag: str, closing_tag: bool, **attrs: str | None) -> str: attrs_ = list() for name, val in attrs.items(): if val is None: continue attrs_.append('{0}="{1}"'.format(name, val)) - attrs_ = ' {0}'.format(' '.join(sorted(attrs_))) + attrs_str = ' {0}'.format(' '.join(sorted(attrs_))) if not closing_tag: - return '<{tag}{attrs} />'.format(tag=tag, attrs=attrs_) - return '<{tag}{attrs}>'.format(tag=tag, attrs=attrs_) + return '<{tag}{attrs} />'.format(tag=tag, attrs=attrs_str) + return '<{tag}{attrs}>'.format(tag=tag, attrs=attrs_str) - def __repr__(self): + def __repr__(self) -> str: return ('{} name="{}", depends="{}"').format( self.__class__.__name__, self.name, self.depends ) @@ -169,29 +196,35 @@ def __repr__(self): class ScriptResource(Resource): """A Javascript resource.""" + async_: str | None + defer: str | None + _integrity: bool | str | None + _integrity_hash: str | None + nomodule: str | None + def __init__( self, - name='', - depends=None, - directory=None, - path=None, - resource=None, - compressed=None, - include=True, - unique=False, - unique_prefix='++webresource++', - hash_algorithm='sha384', - group=None, - url=None, - crossorigin=None, - referrerpolicy=None, - type_=None, - async_=None, - defer=None, - integrity=None, - nomodule=None, - **kwargs, - ): + name: str = '', + depends: str | list[str] | tuple[str, ...] | None = None, + directory: str | None = None, + path: str | None = None, + resource: str | None = None, + compressed: str | None = None, + include: bool | Callable[[], bool] = True, + unique: bool = False, + unique_prefix: str = '++webresource++', + hash_algorithm: str = 'sha384', + group: ResourceGroup | None = None, + url: str | None = None, + crossorigin: str | None = None, + referrerpolicy: str | None = None, + type_: str | None = None, + async_: str | None = None, + defer: str | None = None, + integrity: bool | str | None = None, + nomodule: str | None = None, + **kwargs: Any, + ) -> None: """Create script resource. :param name: The resource unique name. @@ -254,9 +287,9 @@ def __init__( self.nomodule = nomodule @property - def integrity(self): + def integrity(self) -> str | None: if not self._integrity: - return self._integrity + return None if not config.development and self._integrity_hash is not None: return self._integrity_hash if self._integrity is True: @@ -264,17 +297,17 @@ def integrity(self): return self._integrity_hash @integrity.setter - def integrity(self, integrity): + def integrity(self, integrity: bool | str | None) -> None: if integrity is True: if self.url is not None: msg = 'Cannot calculate integrity hash from external resource' raise ResourceError(msg) self._integrity_hash = None else: - self._integrity_hash = integrity + self._integrity_hash = integrity # type: ignore[assignment] self._integrity = integrity - def render(self, base_url): + def render(self, base_url: str) -> str: """Renders the resource HTML ``script`` tag. :param base_url: The base URL to create the URL resource. @@ -296,30 +329,36 @@ def render(self, base_url): class LinkMixin(Resource): """Mixin class for link resources.""" + hreflang: str | None + media: str | None + rel: str | None + sizes: str | None + title: str | None + def __init__( self, - name='', - depends=None, - directory=None, - path=None, - resource=None, - compressed=None, - include=True, - unique=False, - unique_prefix='++webresource++', - hash_algorithm='sha384', - group=None, - url=None, - crossorigin=None, - referrerpolicy=None, - type_=None, - hreflang=None, - media=None, - rel=None, - sizes=None, - title=None, - **kwargs, - ): + name: str = '', + depends: str | list[str] | tuple[str, ...] | None = None, + directory: str | None = None, + path: str | None = None, + resource: str | None = None, + compressed: str | None = None, + include: bool | Callable[[], bool] = True, + unique: bool = False, + unique_prefix: str = '++webresource++', + hash_algorithm: str = 'sha384', + group: ResourceGroup | None = None, + url: str | None = None, + crossorigin: str | None = None, + referrerpolicy: str | None = None, + type_: str | None = None, + hreflang: str | None = None, + media: str | None = None, + rel: str | None = None, + sizes: str | None = None, + title: str | None = None, + **kwargs: Any, + ) -> None: super(LinkMixin, self).__init__( name=name, depends=depends, @@ -344,7 +383,7 @@ def __init__( self.sizes = sizes self.title = title - def render(self, base_url): + def render(self, base_url: str) -> str: """Renders the resource HTML ``link`` tag. :param base_url: The base URL to create the URL resource. @@ -369,28 +408,28 @@ class LinkResource(LinkMixin): def __init__( self, - name='', - depends=None, - directory=None, - path=None, - resource=None, - compressed=None, - include=True, - unique=False, - unique_prefix='++webresource++', - hash_algorithm='sha384', - group=None, - url=None, - crossorigin=None, - referrerpolicy=None, - type_=None, - hreflang=None, - media=None, - rel=None, - sizes=None, - title=None, - **kwargs, - ): + name: str = '', + depends: str | list[str] | tuple[str, ...] | None = None, + directory: str | None = None, + path: str | None = None, + resource: str | None = None, + compressed: str | None = None, + include: bool | Callable[[], bool] = True, + unique: bool = False, + unique_prefix: str = '++webresource++', + hash_algorithm: str = 'sha384', + group: ResourceGroup | None = None, + url: str | None = None, + crossorigin: str | None = None, + referrerpolicy: str | None = None, + type_: str | None = None, + hreflang: str | None = None, + media: str | None = None, + rel: str | None = None, + sizes: str | None = None, + title: str | None = None, + **kwargs: Any, + ) -> None: """Create link resource. :param name: The resource unique name. @@ -456,26 +495,26 @@ class StyleResource(LinkMixin): def __init__( self, - name='', - depends=None, - directory=None, - path=None, - resource=None, - compressed=None, - include=True, - unique=False, - unique_prefix='++webresource++', - hash_algorithm='sha384', - group=None, - url=None, - crossorigin=None, - referrerpolicy=None, - hreflang=None, - media='all', - rel='stylesheet', - title=None, - **kwargs, - ): + name: str = '', + depends: str | list[str] | tuple[str, ...] | None = None, + directory: str | None = None, + path: str | None = None, + resource: str | None = None, + compressed: str | None = None, + include: bool | Callable[[], bool] = True, + unique: bool = False, + unique_prefix: str = '++webresource++', + hash_algorithm: str = 'sha384', + group: ResourceGroup | None = None, + url: str | None = None, + crossorigin: str | None = None, + referrerpolicy: str | None = None, + hreflang: str | None = None, + media: str = 'all', + rel: str = 'stylesheet', + title: str | None = None, + **kwargs: Any, + ) -> None: """Create link resource. :param name: The resource unique name. From 9cbd7d0e51b657b237c8556c530c307da41f29d3 Mon Sep 17 00:00:00 2001 From: Robert Niederreiter Date: Tue, 4 Nov 2025 10:55:46 +0100 Subject: [PATCH 16/17] Bump version, Update changelog --- CHANGES.rst | 28 ++++++++++++++++++++++++++-- pyproject.toml | 2 +- 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 70bc2aa..a75f143 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,10 +1,34 @@ Changelog ========= -1.3.0 (unreleased) +2.0.0 (unreleased) ------------------ -- Official support from Python 3.9 to 3.14. +- Package Structure Refactoring + - Split monolithic _api.py (736 lines) into 7 semantic modules: + - config.py, exceptions.py, base.py, resources.py, groups.py, resolver.py, renderer.py + - Updated __init__.py with explicit __all__ exports + [rnix] + +- Test Suite Reorganization + - Moved tests from webresource/tests.py to tests/ directory at package root + - Split into 8 focused test files matching the module structure + - Created tests/test_utils.py for shared test utilities + - Updated Makefile to use pytest tests + [rnix] + +- Build System Modernization + - Migrated from setup.py to pyproject.toml with hatchling backend + - Python 3.10-3.14 support + [rnix] + +- Python 2 Cleanup + - Removed all Python 2 compatibility code (is_py3, FileNotFoundError checks) + [rnix] + +- Type Hints + - Added comprehensive Python 3.10+ type hints to all production code + - Configured mypy in pyproject.toml with strict settings [rnix] - Do not wrap resource ``__repr__`` output in ``<>`` to render tracebacks diff --git a/pyproject.toml b/pyproject.toml index de4b505..828a92f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "webresource" -version = "1.3.0.dev0" +version = "2.0.0.dev0" description = "A resource registry for web applications." readme = {file = "README.rst", content-type = "text/x-rst"} keywords = ["web", "resources", "dependencies", "javascript", "CSS"] From 9fd5bc70c16719212e55a91a4e9330f1509c575d Mon Sep 17 00:00:00 2001 From: Robert Niederreiter Date: Tue, 4 Nov 2025 10:59:48 +0100 Subject: [PATCH 17/17] Changelog formatting --- CHANGES.rst | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index a75f143..98fdf00 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -5,30 +5,44 @@ Changelog ------------------ - Package Structure Refactoring + - Split monolithic _api.py (736 lines) into 7 semantic modules: - - config.py, exceptions.py, base.py, resources.py, groups.py, resolver.py, renderer.py + + - config.py + - exceptions.py + - base.py + - resources.py + - groups.py + - resolver.py + - renderer.py + - Updated __init__.py with explicit __all__ exports [rnix] - Test Suite Reorganization + - Moved tests from webresource/tests.py to tests/ directory at package root - Split into 8 focused test files matching the module structure - Created tests/test_utils.py for shared test utilities - Updated Makefile to use pytest tests + [rnix] - Build System Modernization + - Migrated from setup.py to pyproject.toml with hatchling backend - Python 3.10-3.14 support - [rnix] -- Python 2 Cleanup - - Removed all Python 2 compatibility code (is_py3, FileNotFoundError checks) [rnix] +- Python 2 Cleanup. Removed all Python 2 compatibility code (is_py3, + FileNotFoundError checks) [rnix] + - Type Hints + - Added comprehensive Python 3.10+ type hints to all production code - Configured mypy in pyproject.toml with strict settings + [rnix] - Do not wrap resource ``__repr__`` output in ``<>`` to render tracebacks