diff --git a/CHANGES.rst b/CHANGES.rst index 72bde30..f8a962b 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -5,7 +5,9 @@ Changes 1.2.2 (unreleased) ------------------ -- Nothing changed yet. +- Make sure ``Unset`` class always returns the same instance in ``__new__``. + Fixes problems with pickle. + [rnix] 1.2.1 (2023-04-16) diff --git a/Makefile b/Makefile index f1164a7..2a4c32b 100644 --- a/Makefile +++ b/Makefile @@ -32,18 +32,39 @@ CLEAN_FS?= # Default: include.mk INCLUDE_MAKEFILE?=include.mk +# Optional additional directories to be added to PATH in format +# `/path/to/dir/:/path/to/other/dir`. Gets inserted first, thus gets searched +# first. +# No default value. +EXTRA_PATH?= + ## core.mxenv -# Python interpreter to use. +# Primary Python interpreter to use. It is used to create the +# virtual environment if `VENV_ENABLED` and `VENV_CREATE` are set to `true`. # Default: python3 -PYTHON_BIN?=python3 +PRIMARY_PYTHON?=python3 # Minimum required Python version. # Default: 3.7 PYTHON_MIN_VERSION?=3.7 +# Install packages using the given package installer method. +# Supported are `pip` and `uv`. If uv is used, its global availability is +# checked. Otherwise, it is installed, either in the virtual environment or +# using the `PRIMARY_PYTHON`, dependent on the `VENV_ENABLED` setting. If +# `VENV_ENABLED` and uv is selected, uv is used to create the virtual +# environment. +# Default: pip +PYTHON_PACKAGE_INSTALLER?=uv + +# Flag whether to use a global installed 'uv' or install +# it in the virtual environment. +# Default: false +MXENV_UV_GLOBAL?=false + # Flag whether to use virtual environment. If `false`, the -# interpreter according to `PYTHON_BIN` found in `PATH` is used. +# interpreter according to `PRIMARY_PYTHON` found in `PATH` is used. # Default: true VENV_ENABLED?=true @@ -58,7 +79,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. @@ -81,6 +102,13 @@ RUFF_SRC?=src # 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 @@ -121,6 +149,8 @@ CHECK_TARGETS?= TYPECHECK_TARGETS?= FORMAT_TARGETS?= +export PATH:=$(if $(EXTRA_PATH),$(EXTRA_PATH):,)$(PATH) + # Defensive settings for make: https://tech.davis-hansson.com/p/make/ SHELL:=bash .ONESHELL: @@ -137,7 +167,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) @@ -145,40 +175,56 @@ $(SENTINEL): # mxenv ############################################################################## -# Check if given Python is installed -ifeq (,$(shell which $(PYTHON_BIN))) -$(error "PYTHON=$(PYTHON_BIN) not found in $(PATH)") -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 executable path +ifeq ("$(VENV_ENABLED)", "true") +export VIRTUAL_ENV=$(abspath $(VENV_FOLDER)) +ifeq ("$(OS)", "Windows_NT") +VENV_EXECUTABLE_FOLDER=$(VIRTUAL_ENV)/Scripts +else +VENV_EXECUTABLE_FOLDER=$(VIRTUAL_ENV)/bin endif - -# Check if venv folder is configured if venv is enabled -ifeq ($(shell [[ "$(VENV_ENABLED)" == "true" && "$(VENV_FOLDER)" == "" ]] && echo "true"),"true") -$(error "VENV_FOLDER must be configured if VENV_ENABLED is true") +export PATH:=$(VENV_EXECUTABLE_FOLDER):$(PATH) +MXENV_PYTHON=python +else +MXENV_PYTHON=$(PRIMARY_PYTHON) endif -# determine the executable path -ifeq ("$(VENV_ENABLED)", "true") -MXENV_PATH=$(VENV_FOLDER)/bin/ +# Determine the package installer +ifeq ("$(PYTHON_PACKAGE_INSTALLER)","uv") +PYTHON_PACKAGE_COMMAND=uv pip else -MXENV_PATH= +PYTHON_PACKAGE_COMMAND=$(MXENV_PYTHON) -m pip endif MXENV_TARGET:=$(SENTINEL_FOLDER)/mxenv.sentinel $(MXENV_TARGET): $(SENTINEL) + @$(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 || : + @[[ "$(VENV_ENABLED)" == "true" && "$(VENV_FOLDER)" == "" ]] \ + && echo "VENV_FOLDER must be configured if VENV_ENABLED is true" && exit 1 || : + @[[ "$(VENV_ENABLED)$(PYTHON_PACKAGE_INSTALLER)" == "falseuv" ]] \ + && echo "Package installer uv does not work with a global Python interpreter." && exit 1 || : ifeq ("$(VENV_ENABLED)", "true") ifeq ("$(VENV_CREATE)", "true") - @echo "Setup Python Virtual Environment under '$(VENV_FOLDER)'" - @$(PYTHON_BIN) -m venv $(VENV_FOLDER) +ifeq ("$(PYTHON_PACKAGE_INSTALLER)$(MXENV_UV_GLOBAL)","uvtrue") + @echo "Setup Python Virtual Environment using package 'uv' at '$(VENV_FOLDER)'" + @uv venv -p $(PRIMARY_PYTHON) --seed $(VENV_FOLDER) +else + @echo "Setup Python Virtual Environment using module 'venv' at '$(VENV_FOLDER)'" + @$(PRIMARY_PYTHON) -m venv $(VENV_FOLDER) + @$(MXENV_PYTHON) -m ensurepip -U +endif +endif +else + @echo "Using system Python interpreter" endif +ifeq ("$(PYTHON_PACKAGE_INSTALLER)$(MXENV_UV_GLOBAL)","uvfalse") + @echo "Install uv" + @$(MXENV_PYTHON) -m pip install uv endif - @$(MXENV_PATH)pip install -U pip setuptools wheel - @$(MXENV_PATH)pip install -U $(MXDEV) - @$(MXENV_PATH)pip install -U $(MXMAKE) + @$(PYTHON_PACKAGE_COMMAND) install -U pip setuptools wheel + @echo "Install/Update MXStack Python packages" + @$(PYTHON_PACKAGE_COMMAND) install -U $(MXDEV) $(MXMAKE) @touch $(MXENV_TARGET) .PHONY: mxenv @@ -195,8 +241,8 @@ ifeq ("$(VENV_CREATE)", "true") @rm -rf $(VENV_FOLDER) endif else - @$(MXENV_PATH)pip uninstall -y $(MXDEV) - @$(MXENV_PATH)pip uninstall -y $(MXMAKE) + @$(PYTHON_PACKAGE_COMMAND) uninstall -y $(MXDEV) + @$(PYTHON_PACKAGE_COMMAND) uninstall -y $(MXMAKE) endif INSTALL_TARGETS+=mxenv @@ -210,18 +256,18 @@ CLEAN_TARGETS+=mxenv-clean RUFF_TARGET:=$(SENTINEL_FOLDER)/ruff.sentinel $(RUFF_TARGET): $(MXENV_TARGET) @echo "Install Ruff" - @$(MXENV_PATH)pip install ruff + @$(PYTHON_PACKAGE_COMMAND) install ruff @touch $(RUFF_TARGET) .PHONY: ruff-check ruff-check: $(RUFF_TARGET) @echo "Run ruff check" - @$(MXENV_PATH)ruff check $(RUFF_SRC) + @ruff check $(RUFF_SRC) .PHONY: ruff-format ruff-format: $(RUFF_TARGET) @echo "Run ruff format" - @$(MXENV_PATH)ruff format $(RUFF_SRC) + @ruff format $(RUFF_SRC) .PHONY: ruff-dirty ruff-dirty: @@ -229,7 +275,8 @@ ruff-dirty: .PHONY: ruff-clean ruff-clean: ruff-dirty - @test -e $(MXENV_PATH)pip && $(MXENV_PATH)pip uninstall -y ruff || : + @test -e $(MXENV_PYTHON) && $(MXENV_PYTHON) -m pip uninstall -y ruff || : + @rm -rf .ruff_cache INSTALL_TARGETS+=$(RUFF_TARGET) CHECK_TARGETS+=ruff-check @@ -249,13 +296,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 @@ -272,9 +317,9 @@ FILES_TARGET:=requirements-mxdev.txt $(FILES_TARGET): $(PROJECT_CONFIG) $(MXENV_TARGET) $(SOURCES_TARGET) $(LOCAL_PACKAGE_FILES) @echo "Create project files" @mkdir -p $(MXMAKE_FILES) - $(call set_mxfiles_env,$(MXENV_PATH),$(MXMAKE_FILES)) - @$(MXENV_PATH)mxdev -n -c $(PROJECT_CONFIG) - $(call unset_mxfiles_env,$(MXENV_PATH),$(MXMAKE_FILES)) + $(call set_mxfiles_env,$(MXMAKE_FILES)) + @mxdev -n -c $(PROJECT_CONFIG) + $(call unset_mxfiles_env) @test -e $(MXMAKE_FILES)/pip.conf && cp $(MXMAKE_FILES)/pip.conf $(VENV_FOLDER)/pip.conf || : @touch $(FILES_TARGET) @@ -303,11 +348,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 @@ -320,8 +375,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) @@ -336,14 +391,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: @@ -351,7 +406,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) @@ -365,14 +420,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: @@ -380,7 +435,7 @@ 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) diff --git a/README.rst b/README.rst index 80243c2..da7ff41 100644 --- a/README.rst +++ b/README.rst @@ -10,7 +10,7 @@ Node :alt: Number of PyPI downloads .. image:: https://github.com/conestack/node/actions/workflows/test.yaml/badge.svg - :target: https://github.com/conestack/node/actions/workflows/test.yaml + :target: https://github.com/conestack/node/actions/workflows/test.yml :alt: Test node diff --git a/src/node/tests/test_utils.py b/src/node/tests/test_utils.py index 7fca7db..18f234a 100644 --- a/src/node/tests/test_utils.py +++ b/src/node/tests/test_utils.py @@ -13,9 +13,11 @@ from node.utils import safe_encode from node.utils import StrCodec from node.utils import UNSET +from node.utils import Unset from odict import odict import copy import logging +import pickle class TestUtils(NodeTestCase): @@ -31,6 +33,8 @@ def test_UNSET(self): self.assertFalse(UNSET <= UNSET) self.assertFalse(UNSET > UNSET) self.assertFalse(UNSET >= UNSET) + self.assertTrue(Unset() is UNSET) + self.assertTrue(pickle.loads(pickle.dumps(UNSET)) is UNSET) def test_ReverseMapping(self): context = odict([ diff --git a/src/node/utils.py b/src/node/utils.py index 200d4a4..29cdb8f 100644 --- a/src/node/utils.py +++ b/src/node/utils.py @@ -14,6 +14,12 @@ class Unset(object): """Identify unset values in contrast to None.""" + instance = None + + def __new__(cls): + if cls.instance is None: + cls.instance = object.__new__(cls) + return cls.instance def __nonzero__(self): return False