diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2ee1cf9..0a0ee63 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -16,30 +16,25 @@ 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 + - 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/CHANGES.rst b/CHANGES.rst index 919aac4..98fdf00 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,8 +1,49 @@ Changelog ========= -1.3 (unreleased) ----------------- +2.0.0 (unreleased) +------------------ + +- 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 properly in browser. 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 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/Makefile b/Makefile index fa3e0a7..80de7c6 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) @@ -32,18 +35,50 @@ 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?= + +# 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 -# 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.10 +PYTHON_MIN_VERSION?=3.10 + +# Install packages using the given package installer method. +# 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 + +# 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 `PYTHON_BIN` found in `PATH` is used. +# interpreter according to `PRIMARY_PYTHON` found in `PATH` is used. # Default: true VENV_ENABLED?=true @@ -58,7 +93,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. @@ -69,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. @@ -79,6 +126,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?= @@ -89,12 +140,19 @@ 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 # :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). @@ -111,10 +169,20 @@ 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 + +## qa.mypy + +# Source folder for code analysis. +# Default: src +MYPY_SRC?=webresource + +# Mypy Python requirements to be installed (via pip). +# Default: types-setuptools +MYPY_REQUIREMENTS?= ############################################################################## # END SETTINGS - DO NOT EDIT BELOW THIS LINE @@ -128,6 +196,11 @@ CHECK_TARGETS?= TYPECHECK_TARGETS?= 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: @@ -144,7 +217,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 +225,97 @@ $(SENTINEL): # mxenv ############################################################################## -# Check if given Python is installed -ifeq (,$(shell which $(PYTHON_BIN))) -$(error "PYTHON=$(PYTHON_BIN) not found in $(PATH)") +OS?= + +# 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 +export PATH:=$(VENV_EXECUTABLE_FOLDER):$(PATH) +MXENV_PYTHON=python +else +MXENV_PYTHON=$(PRIMARY_PYTHON) endif -# 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 package installer with non-interactive flags +ifeq ("$(PYTHON_PACKAGE_INSTALLER)","uv") +PYTHON_PACKAGE_COMMAND=uv pip --no-progress +else +PYTHON_PACKAGE_COMMAND=$(MXENV_PYTHON) -m pip 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") +# 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 the executable path -ifeq ("$(VENV_ENABLED)", "true") -MXENV_PATH=$(VENV_FOLDER)/bin/ +# 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 -MXENV_PATH= +UV_OUTDATED:=false endif MXENV_TARGET:=$(SENTINEL_FOLDER)/mxenv.sentinel $(MXENV_TARGET): $(SENTINEL) + # 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 "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 || : + # 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") - @echo "Setup Python Virtual Environment under '$(VENV_FOLDER)'" - @$(PYTHON_BIN) -m venv $(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) + @$(MXENV_PYTHON) -m ensurepip -U +endif +endif +else + @echo "Using system Python interpreter" endif + + # Install uv locally if needed +ifeq ("$(USE_LOCAL_UV)","true") + @echo "Install uv in virtual environment" + @$(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) + + # 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) @touch $(MXENV_TARGET) .PHONY: mxenv @@ -202,14 +332,93 @@ 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 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 ############################################################################## @@ -217,13 +426,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 @@ -236,12 +445,19 @@ 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) .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 +476,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 @@ -277,15 +491,15 @@ 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) @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 +528,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 +555,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 +571,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 +586,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 +600,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 +615,51 @@ 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 +############################################################################## +# 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 +############################################################################## + -include $(INCLUDE_MAKEFILE) ############################################################################## diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..828a92f --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,70 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "webresource" +version = "2.0.0.dev0" +description = "A resource registry for web applications." +readme = {file = "README.rst", content-type = "text/x-rst"} +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 :: 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", +] + +[project.optional-dependencies] +docs = [ + "Sphinx", + "sphinx_conestack_theme", +] +test = [ + "coverage", + "pytest", +] + +[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.hatch.build.targets.sdist] +exclude = ["/docs"] +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.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/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() diff --git a/tests/test_base.py b/tests/test_base.py new file mode 100644 index 0000000..249598c --- /dev/null +++ b/tests/test_base.py @@ -0,0 +1,49 @@ +from tests.test_utils import np +from webresource.base import ResourceMixin + +import os +import unittest + + +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..b0b0503 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,18 @@ +from webresource.config import ResourceConfig + +import unittest +import webresource as wr + + +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..2dba82b --- /dev/null +++ b/tests/test_exceptions.py @@ -0,0 +1,38 @@ +from collections import Counter +from webresource.resources import Resource + +import unittest +import webresource as wr + + +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..581cea7 --- /dev/null +++ b/tests/test_groups.py @@ -0,0 +1,74 @@ +from webresource.base import ResourceMixin + +import unittest +import webresource as wr + + +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..3ad70b4 --- /dev/null +++ b/tests/test_renderer.py @@ -0,0 +1,158 @@ +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..807083e --- /dev/null +++ b/tests/test_resolver.py @@ -0,0 +1,111 @@ +from webresource.resources import Resource + +import unittest +import webresource as wr + + +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..6ce2bbb --- /dev/null +++ b/tests/test_resources.py @@ -0,0 +1,263 @@ +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 + + +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', 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): + 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, '') + + rendered = resource._render_tag('tag', True, foo='bar', baz=None) + self.assertEqual(rendered, '') + + 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('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/lzkncnWgKEV0zOX9OXa9Uy+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..4f8ea16 --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,22 @@ +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/webresource/__init__.py b/webresource/__init__.py index a30f86b..d97185a 100644 --- a/webresource/__init__.py +++ b/webresource/__init__.py @@ -1,15 +1,30 @@ -from webresource._api import ( # noqa - config, - LinkResource, - Resource, - ResourceCircularDependencyError, - ResourceConflictError, - ResourceError, - ResourceGroup, - ResourceMissingDependencyError, - ResourceRenderer, - GracefulResourceRenderer, - ResourceResolver, - ScriptResource, - StyleResource -) +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/_api.py b/webresource/_api.py deleted file mode 100644 index a4be088..0000000 --- a/webresource/_api.py +++ /dev/null @@ -1,735 +0,0 @@ -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) - - -class Resource(ResourceMixin): - """A web resource.""" - - _hash_algorithms = dict( - 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 - ): - """Base class for resources. - - :param name: The resource unique name. - :param depends: Optional name or list of names of dependency resources. - :param directory: Directory containing the resource files. - :param path: URL path for HTML tag link creation. - :param resource: Resource file. - :param compressed: Optional compressed version of resource file. - :param include: Flag or callback function returning a flag whether to - include the resource. - :param unique: Flag whether to render resource URL including unique key. - Has no effect if ``url`` is given. - :param unique_prefix: Prefix for unique key. Defaults to - '++webresource++'. - :param hash_algorithm: Name of the hashing algorithm. Either 'sha256', - 'sha384' or 'sha512'. Defaults to 'sha384'. - :param group: Optional resource group instance. - :param url: Optional resource URL to use for external resources. - :param crossorigin: Sets the mode of the request to an HTTP CORS Request. - :param referrerpolicy: Specifies which referrer information to send when - fetching the resource. - :param type_: Specifies the media type of the resource. - :param **kwargs: Additional keyword arguments. Gets rendered as - additional attributes on resource tag. - :raise ResourceError: No resource and no url given. - """ - 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 - ) - self.depends = ( - (depends if isinstance(depends, (list, tuple)) else [depends]) - if depends else None - ) - self.resource = resource - self.compressed = compressed - self.unique = unique - self.unique_prefix = unique_prefix - self.hash_algorithm = hash_algorithm - self.file_hash = None - self.url = url - self.crossorigin = crossorigin - self.referrerpolicy = referrerpolicy - self.type_ = type_ - self.additional_attrs = kwargs - - @property - def file_name(self): - """Resource file name depending on operation mode.""" - if not config.development and self.compressed: - return self.compressed - return self.resource - - @property - def file_path(self): - """Absolute resource file path depending on operation mode.""" - directory = self.directory - if not directory: - raise ResourceError('No directory set on resource.') - return os.path.join(directory, self.file_name) - - @property - def file_data(self): - """File content of resource depending on operation mode.""" - with open(self.file_path, 'rb') as f: - return f.read() - - @property - def file_hash(self): - """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() if is_py3 else hash_ - self.file_hash = hash_ - return hash_ - - @file_hash.setter - def file_hash(self, hash_): - self._file_hash = hash_ - - @property - def unique_key(self): - return u'{}{}'.format( - self.unique_prefix, - str(uuid.uuid5(namespace_uuid, self.file_hash)) - ) - - def resource_url(self, base_url): - """Create URL for resource. - - :param base_url: The base URL to create the URL resource. - """ - if self.url is not None: - return self.url - parts = [base_url.strip('/')] - path = self.path - if path: - parts.append(path.strip('/')) - if self.unique: - parts.append(self.unique_key) - parts.append(self.file_name) - return u'/'.join(parts) - - def render(self, base_url): - """Renders the resource HTML tag. must be implemented on subclass. - - :param base_url: The base URL to create the URL resource. - :raise NotImplementedError: Method is abstract. - """ - raise NotImplementedError('Abstract resource not implements ``render``') - - def _render_tag(self, tag, closing_tag, **attrs): - attrs_ = list() - 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_))) - if not closing_tag: - return u'<{tag}{attrs} />'.format(tag=tag, attrs=attrs_) - return u'<{tag}{attrs}>'.format(tag=tag, attrs=attrs_) - - def __repr__(self): - return ( - '{} name="{}", depends="{}"' - ).format( - self.__class__.__name__, - self.name, - self.depends - ) - - -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 - ): - """Create script resource. - - :param name: The resource unique name. - :param depends: Optional name or list of names of dependency resources. - :param directory: Directory containing the resource files. - :param path: URL path for HTML tag link creation. - :param resource: Resource file. - :param compressed: Optional compressed version of resource file. - :param include: Flag or callback function returning a flag whether to - include the resource. - :param unique: Flag whether to render resource URL including unique key. - Has no effect if ``url`` is given. - :param unique_prefix: Prefix for unique key. Defaults to - '++webresource++'. - :param hash_algorithm: Name of the hashing algorithm. Either 'sha256', - 'sha384' or 'sha512'. Defaults to 'sha384'. - :param group: Optional resource group instance. - :param url: Optional resource URL to use for external resources. - :param crossorigin: Sets the mode of the request to an HTTP CORS Request. - :param referrerpolicy: Specifies which referrer information to send when - fetching the resource. - :param type_: Specifies the media type of the resource. - :param async_: Specifies that the script is executed asynchronously - (only for external scripts) - :param defer: Specifies that the script is executed when the page has - finished parsing (only for external scripts). - :param integrity: Allows a browser to check the fetched script to ensure - that the code is never loaded if the source has been manipulated. - If integrity given and value is 'True', the integrity hash gets - calculated from the resource file content. This automatic calculation - won't work if ``url`` is given. If value is a string, it is assumed - to be the already calculated resource hash and is taken as is. - :param nomodule: Specifies that the script should not be executed in - browsers supporting ES2015 modules. - :param **kwargs: Additional keyword arguments. Gets rendered as - additional attributes on resource tag. - :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 - ) - self.async_ = async_ - self.defer = defer - self.integrity = integrity - self.nomodule = nomodule - - @property - def integrity(self): - if not self._integrity: - return self._integrity - 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 - ) - return self._integrity_hash - - @integrity.setter - def integrity(self, integrity): - 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 = integrity - - def render(self, base_url): - """Renders the resource HTML ``script`` tag. - - :param base_url: The base URL to create the URL resource. - """ - attrs = { - 'src': self.resource_url(base_url), - 'crossorigin': self.crossorigin, - 'referrerpolicy': self.referrerpolicy, - 'type': self.type_, - 'async': self.async_, - 'defer': self.defer, - 'integrity': self.integrity, - 'nomodule': self.nomodule - } - attrs.update(self.additional_attrs) - return self._render_tag('script', True, **attrs) - - -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 - ): - 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 - ) - self.hreflang = hreflang - self.media = media - self.rel = rel - self.sizes = sizes - self.title = title - - def render(self, base_url): - """Renders the resource HTML ``link`` tag. - - :param base_url: The base URL to create the URL resource. - """ - attrs = { - 'href': self.resource_url(base_url), - 'crossorigin': self.crossorigin, - 'referrerpolicy': self.referrerpolicy, - 'type': self.type_, - 'hreflang': self.hreflang, - 'media': self.media, - 'rel': self.rel, - 'sizes': self.sizes, - 'title': self.title - } - attrs.update(self.additional_attrs) - return self._render_tag('link', False, **attrs) - - -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 - ): - """Create link resource. - - :param name: The resource unique name. - :param depends: Optional name or list of names of dependency resources. - :param directory: Directory containing the resource files. - :param path: URL path for HTML tag link creation. - :param resource: Resource file. - :param compressed: Optional compressed version of resource file. - :param include: Flag or callback function returning a flag whether to - include the resource. - :param unique: Flag whether to render resource URL including unique key. - Has no effect if ``url`` is given. - :param unique_prefix: Prefix for unique key. Defaults to - '++webresource++'. - :param hash_algorithm: Name of the hashing algorithm. Either 'sha256', - 'sha384' or 'sha512'. Defaults to 'sha384'. - :param group: Optional resource group instance. - :param url: Optional resource URL to use for external resources. - :param crossorigin: Sets the mode of the request to an HTTP CORS Request. - :param referrerpolicy: Specifies which referrer information to send when - fetching the resource. - :param type_: Specifies the media type of the resource. - :param hreflang: Specifies the language of the text in the linked - document. - :param media: Specifies on what device the linked document will be - displayed. - :param rel: Required. Specifies the relationship between the current - document and the linked document. - :param sizes: Specifies the size of the linked resource. Only for - rel="icon". - :param title: Defines a preferred or an alternate stylesheet. - :param **kwargs: Additional keyword arguments. Gets rendered as - additional attributes on resource tag. - :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 - ) - - -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 - ): - """Create link resource. - - :param name: The resource unique name. - :param depends: Optional name or list of names of dependency resources. - :param directory: Directory containing the resource files. - :param path: URL path for HTML tag link creation. - :param resource: Resource file. - :param compressed: Optional compressed version of resource file. - :param include: Flag or callback function returning a flag whether to - include the resource. - :param unique: Flag whether to render resource URL including unique key. - Has no effect if ``url`` is given. - :param unique_prefix: Prefix for unique key. Defaults to - '++webresource++'. - :param hash_algorithm: Name of the hashing algorithm. Either 'sha256', - 'sha384' or 'sha512'. Defaults to 'sha384'. - :param group: Optional resource group instance. - :param url: Optional resource URL to use for external resources. - :param crossorigin: Sets the mode of the request to an HTTP CORS Request. - :param referrerpolicy: Specifies which referrer information to send when - fetching the resource. - :param hreflang: Specifies the language of the text in the linked - document. - :param media: Specifies on what device the linked document will be - displayed. Defaults to "all". - :param rel: Specifies the relationship between the current document and - the linked document. Defaults to "stylesheet". - :param title: Defines a preferred or an alternate stylesheet. - :param **kwargs: Additional keyword arguments. Gets rendered as - additional attributes on resource tag. - :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 - ) - - -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) diff --git a/webresource/base.py b/webresource/base.py new file mode 100644 index 0000000..a81da82 --- /dev/null +++ b/webresource/base.py @@ -0,0 +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 + + +if TYPE_CHECKING: + from webresource.groups import ResourceGroup + + +class ResourceMixin: + """Mixin for ``Resource`` and ``ResourceGroup``.""" + + 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) # type: ignore[arg-type] + + @property + 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: str | None) -> None: + self._path = path + + @property + 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: str | None) -> None: + if directory is None: + self._directory = None + return + self._directory = os.path.abspath(directory) + + @property + def include(self) -> bool: + if callable(self._include): + return self._include() + return self._include + + @include.setter + def include(self, include: bool | Callable[[], bool]) -> None: + self._include = include + + 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) # type: ignore[arg-type] + self.parent = None + + 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 new file mode 100644 index 0000000..101cd12 --- /dev/null +++ b/webresource/config.py @@ -0,0 +1,18 @@ +import logging +import uuid + + +logger: logging.Logger = logging.getLogger(__name__) +namespace_uuid: uuid.UUID = uuid.UUID('f3341b2e-f97e-40d2-ad2f-10a08a778877') + + +class ResourceConfig: + """Config singleton for web resources.""" + + development: bool + + def __init__(self) -> None: + self.development = False + + +config: ResourceConfig = ResourceConfig() diff --git a/webresource/exceptions.py b/webresource/exceptions.py new file mode 100644 index 0000000..59785ab --- /dev/null +++ b/webresource/exceptions.py @@ -0,0 +1,40 @@ +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.""" + + +class ResourceConflictError(ResourceError): + """Multiple resources declared with the same name.""" + + def __init__(self, counter: Counter[str]) -> None: + 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: list[Resource]) -> None: + 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: Resource) -> None: + 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..18b184d --- /dev/null +++ b/webresource/groups.py @@ -0,0 +1,104 @@ +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 +from webresource.resources import Resource +from webresource.resources import ScriptResource +from webresource.resources import StyleResource + + +T = TypeVar('T', bound=Resource) + + +class ResourceGroup(ResourceMixin): + """A resource group.""" + + _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. + :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[Resource | ResourceGroup]: + """List of group members. + + Group members are either instances of ``Resource`` or ``ResourceGroup``. + """ + return self._members + + @property + def scripts(self) -> list[ScriptResource]: + """List of all contained ``ScriptResource`` instances. + + Resources from subsequent resource groups are included. + """ + return self._filtered_resources(ScriptResource) + + @property + def styles(self) -> list[StyleResource]: + """List of all contained ``StyleResource`` instances. + + Resources from subsequent resource groups are included. + """ + return self._filtered_resources(StyleResource) + + @property + 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: Resource | ResourceGroup) -> None: + """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_: type[T], members: list[Resource | ResourceGroup] | None = None + ) -> list[T]: + if members is None: + members = self.members + resources: list[T] = [] + 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) -> str: + return '{} name="{}"'.format(self.__class__.__name__, self.name) diff --git a/webresource/renderer.py b/webresource/renderer.py new file mode 100644 index 0000000..f038c10 --- /dev/null +++ b/webresource/renderer.py @@ -0,0 +1,40 @@ +from webresource.config import logger +from webresource.exceptions import ResourceError +from webresource.resolver import ResourceResolver + + +class ResourceRenderer: + """Resource renderer.""" + + resolver: ResourceResolver + base_url: str + + def __init__( + self, resolver: ResourceResolver, base_url: str = 'https://tld.org' + ) -> None: + """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) -> str: + """Render resources.""" + return '\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) -> str: + lines = [] + for resource in self.resolver.resolve(): + try: + lines.append(resource.render(self.base_url)) + except (ResourceError, FileNotFoundError): + msg = 'Failure to render resource "{}"'.format(resource.name) + lines.append(''.format(msg)) + logger.exception(msg) + return '\n'.join(lines) diff --git a/webresource/resolver.py b/webresource/resolver.py new file mode 100644 index 0000000..43605fb --- /dev/null +++ b/webresource/resolver.py @@ -0,0 +1,98 @@ +from __future__ import annotations + +from collections import Counter +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 + + +class ResourceResolver: + """Resource resolver.""" + + 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 + ``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: list[Resource | ResourceGroup] | None = None + ) -> list[Resource]: + if members is None: + members = self.members + resources: list[Resource] = [] + 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) -> list[Resource]: + """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[:]: + assert resource.depends is not None # guaranteed by above loop + 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/resources.py b/webresource/resources.py new file mode 100644 index 0000000..1f5595e --- /dev/null +++ b/webresource/resources.py @@ -0,0 +1,572 @@ +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 +from webresource.exceptions import ResourceError + +import base64 +import hashlib +import os +import uuid + + +if TYPE_CHECKING: + from webresource.groups import ResourceGroup + + +class Resource(ResourceMixin): + """A web resource.""" + + _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: 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. + :param depends: Optional name or list of names of dependency resources. + :param directory: Directory containing the resource files. + :param path: URL path for HTML tag link creation. + :param resource: Resource file. + :param compressed: Optional compressed version of resource file. + :param include: Flag or callback function returning a flag whether to + include the resource. + :param unique: Flag whether to render resource URL including unique key. + Has no effect if ``url`` is given. + :param unique_prefix: Prefix for unique key. Defaults to + '++webresource++'. + :param hash_algorithm: Name of the hashing algorithm. Either 'sha256', + 'sha384' or 'sha512'. Defaults to 'sha384'. + :param group: Optional resource group instance. + :param url: Optional resource URL to use for external resources. + :param crossorigin: Sets the mode of the request to an HTTP CORS Request. + :param referrerpolicy: Specifies which referrer information to send when + fetching the resource. + :param type_: Specifies the media type of the resource. + :param **kwargs: Additional keyword arguments. Gets rendered as + additional attributes on resource tag. + :raise ResourceError: No resource and no url given. + """ + 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 + ) + 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 + self.unique_prefix = unique_prefix + self.hash_algorithm = hash_algorithm + self.file_hash = None + self.url = url + self.crossorigin = crossorigin + self.referrerpolicy = referrerpolicy + self.type_ = type_ + self.additional_attrs = kwargs + + @property + 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) -> str: + """Absolute resource file path depending on operation mode.""" + directory = self.directory + if not directory: + raise ResourceError('No directory set on resource.') + return os.path.join(directory, self.file_name) + + @property + 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) -> 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_str = hash_.decode() + self.file_hash = hash_str + return hash_str + + @file_hash.setter + def file_hash(self, hash_: str | None) -> None: + self._file_hash = hash_ + + @property + def unique_key(self) -> str: + return '{}{}'.format( + self.unique_prefix, str(uuid.uuid5(namespace_uuid, self.file_hash)) + ) + + def resource_url(self, base_url: str) -> str: + """Create URL for resource. + + :param base_url: The base URL to create the URL resource. + """ + if self.url is not None: + return self.url + parts = [base_url.strip('/')] + path = self.path + if path: + parts.append(path.strip('/')) + if self.unique: + parts.append(self.unique_key) + parts.append(self.file_name) + return '/'.join(parts) + + 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. + :raise NotImplementedError: Method is abstract. + """ + raise NotImplementedError('Abstract resource not implements ``render``') + + 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_str = ' {0}'.format(' '.join(sorted(attrs_))) + if not closing_tag: + return '<{tag}{attrs} />'.format(tag=tag, attrs=attrs_str) + return '<{tag}{attrs}>'.format(tag=tag, attrs=attrs_str) + + def __repr__(self) -> str: + return ('{} name="{}", depends="{}"').format( + self.__class__.__name__, self.name, self.depends + ) + + +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: 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. + :param depends: Optional name or list of names of dependency resources. + :param directory: Directory containing the resource files. + :param path: URL path for HTML tag link creation. + :param resource: Resource file. + :param compressed: Optional compressed version of resource file. + :param include: Flag or callback function returning a flag whether to + include the resource. + :param unique: Flag whether to render resource URL including unique key. + Has no effect if ``url`` is given. + :param unique_prefix: Prefix for unique key. Defaults to + '++webresource++'. + :param hash_algorithm: Name of the hashing algorithm. Either 'sha256', + 'sha384' or 'sha512'. Defaults to 'sha384'. + :param group: Optional resource group instance. + :param url: Optional resource URL to use for external resources. + :param crossorigin: Sets the mode of the request to an HTTP CORS Request. + :param referrerpolicy: Specifies which referrer information to send when + fetching the resource. + :param type_: Specifies the media type of the resource. + :param async_: Specifies that the script is executed asynchronously + (only for external scripts) + :param defer: Specifies that the script is executed when the page has + finished parsing (only for external scripts). + :param integrity: Allows a browser to check the fetched script to ensure + that the code is never loaded if the source has been manipulated. + If integrity given and value is 'True', the integrity hash gets + calculated from the resource file content. This automatic calculation + won't work if ``url`` is given. If value is a string, it is assumed + to be the already calculated resource hash and is taken as is. + :param nomodule: Specifies that the script should not be executed in + browsers supporting ES2015 modules. + :param **kwargs: Additional keyword arguments. Gets rendered as + additional attributes on resource tag. + :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, + ) + self.async_ = async_ + self.defer = defer + self.integrity = integrity + self.nomodule = nomodule + + @property + def integrity(self) -> str | None: + if not self._integrity: + return None + if not config.development and self._integrity_hash is not None: + return self._integrity_hash + if self._integrity is True: + self._integrity_hash = '{}-{}'.format(self.hash_algorithm, self.file_hash) + return self._integrity_hash + + @integrity.setter + 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 # type: ignore[assignment] + self._integrity = integrity + + def render(self, base_url: str) -> str: + """Renders the resource HTML ``script`` tag. + + :param base_url: The base URL to create the URL resource. + """ + attrs = { + 'src': self.resource_url(base_url), + 'crossorigin': self.crossorigin, + 'referrerpolicy': self.referrerpolicy, + 'type': self.type_, + 'async': self.async_, + 'defer': self.defer, + 'integrity': self.integrity, + 'nomodule': self.nomodule, + } + attrs.update(self.additional_attrs) + return self._render_tag('script', True, **attrs) + + +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: 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, + 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 + self.rel = rel + self.sizes = sizes + self.title = title + + def render(self, base_url: str) -> str: + """Renders the resource HTML ``link`` tag. + + :param base_url: The base URL to create the URL resource. + """ + attrs = { + 'href': self.resource_url(base_url), + 'crossorigin': self.crossorigin, + 'referrerpolicy': self.referrerpolicy, + 'type': self.type_, + 'hreflang': self.hreflang, + 'media': self.media, + 'rel': self.rel, + 'sizes': self.sizes, + 'title': self.title, + } + attrs.update(self.additional_attrs) + return self._render_tag('link', False, **attrs) + + +class LinkResource(LinkMixin): + """A Link Resource.""" + + def __init__( + self, + 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. + :param depends: Optional name or list of names of dependency resources. + :param directory: Directory containing the resource files. + :param path: URL path for HTML tag link creation. + :param resource: Resource file. + :param compressed: Optional compressed version of resource file. + :param include: Flag or callback function returning a flag whether to + include the resource. + :param unique: Flag whether to render resource URL including unique key. + Has no effect if ``url`` is given. + :param unique_prefix: Prefix for unique key. Defaults to + '++webresource++'. + :param hash_algorithm: Name of the hashing algorithm. Either 'sha256', + 'sha384' or 'sha512'. Defaults to 'sha384'. + :param group: Optional resource group instance. + :param url: Optional resource URL to use for external resources. + :param crossorigin: Sets the mode of the request to an HTTP CORS Request. + :param referrerpolicy: Specifies which referrer information to send when + fetching the resource. + :param type_: Specifies the media type of the resource. + :param hreflang: Specifies the language of the text in the linked + document. + :param media: Specifies on what device the linked document will be + displayed. + :param rel: Required. Specifies the relationship between the current + document and the linked document. + :param sizes: Specifies the size of the linked resource. Only for + rel="icon". + :param title: Defines a preferred or an alternate stylesheet. + :param **kwargs: Additional keyword arguments. Gets rendered as + additional attributes on resource tag. + :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, + ) + + +class StyleResource(LinkMixin): + """A Stylesheet Resource.""" + + def __init__( + self, + 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. + :param depends: Optional name or list of names of dependency resources. + :param directory: Directory containing the resource files. + :param path: URL path for HTML tag link creation. + :param resource: Resource file. + :param compressed: Optional compressed version of resource file. + :param include: Flag or callback function returning a flag whether to + include the resource. + :param unique: Flag whether to render resource URL including unique key. + Has no effect if ``url`` is given. + :param unique_prefix: Prefix for unique key. Defaults to + '++webresource++'. + :param hash_algorithm: Name of the hashing algorithm. Either 'sha256', + 'sha384' or 'sha512'. Defaults to 'sha384'. + :param group: Optional resource group instance. + :param url: Optional resource URL to use for external resources. + :param crossorigin: Sets the mode of the request to an HTTP CORS Request. + :param referrerpolicy: Specifies which referrer information to send when + fetching the resource. + :param hreflang: Specifies the language of the text in the linked + document. + :param media: Specifies on what device the linked document will be + displayed. Defaults to "all". + :param rel: Specifies the relationship between the current document and + the linked document. Defaults to "stylesheet". + :param title: Defines a preferred or an alternate stylesheet. + :param **kwargs: Additional keyword arguments. Gets rendered as + additional attributes on resource tag. + :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, + ) diff --git a/webresource/tests.py b/webresource/tests.py deleted file mode 100644 index 71f18c3..0000000 --- a/webresource/tests.py +++ /dev/null @@ -1,707 +0,0 @@ -# -*- coding: utf-8 -*- -from collections import Counter -from webresource._api import ( - is_py3, - LinkMixin, - Resource, - ResourceConfig, - ResourceMixin -) -import os -import shutil -import tempfile -import unittest -import webresource as wr - - -try: - FileNotFoundError -except NameError: # pragma: nocover - FileNotFoundError = EnvironmentError - - -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, - ) - 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 - rendered = renderer.render() - self.assertEqual(rendered, ( - '\n' - '\n' - '\n' - '\n' - '' - )) - - -if __name__ == '__main__': - unittest.main()