diff --git a/poetry.lock b/poetry.lock index a8669b8d..317bf77f 100644 --- a/poetry.lock +++ b/poetry.lock @@ -365,14 +365,14 @@ files = [ [[package]] name = "cookiecutter" -version = "2.2.3" +version = "2.3.0" description = "A command-line utility that creates projects from project templates, e.g. creating a Python package project from a Python package project template." category = "main" optional = false python-versions = ">=3.7" files = [ - {file = "cookiecutter-2.2.3-py3-none-any.whl", hash = "sha256:17ad6751aef0a39d004c5ecacbd18176de6e83505343073fd7e48b60bdac5254"}, - {file = "cookiecutter-2.2.3.tar.gz", hash = "sha256:d56f18c0c01c09804450b501ac43e8f6104cfa7cdd93610359c68b1ba9fd84d2"}, + {file = "cookiecutter-2.3.0-py3-none-any.whl", hash = "sha256:7e87944757c6e9f8729cf89a4139b6a35ab4d6dcbc6ae3e7d6360d44ad3ad383"}, + {file = "cookiecutter-2.3.0.tar.gz", hash = "sha256:942a794981747f6d7f439d6e49d39dc91a9a641283614160c93c474c72c29621"}, ] [package.dependencies] @@ -383,6 +383,7 @@ Jinja2 = ">=2.7,<4.0.0" python-slugify = ">=4.0.0" pyyaml = ">=5.3.1" requests = ">=2.23.0" +rich = "*" [[package]] name = "datrie" @@ -786,24 +787,22 @@ files = [ [[package]] name = "importlib-metadata" -version = "6.7.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.7.0-py3-none-any.whl", hash = "sha256:cb52082e659e97afc5dac71e79de97d8681de3aa07ff18578330904a9d18e5b5"}, - {file = "importlib_metadata-6.7.0.tar.gz", hash = "sha256:1aaf550d4f73e5d6783e7acb77aec43d49da8017410afae93822cc9cca98c4d4"}, + {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 = ["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-mypy (>=0.9.1)", "pytest-perf (>=0.9.2)", "pytest-ruff"] +docs = ["rst.linker", "sphinx"] +testing = ["importlib-resources", "packaging"] [[package]] name = "importlib-resources" @@ -1124,6 +1123,32 @@ traitlets = ">=5.3" docs = ["myst-parser", "sphinx-autodoc-typehints", "sphinxcontrib-github-alt", "sphinxcontrib-spelling", "traitlets"] test = ["ipykernel", "pre-commit", "pytest", "pytest-cov", "pytest-timeout"] +[[package]] +name = "markdown-it-py" +version = "2.2.0" +description = "Python port of markdown-it. Markdown parsing, done right!" +category = "main" +optional = false +python-versions = ">=3.7" +files = [ + {file = "markdown-it-py-2.2.0.tar.gz", hash = "sha256:7c9a5e412688bc771c67432cbfebcdd686c93ce6484913dccf06cb5a0bea35a1"}, + {file = "markdown_it_py-2.2.0-py3-none-any.whl", hash = "sha256:5a35f8d1870171d9acc47b99612dc146129b631baf04970128b568f190d0cc30"}, +] + +[package.dependencies] +mdurl = ">=0.1,<1.0" +typing_extensions = {version = ">=3.7.4", markers = "python_version < \"3.8\""} + +[package.extras] +benchmarking = ["psutil", "pytest", "pytest-benchmark"] +code-style = ["pre-commit (>=3.0,<4.0)"] +compare = ["commonmark (>=0.9,<1.0)", "markdown (>=3.4,<4.0)", "mistletoe (>=1.0,<2.0)", "mistune (>=2.0,<3.0)", "panflute (>=2.3,<3.0)"] +linkify = ["linkify-it-py (>=1,<3)"] +plugins = ["mdit-py-plugins"] +profiling = ["gprof2dot"] +rtd = ["attrs", "myst-parser", "pyyaml", "sphinx", "sphinx-copybutton", "sphinx-design", "sphinx_book_theme"] +testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"] + [[package]] name = "markupsafe" version = "2.1.3" @@ -1184,6 +1209,18 @@ files = [ {file = "MarkupSafe-2.1.3.tar.gz", hash = "sha256:af598ed32d6ae86f1b747b82783958b1a4ab8f617b06fe68795c7f026abbdcad"}, ] +[[package]] +name = "mdurl" +version = "0.1.2" +description = "Markdown URL utilities" +category = "main" +optional = false +python-versions = ">=3.7" +files = [ + {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"}, + {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, +] + [[package]] name = "mkinit" version = "1.0.0" @@ -1251,26 +1288,24 @@ files = [ [[package]] name = "nbformat" -version = "5.8.0" +version = "5.6.0" description = "The Jupyter Notebook format" category = "main" optional = false python-versions = ">=3.7" files = [ - {file = "nbformat-5.8.0-py3-none-any.whl", hash = "sha256:d910082bd3e0bffcf07eabf3683ed7dda0727a326c446eeb2922abe102e65162"}, - {file = "nbformat-5.8.0.tar.gz", hash = "sha256:46dac64c781f1c34dfd8acba16547024110348f9fc7eab0f31981c2a3dc48d1f"}, + {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 = "*" -importlib-metadata = {version = ">=3.6", markers = "python_version < \"3.8\""} jsonschema = ">=2.6" jupyter-core = "*" traitlets = ">=5.1" [package.extras] -docs = ["myst-parser", "pydata-sphinx-theme", "sphinx", "sphinxcontrib-github-alt", "sphinxcontrib-spelling"] -test = ["pep440", "pre-commit", "pytest", "testpath"] +test = ["check-manifest", "pep440", "pre-commit", "pytest", "testpath"] [[package]] name = "nbformat" @@ -1754,22 +1789,22 @@ files = [ [[package]] name = "platformdirs" -version = "3.10.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.10.0-py3-none-any.whl", hash = "sha256:d7c24979f292f916dc9cbf8648319032f551ea8c49a4c9bf2fb556a02070ec1d"}, - {file = "platformdirs-3.10.0.tar.gz", hash = "sha256:b45696dab2d7cc691a3226759c0d3b00c47c8b6e293d96f6436f733303f77f6d"}, + {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.7.1", markers = "python_version < \"3.8\""} +typing-extensions = {version = ">=4.4", markers = "python_version < \"3.8\""} [package.extras] -docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.1)", "sphinx-autodoc-typehints (>=1.24)"] -test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.4)", "pytest-cov (>=4.1)", "pytest-mock (>=3.11.1)"] +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" @@ -2040,6 +2075,21 @@ files = [ {file = "pyfakefs-5.2.3.tar.gz", hash = "sha256:f4d677645e44c56fd47d579c7586ff0daef1546d3100df2af50969f794368fc6"}, ] +[[package]] +name = "pygments" +version = "2.16.1" +description = "Pygments is a syntax highlighting package written in Python." +category = "main" +optional = false +python-versions = ">=3.7" +files = [ + {file = "Pygments-2.16.1-py3-none-any.whl", hash = "sha256:13fc09fa63bc8d8671a6d247e1eb303c4b343eaee81d861f3404db2935653692"}, + {file = "Pygments-2.16.1.tar.gz", hash = "sha256:1daff0494820c69bc8941e407aa20f577374ee88364ee10a98fdbe0aece96e29"}, +] + +[package.extras] +plugins = ["importlib-metadata"] + [[package]] name = "pyparsing" version = "3.1.1" @@ -2351,6 +2401,26 @@ files = [ {file = "reretry-0.11.8.tar.gz", hash = "sha256:f2791fcebe512ea2f1d153a2874778523a8064860b591cd90afc21a8bed432e3"}, ] +[[package]] +name = "rich" +version = "13.5.2" +description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" +category = "main" +optional = false +python-versions = ">=3.7.0" +files = [ + {file = "rich-13.5.2-py3-none-any.whl", hash = "sha256:146a90b3b6b47cac4a73c12866a499e9817426423f57c5a66949c086191a8808"}, + {file = "rich-13.5.2.tar.gz", hash = "sha256:fb9d6c0a0f643c99eed3875b5377a184132ba9be4d61516a55273d3554d75a39"}, +] + +[package.dependencies] +markdown-it-py = ">=2.2.0" +pygments = ">=2.13.0,<3.0.0" +typing-extensions = {version = ">=4.0.0,<5.0", markers = "python_version < \"3.9\""} + +[package.extras] +jupyter = ["ipywidgets (>=7.5.1,<9)"] + [[package]] name = "ruff" version = "0.0.274" @@ -2619,14 +2689,14 @@ files = [ [[package]] name = "snakemake" -version = "7.31.1" +version = "7.32.2" description = "Workflow management system to create reproducible and scalable data analyses" category = "main" optional = false python-versions = ">=3.7" files = [ - {file = "snakemake-7.31.1-py3-none-any.whl", hash = "sha256:919c1494b3f2caa8100de4282cddffac11fb26e49c41b1655b8eb70ffafa5732"}, - {file = "snakemake-7.31.1.tar.gz", hash = "sha256:6fadcc9a051737aa187dccf437879b3b83ddc917fff9bd7d400e056cf17a1788"}, + {file = "snakemake-7.32.2-py3-none-any.whl", hash = "sha256:43654368991040b0e796417b3720668da8d955386ae8fe20b451b7d35e0d3ee1"}, + {file = "snakemake-7.32.2.tar.gz", hash = "sha256:902dca1e50d8ea16be3a2ecf314259e3157e9fd619c8dcd7cd166aeb12336718"}, ] [package.dependencies] @@ -3053,25 +3123,25 @@ zstd = ["zstandard (>=0.18.0)"] [[package]] name = "virtualenv" -version = "20.24.2" +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.24.2-py3-none-any.whl", hash = "sha256:43a3052be36080548bdee0b42919c88072037d50d56c28bd3f853cbe92b953ff"}, - {file = "virtualenv-20.24.2.tar.gz", hash = "sha256:fd8a78f46f6b99a67b7ec5cf73f92357891a7b3a40fd97637c27f854aae3b9e0"}, + {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.7,<1" -filelock = ">=3.12.2,<4" -importlib-metadata = {version = ">=6.6", markers = "python_version < \"3.8\""} -platformdirs = ">=3.9.1,<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.5.20)", "proselint (>=0.13)", "sphinx (>=7.0.1)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"] -test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8)", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10)"] +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" @@ -3194,4 +3264,4 @@ testing = ["big-O", "flake8 (<5)", "jaraco.functools", "jaraco.itertools", "more [metadata] lock-version = "2.0" python-versions = ">=3.7,<3.12" -content-hash = "191edad79097d6350cf4e4415c05710ce275b3c01cffea2384296c58fc414399" +content-hash = "71f77212be4cde6fc3e4a22c203a7151ab30160a651b64d353e7d2b428a011c2" diff --git a/pyproject.toml b/pyproject.toml index b35d4afb..b7e6ed04 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 = "==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 diff --git a/snakebids/app.py b/snakebids/app.py index 93804a45..eaffc8e4 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, @@ -70,6 +75,26 @@ 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: + logger.warning( + "App version not found; will be recorded in output as 'unknown'. " + "If this is unexpected, please contact the app maintainer." + ) + return None + + @attr.define(slots=False) class SnakeBidsApp: """Snakebids app with config and arguments. @@ -130,6 +155,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: @@ -207,7 +233,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.version or "unknown", + ), force_overwrite=True, ) diff --git a/snakebids/tests/test_app.py b/snakebids/tests/test_app.py index 66ed3e0b..31922967 100644 --- a/snakebids/tests/test_app.py +++ b/snakebids/tests/test_app.py @@ -21,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): @@ -178,6 +183,9 @@ def test_runs_in_correct_mode( "pybidsdb_reset": True, "snakefile": Path("Snakefile"), "output_dir": outputdir.resolve(), + "snakemake_version": metadata.version("snakemake"), + "snakebids_version": metadata.version("snakebids"), + "app_version": "unknown", # not installing a snakebids app here } ) if root == "app" and not tail: @@ -297,6 +305,24 @@ def plugin(my_app: SnakeBidsApp): assert app.foo == "bar" # type: ignore + def test_get_app_version_no_package(self, app: SnakeBidsApp): + 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") + 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") + class TestGenBoutiques: def test_boutiques_descriptor(self, tmp_path: Path, app: SnakeBidsApp): 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: ...