Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow custom snakebids version in app template #376

Merged
merged 4 commits into from
Feb 20, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions containers/test-template/test-template.sh
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,9 @@ case "$method" in
"setuptools" )
python -m venv .venv
.venv/bin/python -m pip install .
if [ -d /src ]; then
.venv/bin/python -m pip install /src
fi
# if [ -d /src ]; then
# .venv/bin/python -m pip install /src
# fi
PATH=".venv/bin:$PATH" eval "$script"
;;
"poetry" )
Expand Down
49 changes: 48 additions & 1 deletion snakebids/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import snakebids
from snakebids.app import SnakeBidsApp
from snakebids.cli import add_dynamic_args
from snakebids.utils.utils import text_fold


def create_app(args: argparse.Namespace) -> None:
Expand All @@ -31,6 +32,39 @@
file=sys.stderr,
)
sys.exit(1)

data = {"app_full_name": output.name}

Check warning on line 36 in snakebids/admin.py

View check run for this annotation

Codecov / codecov/patch

snakebids/admin.py#L36

Added line #L36 was not covered by tests

if args.snakebids_version is not None:
version = args.snakebids_version
if ("@" not in version and ";" in version) or (

Check warning on line 40 in snakebids/admin.py

View check run for this annotation

Codecov / codecov/patch

snakebids/admin.py#L38-L40

Added lines #L38 - L40 were not covered by tests
"@" in version and " ;" in version
):
print(

Check warning on line 43 in snakebids/admin.py

View check run for this annotation

Codecov / codecov/patch

snakebids/admin.py#L43

Added line #L43 was not covered by tests
f"{Fore.RED}Snakebids version may not specify markers{Style.RESET_ALL}",
file=sys.stderr,
)
sys.exit(1)
if (

Check warning on line 48 in snakebids/admin.py

View check run for this annotation

Codecov / codecov/patch

snakebids/admin.py#L47-L48

Added lines #L47 - L48 were not covered by tests
"@" in version
and version[1:].lstrip().startswith("git+")
and "@" in version[1:]
):
print(

Check warning on line 53 in snakebids/admin.py

View check run for this annotation

Codecov / codecov/patch

snakebids/admin.py#L53

Added line #L53 was not covered by tests
f"{Fore.RED}Credentials and rev specifiers in git requirement "
f"specifications are not supported{Style.RESET_ALL}",
file=sys.stderr,
)
sys.exit(1)
if version.strip().startswith("["):
print(

Check warning on line 60 in snakebids/admin.py

View check run for this annotation

Codecov / codecov/patch

snakebids/admin.py#L58-L60

Added lines #L58 - L60 were not covered by tests
f"{Fore.RED}Snakebids version may not specify extras{Style.RESET_ALL}",
file=sys.stderr,
)
sys.exit(1)

Check warning on line 64 in snakebids/admin.py

View check run for this annotation

Codecov / codecov/patch

snakebids/admin.py#L64

Added line #L64 was not covered by tests

data["snakebids_version"] = args.snakebids_version

Check warning on line 66 in snakebids/admin.py

View check run for this annotation

Codecov / codecov/patch

snakebids/admin.py#L66

Added line #L66 was not covered by tests

print(
f"Creating Snakebids app at {Fore.GREEN}{output}{Fore.RESET}", file=sys.stderr
)
Expand All @@ -39,7 +73,7 @@
copier.run_copy(
str(Path(itx.first(snakebids.__path__), "project_template")),
output,
data={"app_full_name": output.name},
data=data,
unsafe=True,
)
except KeyboardInterrupt:
Expand All @@ -64,6 +98,19 @@

parser_create = subparsers.add_parser("create", help="Create a new Snakebids app.")
parser_create.add_argument("output_dir", nargs="?", default=".")
parser_create.add_argument(

Check warning on line 101 in snakebids/admin.py

View check run for this annotation

Codecov / codecov/patch

snakebids/admin.py#L101

Added line #L101 was not covered by tests
"--snakebids-version",
default=None,
metavar="VERSION_SPECIFIER",
help=text_fold(
"""
Specify snakebids version requirement. Supports either a valid version
specifier (e.g. `>=x.x.x`, `==a.b.c`) or a url prepended with `@` (e.g. `@
https://...`). Paths can be specified with `@ file:///absolute/path/...`.
Markers and extras may not be specified.
"""
),
)
parser_create.set_defaults(func=create_app)

parser_boutiques = subparsers.add_parser(
Expand Down
10 changes: 2 additions & 8 deletions snakebids/jinja2_ext/__init__.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,10 @@
# type: ignore
__submodules__ = []

# <AUTOGEN_INIT>
import lazy_loader

__getattr__, __dir__, __all__ = lazy_loader.attach_stub(__name__, __file__)

__all__ = [
"ColoramaExtension",
"GitConfigExtension",
"SnakebidsVersionExtension",
"TomlEncodeExtension",
"executable",
"toml_string",
]
__all__ = []
# </AUTOGEN_INIT>
24 changes: 1 addition & 23 deletions snakebids/jinja2_ext/__init__.pyi
Original file line number Diff line number Diff line change
@@ -1,23 +1 @@
from .colorama import (
ColoramaExtension,
)
from .snakebids_version import (
SnakebidsVersionExtension,
)
from .toml_encode import (
TomlEncodeExtension,
toml_string,
)
from .vcs import (
GitConfigExtension,
executable,
)

__all__ = [
"ColoramaExtension",
"GitConfigExtension",
"SnakebidsVersionExtension",
"TomlEncodeExtension",
"executable",
"toml_string",
]
__all__ = []
27 changes: 27 additions & 0 deletions snakebids/jinja2_ext/format_dep_spec.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import jinja2
from jinja2.ext import Extension

from snakebids.jinja2_ext.toml_encode import toml_string


def format_poetry(item: str):
"""Format a pip style dependency specification for a poetry pyproject.toml.

Only supports urls (prefixed with @) and version specifiers. No markers or extras.
The package name should already be stripped.
"""
if item.strip().startswith("@"):
url = item[1:].strip()
if url.startswith("git+"):
return f"{{ git = {toml_string(url[4:])} }}"
if url.startswith("file://"):
return f"{{ path = {toml_string(url[7:])} }}"
return f"{{ url = {toml_string(url)} }}"
return toml_string(item)


class FormatDepSpec(Extension):
"""Enable the toml_string filter, which encodes provided value as toml."""

def __init__(self, env: jinja2.Environment):
env.filters["format_poetry"] = format_poetry # type: ignore
2 changes: 1 addition & 1 deletion snakebids/jinja2_ext/snakebids_version.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ class SnakebidsVersionExtension(Extension):
"""

def __init__(self, env: jinja2.Environment):
env.globals["snakebids_version"] = self._lookup_version() # type: ignore
env.globals["curr_snakebids_version"] = self._lookup_version() # type: ignore
super().__init__(env)

def _lookup_version(self):
Expand Down
10 changes: 10 additions & 0 deletions snakebids/jinja2_ext/toml_encode.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,18 @@ def toml_string(item: str):
return json.dumps(item, ensure_ascii=False).replace("\x7F", "\\u007f")


def toml_encode(item: str):
"""Encode string for inclusion in toml without wrapping in quotes.

Technically encodes as json, a (mostly) strict subset of toml, with some encoding
fixes
"""
return toml_string(item)[1:-1]


class TomlEncodeExtension(Extension):
"""Enable the toml_string filter, which encodes provided value as toml."""

def __init__(self, env: jinja2.Environment):
env.filters["toml_string"] = toml_string # type: ignore
env.filters["toml_encode"] = toml_encode # type: ignore
7 changes: 6 additions & 1 deletion snakebids/project_template/copier.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,11 @@ python_version:
when: false

snakebids_version:
default: "{{ snakebids_version or '0.9.3' }}"
default: ">={{ curr_snakebids_version or '0.9.3' }}"
when: false

snakebids_is_direct_reference:
default: "{{ snakebids_version.strip().startswith('@') }}"
when: false

snakemake_version:
Expand Down Expand Up @@ -128,3 +132,4 @@ _jinja_extensions:
- snakebids.jinja2_ext.colorama.ColoramaExtension
- snakebids.jinja2_ext.toml_encode.TomlEncodeExtension
- snakebids.jinja2_ext.snakebids_version.SnakebidsVersionExtension
- snakebids.jinja2_ext.format_dep_spec.FormatDepSpec
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ classifiers = [
requires-python = "{{ python_version }}"
dependencies = [
"snakemake >= {{ snakemake_version }},<8",
"snakebids >= {{ snakebids_version }}",
"snakebids {{ snakebids_version | toml_encode }}",
{#- newer pulps are incompatible with old snakemakes, and we need to support
old snakemakes for python versions <3.11. So cap pulp to the last working
version
Expand All @@ -51,3 +51,9 @@ build-backend = "hatchling.build"
requires = ["setuptools"]
build-backend = "setuptools.build_meta"
{%- endif %}

{%- if snakebids_is_direct_reference == "True" and build_system == "hatch" %}

[tool.hatch.metadata]
allow-direct-references = true
{%- endif %}
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ classifiers = [
[tool.poetry.dependencies]
python = "{{ python_version }}"
snakemake = ">={{ snakemake_version }},<8"
snakebids = ">={{ snakebids_version }}"
snakebids = {{ snakebids_version | format_poetry }}
{#- newer pulps are incompatible with old snakemakes, and we need to support
old snakemakes for python versions <3.11. So cap pulp to the last working
version
Expand Down
116 changes: 103 additions & 13 deletions snakebids/tests/test_admin.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
from __future__ import annotations

import re
import sys
from argparse import ArgumentParser, Namespace
from pathlib import Path

import more_itertools as itx
import pytest
from hypothesis import given
from hypothesis import strategies as st
Expand All @@ -18,21 +21,19 @@ def parser():
return gen_parser()


class TestAdminCli:
def test_fails_if_no_subcommand(
self, parser: ArgumentParser, mocker: MockerFixture
):
mocker.patch.object(sys, "argv", ["snakebids"])
with pytest.raises(SystemExit):
parser.parse_args()
def test_fails_if_no_subcommand(parser: ArgumentParser, mocker: MockerFixture):
mocker.patch.object(sys, "argv", ["snakebids"])
with pytest.raises(SystemExit):
parser.parse_args()


def test_fails_if_invalid_subcommand(parser: ArgumentParser, mocker: MockerFixture):
mocker.patch.object(sys, "argv", ["snakebids", "dummy"])
with pytest.raises(SystemExit):
parser.parse_args()

def test_fails_if_invalid_subcommand(
self, parser: ArgumentParser, mocker: MockerFixture
):
mocker.patch.object(sys, "argv", ["snakebids", "dummy"])
with pytest.raises(SystemExit):
parser.parse_args()

class TestCreateCommand:
@given(
name=st.text()
.filter(lambda s: not re.match(r"^[a-zA-Z_][a-zA-Z_0-9]*$", s))
Expand Down Expand Up @@ -79,6 +80,95 @@ def test_create_fails_missing_parent_dir(
assert "does not exist" in capture.err
assert str(path.parent) in capture.err

def test_create_fails_when_markers_in_snakebids_version(
self,
parser: ArgumentParser,
mocker: MockerFixture,
capsys: pytest.CaptureFixture[str],
):
mocker.patch.object(
sys,
"argv",
["snakebids", "create", "name", "--snakebids-version", "... ; markers"],
)
args = parser.parse_args()
with pytest.raises(SystemExit):
args.func(args)
capture = capsys.readouterr()
assert "may not specify markers" in capture.err

def test_create_fails_when_get_spec_has_at_sign(
self,
parser: ArgumentParser,
mocker: MockerFixture,
capsys: pytest.CaptureFixture[str],
):
mocker.patch.object(
sys,
"argv",
["snakebids", "create", "name", "--snakebids-version", "@ git+...@...@..."],
)
args = parser.parse_args()
with pytest.raises(SystemExit):
args.func(args)
capture = capsys.readouterr()
assert "in git requirement" in capture.err

def test_create_fails_when_snakebids_version_specifies_extras(
self,
parser: ArgumentParser,
mocker: MockerFixture,
capsys: pytest.CaptureFixture[str],
):
mocker.patch.object(
sys,
"argv",
["snakebids", "create", "name", "--snakebids-version", "[...] ..."],
)
args = parser.parse_args()
with pytest.raises(SystemExit):
args.func(args)
capture = capsys.readouterr()
assert "may not specify extras" in capture.err

@given(
name=st.from_regex(r"^[a-zA-Z_][a-zA-Z_0-9]*$"),
version=st.text(st.characters(blacklist_characters=["@", ";", "["]))
| st.none(),
)
@allow_function_scoped
def test_create_calls_copier_correctly(
self,
parser: ArgumentParser,
mocker: MockerFixture,
name: str,
version: str | None,
tmp_path: Path,
capsys: pytest.CaptureFixture[str],
):
import snakebids
from snakebids.admin import copier

run_copy = mocker.patch.object(copier, "run_copy")
path = tmp_path / name
args = ["snakebids", "create", str(path)]
if version is not None:
args.extend(["--snakebids-version", version])
mocker.patch.object(sys, "argv", args)
args = parser.parse_args()
args.func(args)
capture = capsys.readouterr()
assert "Creating Snakebids app at" in capture.err
run_copy.assert_called_once_with(
str(Path(itx.first(snakebids.__path__), "project_template")),
path,
data={
"app_full_name": name,
**({"snakebids_version": version} if version is not None else {}),
},
unsafe=True,
)

def test_create_succeeds(self, parser: ArgumentParser, mocker: MockerFixture):
mocker.patch.object(sys, "argv", ["snakebids", "create"])
assert isinstance(parser.parse_args(), Namespace)
Expand Down
Loading
Loading