From 18d23ba92a5f9ce1c024f1f5294da4da6fa5dd7a Mon Sep 17 00:00:00 2001 From: Tristan Kuehn Date: Wed, 14 Jun 2023 17:02:34 -0400 Subject: [PATCH 1/9] Take a stab at saving version info --- poetry.lock | 2 +- pyproject.toml | 1 + snakebids/app.py | 19 ++++++++++++++++++- 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/poetry.lock b/poetry.lock index 2fb215fe..7e71f6cb 100644 --- a/poetry.lock +++ b/poetry.lock @@ -3131,4 +3131,4 @@ testing = ["big-O", "flake8 (<5)", "jaraco.functools", "jaraco.itertools", "more [metadata] lock-version = "2.0" python-versions = ">=3.7,<3.12" -content-hash = "211348130ff5674b03a24fe52822c7985ffa25bafe62ea7579eb17224b718b25" +content-hash = "a357c428fa8c73c70766bfe27c52d6ea53713a3f71dc83134a61fc2180a6104e" diff --git a/pyproject.toml b/pyproject.toml index 19e1f9b5..320ee8b5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,6 +52,7 @@ boutiques = "^0.5.25" more-itertools = ">=8,<10" cached-property = "^1.5.2" pvandyken-deprecated = "0.0.3" +importlib_metadata = [ { version = "^6.6.0", python = "<3.8" } ] # Below are non-direct dependencies (i.e. dependencies of other depenencies) # specified to ensure a version with a pre-built wheel is installed depending diff --git a/snakebids/app.py b/snakebids/app.py index 518c73e0..c9d9ac1b 100644 --- a/snakebids/app.py +++ b/snakebids/app.py @@ -13,6 +13,11 @@ import snakemake from snakemake.io import load_configfile +if sys.version_info >= (3, 8): + from importlib import metadata +else: + import importlib_metadata as metadata + from snakebids.cli import ( SnakebidsArgs, add_dynamic_args, @@ -207,7 +212,12 @@ def run_snakemake(self) -> None: # Write the config file write_config_file( config_file=new_config_file, - data=app.config, + data=dict( + app.config, + snakemake_version=metadata.version("snakemake"), + snakebids_version=metadata.version("snakebids"), + app_version=app.get_app_version() or "unknown", + ), force_overwrite=True, ) @@ -240,6 +250,13 @@ def create_descriptor(self, out_file: PathLike[str] | str) -> None: ) new_descriptor.save(out_file) # type: ignore + def get_app_version(self) -> str | None: + """Attempt to get the app version, returning None if we can't.""" + try: + return metadata.version(self.snakemake_dir.name) + except metadata.PackageNotFoundError: + return None + def update_config(config: dict[str, Any], snakebids_args: SnakebidsArgs) -> None: """Add snakebids arguments to config in-place.""" From ac6cf4c9deb66b5e84ef1174bd338ecea29457a9 Mon Sep 17 00:00:00 2001 From: Tristan Kuehn Date: Thu, 15 Jun 2023 10:16:24 -0400 Subject: [PATCH 2/9] Improve get_app_version README --- snakebids/app.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/snakebids/app.py b/snakebids/app.py index c9d9ac1b..870c4a10 100644 --- a/snakebids/app.py +++ b/snakebids/app.py @@ -251,7 +251,15 @@ def create_descriptor(self, out_file: PathLike[str] | str) -> None: new_descriptor.save(out_file) # type: ignore def get_app_version(self) -> str | None: - """Attempt to get the app version, returning None if we can't.""" + """Attempt to get the app version, returning None if we can't. + + This will succeed only if the following conditions are true: + + 1. The Snakebids app is a distribution package installed in the current + environment. + 2. The app's distribution package has the same name as this + SnakeBidsApp's snakemake_dir + """ try: return metadata.version(self.snakemake_dir.name) except metadata.PackageNotFoundError: From c272beeab1ec315099b141f363af8aed40d08413 Mon Sep 17 00:00:00 2001 From: Tristan Kuehn Date: Thu, 15 Jun 2023 10:17:38 -0400 Subject: [PATCH 3/9] Add typings for importlib_metadata --- typings/importlib_metadata/__init__.pyi | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 typings/importlib_metadata/__init__.pyi diff --git a/typings/importlib_metadata/__init__.pyi b/typings/importlib_metadata/__init__.pyi new file mode 100644 index 00000000..9fcb412e --- /dev/null +++ b/typings/importlib_metadata/__init__.pyi @@ -0,0 +1,15 @@ +def version(distribution_name: str) -> str: + """Get the version string for the named package. + + :param distribution_name: The name of the distribution package to query. + :return: The version string for the package as defined in the package's + "Version" metadata key. + """ + ... + +class PackageNotFoundError(ModuleNotFoundError): + """The package was not found.""" + + def __str__(self) -> str: ... + @property + def name(self) -> str: ... From 8d25cfabbae5c5697d7d82d0e64504884ef619d2 Mon Sep 17 00:00:00 2001 From: Tristan Kuehn Date: Thu, 15 Jun 2023 10:18:05 -0400 Subject: [PATCH 4/9] Add tests for added provenance --- snakebids/tests/test_app.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/snakebids/tests/test_app.py b/snakebids/tests/test_app.py index 5b379c2d..c1dd84e7 100644 --- a/snakebids/tests/test_app.py +++ b/snakebids/tests/test_app.py @@ -2,6 +2,7 @@ import copy import json +import sys from pathlib import Path from typing import Any, cast @@ -20,6 +21,11 @@ from ..app import SnakeBidsApp from .mock.config import config +if sys.version_info >= (3, 8): + from importlib import metadata +else: + import importlib_metadata as metadata + @pytest.fixture def app(mocker: MockerFixture): @@ -142,6 +148,9 @@ def test_runs_in_correct_mode( "pybidsdb_reset": True, "snakefile": Path("Snakefile"), "output_dir": outputdir.resolve(), + "snakemake_version": metadata.version("snakemake"), + "snakebids_version": "0.0.0", # poetry-dynamic-versioning + "app_version": "unknown", # not installing a snakebids app here } ) if root == "app" and not tail: @@ -234,6 +243,19 @@ def plugin(my_app: SnakeBidsApp): assert app.foo == "bar" # type: ignore + def test_get_app_version_no_package(self, app: SnakeBidsApp): + assert app.get_app_version() is None + + def test_get_app_version_package(self, mocker: MockerFixture, app: SnakeBidsApp): + app.snakemake_dir = Path("my_app") + + metadata_pkg = ( + "importlib.metadata" if sys.version_info >= (3, 8) else "importlib_metadata" + ) + mock = mocker.patch(f"{metadata_pkg}.version", return_value="0.1.0") + assert app.get_app_version() == "0.1.0" + mock.assert_called_once_with("my_app") + class TestGenBoutiques: def test_boutiques_descriptor(self, tmp_path: Path, app: SnakeBidsApp): From b2f34cf2b4a143bfcdc850434160c12c181100ac Mon Sep 17 00:00:00 2001 From: Tristan Kuehn Date: Wed, 21 Jun 2023 11:38:58 -0400 Subject: [PATCH 5/9] Make version a SnakeBidsApp attribute --- snakebids/app.py | 34 ++++++++++++++++++---------------- snakebids/tests/test_app.py | 15 ++++++++++----- 2 files changed, 28 insertions(+), 21 deletions(-) diff --git a/snakebids/app.py b/snakebids/app.py index 870c4a10..686acca9 100644 --- a/snakebids/app.py +++ b/snakebids/app.py @@ -73,6 +73,22 @@ def wrapper(self: "SnakeBidsApp"): return wrapper +def _get_app_version(self: SnakeBidsApp) -> str | None: + """Attempt to get the app version, returning None if we can't. + + This will succeed only if the following conditions are true: + + 1. The Snakebids app is a distribution package installed in the current + environment. + 2. The app's distribution package has the same name as this + SnakeBidsApp's snakemake_dir + """ + try: + return metadata.version(self.snakemake_dir.name) + except metadata.PackageNotFoundError: + return None + + @attr.define(slots=False) class SnakeBidsApp: """Snakebids app with config and arguments. @@ -133,6 +149,7 @@ class SnakeBidsApp: lambda self: load_configfile(self.snakemake_dir / self.configfile_path), takes_self=True, ) + version: Optional[str] = attr.Factory(_get_app_version, takes_self=True) args: Optional[SnakebidsArgs] = None def run_snakemake(self) -> None: @@ -216,7 +233,7 @@ def run_snakemake(self) -> None: app.config, snakemake_version=metadata.version("snakemake"), snakebids_version=metadata.version("snakebids"), - app_version=app.get_app_version() or "unknown", + app_version=app.version or "unknown", ), force_overwrite=True, ) @@ -250,21 +267,6 @@ def create_descriptor(self, out_file: PathLike[str] | str) -> None: ) new_descriptor.save(out_file) # type: ignore - def get_app_version(self) -> str | None: - """Attempt to get the app version, returning None if we can't. - - This will succeed only if the following conditions are true: - - 1. The Snakebids app is a distribution package installed in the current - environment. - 2. The app's distribution package has the same name as this - SnakeBidsApp's snakemake_dir - """ - try: - return metadata.version(self.snakemake_dir.name) - except metadata.PackageNotFoundError: - return None - def update_config(config: dict[str, Any], snakebids_args: SnakebidsArgs) -> None: """Add snakebids arguments to config in-place.""" diff --git a/snakebids/tests/test_app.py b/snakebids/tests/test_app.py index c1dd84e7..970899b6 100644 --- a/snakebids/tests/test_app.py +++ b/snakebids/tests/test_app.py @@ -244,16 +244,21 @@ def plugin(my_app: SnakeBidsApp): assert app.foo == "bar" # type: ignore def test_get_app_version_no_package(self, app: SnakeBidsApp): - assert app.get_app_version() is None - - def test_get_app_version_package(self, mocker: MockerFixture, app: SnakeBidsApp): - app.snakemake_dir = Path("my_app") + assert app.version is None + def test_get_app_version_package(self, mocker: MockerFixture): metadata_pkg = ( "importlib.metadata" if sys.version_info >= (3, 8) else "importlib_metadata" ) mock = mocker.patch(f"{metadata_pkg}.version", return_value="0.1.0") - assert app.get_app_version() == "0.1.0" + app = SnakeBidsApp( + Path("my_app"), + snakefile_path=Path("Snakefile"), + configfile_path=Path("mock/config.yaml"), + config=copy.deepcopy(config), + ) + + assert app.version == "0.1.0" mock.assert_called_once_with("my_app") From e28582680a2528bedb0361c30bc9c5a2e4ed5006 Mon Sep 17 00:00:00 2001 From: Tristan Kuehn Date: Wed, 21 Jun 2023 11:52:47 -0400 Subject: [PATCH 6/9] Pin importlib_metadata --- poetry.lock | 70 +++++++++++++++++++++++++++++++------------------- pyproject.toml | 2 +- 2 files changed, 45 insertions(+), 27 deletions(-) diff --git a/poetry.lock b/poetry.lock index 7e71f6cb..ae9b927d 100644 --- a/poetry.lock +++ b/poetry.lock @@ -786,24 +786,22 @@ files = [ [[package]] name = "importlib-metadata" -version = "6.6.0" +version = "1.4.0" description = "Read metadata from Python packages" category = "main" optional = false -python-versions = ">=3.7" +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" files = [ - {file = "importlib_metadata-6.6.0-py3-none-any.whl", hash = "sha256:43dd286a2cd8995d5eaef7fee2066340423b818ed3fd70adf0bad5f1fac53fed"}, - {file = "importlib_metadata-6.6.0.tar.gz", hash = "sha256:92501cdf9cc66ebd3e612f1b4f0c0765dfa42f0fa38ffb319b6bd84dd675d705"}, + {file = "importlib_metadata-1.4.0-py2.py3-none-any.whl", hash = "sha256:bdd9b7c397c273bcc9a11d6629a38487cd07154fa255a467bf704cd2c258e359"}, + {file = "importlib_metadata-1.4.0.tar.gz", hash = "sha256:f17c015735e1a88296994c0697ecea7e11db24290941983b08c9feb30921e6d8"}, ] [package.dependencies] -typing-extensions = {version = ">=3.6.4", markers = "python_version < \"3.8\""} zipp = ">=0.5" [package.extras] -docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] -perf = ["ipython"] -testing = ["flake8 (<5)", "flufl.flake8", "importlib-resources (>=1.3)", "packaging", "pyfakefs", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)", "pytest-perf (>=0.9.2)"] +docs = ["rst.linker", "sphinx"] +testing = ["importlib-resources", "packaging"] [[package]] name = "importlib-resources" @@ -1265,6 +1263,27 @@ files = [ {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, ] +[[package]] +name = "nbformat" +version = "5.6.0" +description = "The Jupyter Notebook format" +category = "main" +optional = false +python-versions = ">=3.7" +files = [ + {file = "nbformat-5.6.0-py3-none-any.whl", hash = "sha256:349db50afcf5f44cac6ddcf747fcb9330eafb751044c83994066c48e2f140b35"}, + {file = "nbformat-5.6.0.tar.gz", hash = "sha256:6f9edb3b70119d82ba89b74b0ecfdbb83f35af8661e491e49ac0893657042674"}, +] + +[package.dependencies] +fastjsonschema = "*" +jsonschema = ">=2.6" +jupyter-core = "*" +traitlets = ">=5.1" + +[package.extras] +test = ["check-manifest", "pep440", "pre-commit", "pytest", "testpath"] + [[package]] name = "nbformat" version = "5.8.0" @@ -1279,7 +1298,6 @@ files = [ [package.dependencies] fastjsonschema = "*" -importlib-metadata = {version = ">=3.6", markers = "python_version < \"3.8\""} jsonschema = ">=2.6" jupyter-core = "*" traitlets = ">=5.1" @@ -1713,22 +1731,22 @@ files = [ [[package]] name = "platformdirs" -version = "3.5.0" +version = "2.6.2" description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." category = "dev" optional = false python-versions = ">=3.7" files = [ - {file = "platformdirs-3.5.0-py3-none-any.whl", hash = "sha256:47692bc24c1958e8b0f13dd727307cff1db103fca36399f457da8e05f222fdc4"}, - {file = "platformdirs-3.5.0.tar.gz", hash = "sha256:7954a68d0ba23558d753f73437c55f89027cf8f5108c19844d4b82e5af396335"}, + {file = "platformdirs-2.6.2-py3-none-any.whl", hash = "sha256:83c8f6d04389165de7c9b6f0c682439697887bca0aa2f1c87ef1826be3584490"}, + {file = "platformdirs-2.6.2.tar.gz", hash = "sha256:e1fea1fe471b9ff8332e229df3cb7de4f53eeea4998d3b6bfff542115e998bd2"}, ] [package.dependencies] -typing-extensions = {version = ">=4.5", markers = "python_version < \"3.8\""} +typing-extensions = {version = ">=4.4", markers = "python_version < \"3.8\""} [package.extras] -docs = ["furo (>=2023.3.27)", "proselint (>=0.13)", "sphinx (>=6.1.3)", "sphinx-autodoc-typehints (>=1.23,!=1.23.4)"] -test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.3.1)", "pytest-cov (>=4)", "pytest-mock (>=3.10)"] +docs = ["furo (>=2022.12.7)", "proselint (>=0.13)", "sphinx (>=5.3)", "sphinx-autodoc-typehints (>=1.19.5)"] +test = ["appdirs (==1.4.4)", "covdefaults (>=2.2.2)", "pytest (>=7.2)", "pytest-cov (>=4)", "pytest-mock (>=3.10)"] [[package]] name = "pluggy" @@ -2990,25 +3008,25 @@ socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] [[package]] name = "virtualenv" -version = "20.23.0" +version = "20.16.2" description = "Virtual Python Environment builder" category = "dev" optional = false -python-versions = ">=3.7" +python-versions = ">=3.6" files = [ - {file = "virtualenv-20.23.0-py3-none-any.whl", hash = "sha256:6abec7670e5802a528357fdc75b26b9f57d5d92f29c5462ba0fbe45feacc685e"}, - {file = "virtualenv-20.23.0.tar.gz", hash = "sha256:a85caa554ced0c0afbd0d638e7e2d7b5f92d23478d05d17a76daeac8f279f924"}, + {file = "virtualenv-20.16.2-py2.py3-none-any.whl", hash = "sha256:635b272a8e2f77cb051946f46c60a54ace3cb5e25568228bd6b57fc70eca9ff3"}, + {file = "virtualenv-20.16.2.tar.gz", hash = "sha256:0ef5be6d07181946891f5abc8047fda8bc2f0b4b9bf222c64e6e8963baee76db"}, ] [package.dependencies] -distlib = ">=0.3.6,<1" -filelock = ">=3.11,<4" -importlib-metadata = {version = ">=6.4.1", markers = "python_version < \"3.8\""} -platformdirs = ">=3.2,<4" +distlib = ">=0.3.1,<1" +filelock = ">=3.2,<4" +importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} +platformdirs = ">=2,<3" [package.extras] -docs = ["furo (>=2023.3.27)", "proselint (>=0.13)", "sphinx (>=6.1.3)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=22.12)"] -test = ["covdefaults (>=2.3)", "coverage (>=7.2.3)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.3.1)", "pytest-env (>=0.8.1)", "pytest-freezegun (>=0.4.2)", "pytest-mock (>=3.10)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=67.7.1)", "time-machine (>=2.9)"] +docs = ["proselint (>=0.10.2)", "sphinx (>=3)", "sphinx-argparse (>=0.2.5)", "sphinx-rtd-theme (>=0.4.3)", "towncrier (>=21.3)"] +testing = ["coverage (>=4)", "coverage-enable-subprocess (>=1)", "flaky (>=3)", "packaging (>=20.0)", "pytest (>=4)", "pytest-env (>=0.6.2)", "pytest-freezegun (>=0.4.1)", "pytest-mock (>=2)", "pytest-randomly (>=1)", "pytest-timeout (>=1)"] [[package]] name = "wrapt" @@ -3131,4 +3149,4 @@ testing = ["big-O", "flake8 (<5)", "jaraco.functools", "jaraco.itertools", "more [metadata] lock-version = "2.0" python-versions = ">=3.7,<3.12" -content-hash = "a357c428fa8c73c70766bfe27c52d6ea53713a3f71dc83134a61fc2180a6104e" +content-hash = "4f853d7075adcde4e738f001311db668ed3099eaed5a51f16222de653a9e57d3" diff --git a/pyproject.toml b/pyproject.toml index 320ee8b5..a1285e35 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,7 +52,7 @@ boutiques = "^0.5.25" more-itertools = ">=8,<10" cached-property = "^1.5.2" pvandyken-deprecated = "0.0.3" -importlib_metadata = [ { version = "^6.6.0", python = "<3.8" } ] +importlib_metadata = [ { version = "==1.4", python = "<3.8" } ] # Below are non-direct dependencies (i.e. dependencies of other depenencies) # specified to ensure a version with a pre-built wheel is installed depending From b263e54ffefe8d40c46f44f6fec18503ece022b2 Mon Sep 17 00:00:00 2001 From: Tristan Kuehn Date: Wed, 21 Jun 2023 11:55:03 -0400 Subject: [PATCH 7/9] Make tested snakebids version more robust --- snakebids/tests/test_app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/snakebids/tests/test_app.py b/snakebids/tests/test_app.py index 970899b6..022810aa 100644 --- a/snakebids/tests/test_app.py +++ b/snakebids/tests/test_app.py @@ -149,7 +149,7 @@ def test_runs_in_correct_mode( "snakefile": Path("Snakefile"), "output_dir": outputdir.resolve(), "snakemake_version": metadata.version("snakemake"), - "snakebids_version": "0.0.0", # poetry-dynamic-versioning + "snakebids_version": metadata.version("snakebids"), "app_version": "unknown", # not installing a snakebids app here } ) From d19c80fba5e82cc48abad6a6935ecbfd717b0428 Mon Sep 17 00:00:00 2001 From: Tristan Kuehn Date: Wed, 21 Jun 2023 12:01:45 -0400 Subject: [PATCH 8/9] Add draft warning if version can't be found --- snakebids/app.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/snakebids/app.py b/snakebids/app.py index 686acca9..51882048 100644 --- a/snakebids/app.py +++ b/snakebids/app.py @@ -86,6 +86,14 @@ def _get_app_version(self: SnakeBidsApp) -> str | None: try: return metadata.version(self.snakemake_dir.name) except metadata.PackageNotFoundError: + logger.warning( + "This Snakebids app's version could not be found. This will not generally " + "affect the app's functioning, but its version will be recorded as " + '"unknown" in the output config file. If you\'ve installed the app into ' + "your python environment, this is likely caused by the app maintainer's " + "version implementation being non-standard, and only the app maintainer " + "could correct the versioning. In that case, this message can be ignored." + ) return None From f08dee1be50c66f34fb8116e950b0b8df79bd26b Mon Sep 17 00:00:00 2001 From: Tristan Kuehn Date: Tue, 8 Aug 2023 10:21:14 -0400 Subject: [PATCH 9/9] Make version not found warning terser --- snakebids/app.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/snakebids/app.py b/snakebids/app.py index 3cefc565..eaffc8e4 100644 --- a/snakebids/app.py +++ b/snakebids/app.py @@ -89,12 +89,8 @@ def _get_app_version(self: SnakeBidsApp) -> str | None: return metadata.version(self.snakemake_dir.name) except metadata.PackageNotFoundError: logger.warning( - "This Snakebids app's version could not be found. This will not generally " - "affect the app's functioning, but its version will be recorded as " - '"unknown" in the output config file. If you\'ve installed the app into ' - "your python environment, this is likely caused by the app maintainer's " - "version implementation being non-standard, and only the app maintainer " - "could correct the versioning. In that case, this message can be ignored." + "App version not found; will be recorded in output as 'unknown'. " + "If this is unexpected, please contact the app maintainer." ) return None