Skip to content

Commit

Permalink
Overhaul of snakebids create
Browse files Browse the repository at this point in the history
Use copier instead of cookiecutter
- Better feature set
- Better documentation
- More polished (e.g. typed codebase)

Change starting workflow into a "welcome" workflow
- Pregenerated empty data file based on the tutorial
- workflow reads in the datafiles using generate_inputs and prints a
  welcome message

Allow choice of build systems. App will be immediately installable and
publishable

End-to-end testing of app creation, dependency installation, and initial
run

Validation of email address and app_name. Make app_name and version
mandatory fields
  • Loading branch information
pvandyken committed Sep 19, 2023
1 parent 285fd12 commit 64582e0
Show file tree
Hide file tree
Showing 45 changed files with 1,432 additions and 480 deletions.
35 changes: 35 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ jobs:
- name: Install library
run: poetry install --no-interaction --no-ansi


#----------------------------------------------
# run python style checks
#----------------------------------------------
Expand Down Expand Up @@ -113,6 +114,40 @@ jobs:
- name: Install dependencies
if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true'
run: poetry install --no-interaction --no-root --no-ansi


#---------------------------------------------
# Build docker container needed for test
#---------------------------------------------

- name: Set up QEMU
uses: docker/setup-qemu-action@v3

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3

- name: Cache Template Testing Containers
uses: actions/cache@v3
with:
path: container-test-template-cache
key: ${{ runner.os }}-test-template-cache-${{ hashFiles('containers/test-template/**') }}-${{ matrix.python-version }}

- name: Inject container-test-template-cache into docker
uses: reproducible-containers/[email protected]
with:
cache-source: container-test-template-cache
- name: Build and push
uses: docker/build-push-action@v5
with:
context: containers/test-template
cache-from: type=gha
cache-to: type=gha,mode=max
push: false
load: true
tags: snakebids/test-template:${{ matrix.python-version }}
platforms: linux/amd64
build-args: |
PYTHON_VERSION=${{ matrix.python-version }}
#----------------------------------------------
# install your root project, if required
#----------------------------------------------
Expand Down
21 changes: 21 additions & 0 deletions containers/test-template/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
ARG PYTHON_VERSION=3.11
FROM python:${PYTHON_VERSION}-slim

# Install and uninstall snakebids to cache it and it's dependences
RUN apt-get update && apt-get install -y gcc && \
rm -rf /var/lib/apt/lists/* && \
python -m pip install pipx && \
pipx install poetry && \
pipx install hatch && \
pipx install pdm && \
mkdir prebuild && \
cd prebuild && \
pip wheel snakebids && \
cd .. && \
rm -rf prebuild

COPY ./test-template.sh /run/test-template.sh
ENV PATH="/root/.local/bin:$PATH"

WORKDIR /work
ENTRYPOINT [ "/run/test-template.sh" ]
32 changes: 32 additions & 0 deletions containers/test-template/test-template.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
#!/bin/sh

set -eu

method="$1"
script_name="$2"

cp -r /app/* /work
script="'${script_name}' tests/data tests/result participant -c1 --skip-bids-validation"
case "$method" in
"setuptools" )
python -m venv .venv
.venv/bin/python -m pip install .
PATH=".venv/bin:$PATH" eval "$script"
;;
"poetry" )
poetry install
eval "poetry run $script"
;;
"hatch" )
hatch env create
eval "hatch env run -- $script"
;;
"pdm" )
pdm install
eval "pdm run $script"
;;
* )
>&2 echo "Invalid method"
exit 1
;;
esac
725 changes: 500 additions & 225 deletions poetry.lock

Large diffs are not rendered by default.

23 changes: 20 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ scipy = [
{ version = ">=1.10.0,<=1.10.1", python = "<3.9" },
{ version = ">=1.10.0", python = ">=3.9" }
]
copier = ">=8.2.0"
jinja2-time = ">=0.2.0"

[tool.poetry.group.dev.dependencies]
black = "^23.1.0"
Expand All @@ -80,8 +82,10 @@ pyparsing = "^3.0.9"
# Version 1.1.312-1.1.315 have a false positive handling some nested function
# calls
pathvalidate = "^3.0.0"
pyright = ">=1.1.324"
# As of pyright==1.1.327 only 1.1.324 is bug free
pyright = "==1.1.324"
ruff = "^0.0.285"
tomli = "^2.0.1"

[tool.poetry.scripts]
snakebids = "snakebids.admin:main"
Expand All @@ -92,15 +96,28 @@ build-backend = "poetry_dynamic_versioning.backend"

[tool.poe.tasks]
setup = "pre-commit install"
quality = { shell = "isort snakebids && black snakebids && ruff snakebids && pyright" }
fix = { shell = "ruff --fix snakebids && isort snakebids && black snakebids"}
quality.shell = "isort snakebids && black snakebids && ruff snakebids && pyright snakebids"
fix.shell = "ruff --fix snakebids && isort snakebids && black snakebids"
test = """
pytest --doctest-modules --ignore=docs \
--ignore=snakebids/project_template --benchmark-disable
"""
mkinit = "mkinit --recursive --nomods --black -i snakebids"
benchmark = "pytest --benchmark-only --benchmark-autosave"

[tool.poe.tasks._get_version]
imports = ["platform"]
expr = "platform.python_version()"

[tool.poe.tasks.build-container]
args = [{ name = "container_id", positional = true, required = true }]
uses = { VERSION = "_get_version"}
cmd = """
docker build 'containers/${container_id}' \
--tag 'snakebids/${container_id}:${VERSION}' \
--build-arg="PYTHON_VERSION=${VERSION}"
"""

[tool.isort]
profile = "black"
multi_line_output = 3
Expand Down
39 changes: 33 additions & 6 deletions snakebids/admin.py
Original file line number Diff line number Diff line change
@@ -1,25 +1,52 @@
"""Script to generate a Snakebids project."""

import argparse
import re
import sys
from pathlib import Path

import copier
import more_itertools as itx
from cookiecutter.main import cookiecutter # type: ignore
from colorama import Fore, Style

import snakebids
from snakebids.app import SnakeBidsApp
from snakebids.cli import add_dynamic_args


def create_app(args: argparse.Namespace) -> None:
cookiecutter(
str(Path(itx.first(snakebids.__path__)) / "project_template"),
output_dir=args.output_dir,
output = Path(args.output_dir).resolve()
if not output.parent.exists():
print(
f"{Fore.RED}{Style.BRIGHT}{output.parent}{Style.RESET_ALL}{Fore.RED} does "
f"not exist{Fore.RESET}",
file=sys.stderr,
)
sys.exit(1)
if not re.match(r"^[a-zA-Z_][a-zA-Z_0-9]*$", output.name):
print(
f"{Fore.RED}Output directory name {Style.BRIGHT}{output.name}"
f"{Style.RESET_ALL}{Fore.RED} is not a valid python module name",
file=sys.stderr,
)
sys.exit(1)
print(
f"Creating Snakebids app at {Fore.GREEN}{output}{Fore.RESET}", file=sys.stderr
)
print(file=sys.stderr)
try:
copier.run_copy(
str(Path(itx.first(snakebids.__path__), "project_template")),
output,
data={"app_full_name": output.name},
unsafe=True,
)
except KeyboardInterrupt:
print(f"{Fore.RED}Aborted!{Fore.RESET}", file=sys.stderr)
sys.exit(1)


def create_descriptor(args: argparse.Namespace) -> None:
# pylint: disable=unsubscriptable-object
app = SnakeBidsApp(args.app_dir.resolve())
add_dynamic_args(app.parser, app.config["parse_args"], app.config["pybids_inputs"])
app.create_descriptor(args.out_path)
Expand Down Expand Up @@ -57,7 +84,7 @@ def gen_parser() -> argparse.ArgumentParser:


def main() -> None:
"""Invoke Cookiecutter on the Snakebids project template."""
"""Invoke snakebids cli."""

parser = gen_parser()
args = parser.parse_args()
Expand Down
2 changes: 1 addition & 1 deletion snakebids/core/datasets.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ def __repr__(self) -> str:
return f'{self.__class__.__name__}({list(self._data)}, entity="{self.entity}")'

@property
def entities(self) -> tuple[str]:
def entities(self) -> tuple[str, ...]:
"""The unique values associated with the component"""
return tuple(set(self._data))

Expand Down
Empty file.
11 changes: 11 additions & 0 deletions snakebids/jinja2_ext/colorama.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
from __future__ import annotations

import jinja2.parser
from colorama import Fore
from jinja2.ext import Extension


class ColoramaExtension(Extension):
def __init__(self, env: jinja2.Environment):
super().__init__(env)
env.globals["Fore"] = Fore # type: ignore
13 changes: 13 additions & 0 deletions snakebids/jinja2_ext/toml_encode.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import json

import jinja2
from jinja2.ext import Extension


def toml_string(item: str):
return json.dumps(item, ensure_ascii=False).replace("\x7F", "\\u007f")


class TomlEncodeExtension(Extension):
def __init__(self, env: jinja2.Environment):
env.filters["toml_string"] = toml_string # type: ignore
79 changes: 79 additions & 0 deletions snakebids/jinja2_ext/vcs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
from __future__ import annotations

import re
import subprocess
import sys
from pathlib import Path

import jinja2.parser
from jinja2 import nodes
from jinja2.ext import Extension


class GitConfigExtension(Extension):
tags = {"gitconfig"} # noqa: RUF012
_config: dict[str, str]

def __init__(self, env: jinja2.Environment) -> None:
self._config = {}

try:
config_list = subprocess.check_output(
[executable(), "config", "-l"], stderr=subprocess.STDOUT
).decode()

m = re.findall("(?ms)^([^=]+)=(.*?)$", config_list)
if m:
for group in m:
self._config[group[0]] = group[1]
except (subprocess.CalledProcessError, OSError):
pass

def get(self, key: str, default: str | None = None) -> str | None:
return self._config.get(key, default)

def __getitem__(self, item: str) -> str:
return self._config[item]

def parse(self, parser: jinja2.parser.Parser):
lineno = next(parser.stream).lineno

node = parser.parse_expression()

if not isinstance(node, nodes.Const):
raise ValueError("Argument to `gitconfig` must be a string")
call_method = self.call_method(
"get",
[node],
lineno=lineno,
)
return nodes.Output([call_method], lineno=lineno)


def executable() -> str:
_executable = None

if sys.platform == "win32":
# Finding git via where.exe
where = "%WINDIR%\\System32\\where.exe"
paths = subprocess.check_output(
[where, "git"], shell=True, encoding="oem"
).split("\n")
for path in paths:
if not path:
continue

_path = Path(path.strip())
try:
_path.relative_to(Path.cwd())
except ValueError:
_executable = str(_path)

break
else:
_executable = "git"

if _executable is None: # type: ignore
raise RuntimeError("Unable to find a valid git executable")

return _executable
2 changes: 1 addition & 1 deletion snakebids/plugins/validator.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ def __call__(self, app: SnakeBidsApp) -> None:
temp.flush()
try:
subprocess.check_call(
["bids-validator", app.config["bids_dirs"], "-c", temp.name]
["bids-validator", app.config["bids_dir"], "-c", temp.name]
)

# If successfully bids-validation performed
Expand Down
3 changes: 3 additions & 0 deletions snakebids/project_template/README.md.jinja
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# {{app_full_name}}

{{ app_description }}
12 changes: 0 additions & 12 deletions snakebids/project_template/cookiecutter.json

This file was deleted.

Loading

0 comments on commit 64582e0

Please sign in to comment.