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}>{tag}>'.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}>{tag}>'.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()