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
  • Loading branch information
pvandyken committed Sep 15, 2023
1 parent 06f4961 commit bd6f78f
Show file tree
Hide file tree
Showing 34 changed files with 681 additions and 209 deletions.
22 changes: 22 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,27 @@ jobs:
- name: Install dependencies
if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true'
run: poetry install --no-interaction --no-root --no-ansi

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

- 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
platforms: linux/amd64
#----------------------------------------------
# install your root project, if required
#----------------------------------------------
Expand Down
18 changes: 18 additions & 0 deletions containers/test-template/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
FROM python:3.11.5

# Install and uninstall snakebids to cache it and it's dependences
RUN 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
222 changes: 168 additions & 54 deletions poetry.lock

Large diffs are not rendered by default.

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

[tool.poetry.group.dev.dependencies]
black = "^23.1.0"
Expand Down Expand Up @@ -93,15 +94,22 @@ 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"
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.build-container]
args = [{ name = "container_id", positional = true, required = true }]
cmd = """
docker build 'containers/${container_id}' \
--tag 'snakebids/${container_id}:dev'
"""

[tool.isort]
profile = "black"
multi_line_output = 3
Expand Down
28 changes: 24 additions & 4 deletions snakebids/admin.py
Original file line number Diff line number Diff line change
@@ -1,21 +1,41 @@
"""Script to generate a Snakebids project."""

import argparse
import sys
from pathlib import Path

import copier # type: ignore
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)
print(
f"Creating Snakebids app at {Fore.GREEN}{output}{Fore.RESET}", file=sys.stderr
)
print(file=sys.stderr)
try:
copier.run_copy( # type: ignore
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:
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
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
File renamed without changes.
87 changes: 84 additions & 3 deletions snakebids/project_template/copier.yaml
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
full_name:
type: str
help: What is your name?
placeholder: " optional"
default: '{% gitconfig "user.name" %}'

email:
type: str
help: What is your email?
default: '{% gitconfig "user.email" %}'

github:
type: str
Expand All @@ -14,7 +15,6 @@ github:
app_full_name:
type: str
help: What is the name of your app?
default: "{{ dst_path }}"
validator: '{% if not app_full_name %}Required{% endif %}'

app_description:
Expand All @@ -24,6 +24,25 @@ app_description:
app_version:
default: "0.1.0"
help: Starting version number for your app
validator: '{% if not app_version %}Required{% endif %}'

build_system:
help: >
What build system would you like to use? If you don't know what this means,
choose setuptools
choices:
- setuptools
- poetry
- hatch
- flit

# Flit and setuptools requires specifying license via Trove classifiers; too
# complicated to get via interactive prompt
license:
default: >-
{% if build_system != "flit" and build_system != "setuptools" %}MIT{% endif %}
help: Usage license for your app
when: "{{ build_system != 'flit' and build_system != 'setuptools' }}"

create_doc_template:
type: bool
Expand All @@ -34,12 +53,74 @@ bids_version:
when: false

snakebids_version:
default: "0.0.0"
default: "0.9.2"
when: false

snakemake_version:
default: "7.20"
when: false

name_slug:
default: "{{ app_full_name|lower|replace(' ', '_')|replace('-', '_') }}"
when: false

test_run_cmd:
default: "{{ name_slug }} tests/data tests/result participant -c1"
when: false

_message_after_copy: >
Snakebids project successfully created in
{{ Fore.GREEN }}{{ _copier_conf.dst_path }}{{ Fore.RESET }}
Just a few steps before coding:
{{ Fore.BLUE }}1.{{ Fore.RESET }} Change into the project directory:
$ cd {{ _copier_conf.dst_path }}
{% if build_system == "poetry" -%}
{{ Fore.BLUE }}2.{{ Fore.RESET }} Install dependencies.
$ poetry install
{%- else -%}
{{ Fore.BLUE + "2." + Fore.RESET + """ Install dependencies. This may vary
depending on your tooling. The following are examples (the first example
should work on any python environment; the others require 3rd party tools):
""" | wordwrap(80)}}
* {{Fore.YELLOW}}Virtual Environment and setuptools:{{Fore.RESET}}
$ python -m venv .venv
$ source .venv/bin/activate
$ pip install .
* {{Fore.YELLOW}}Hatch:{{Fore.RESET}}
$ hatch env create
* {{Fore.YELLOW}}PDM:{{Fore.RESET}}
$ pdm install
{%- endif %}
{{ Fore.BLUE }}3.{{Fore.RESET }} Perform a test run.
{% if build_system == "poetry" %}
$ poetry run {{ test_run_cmd }}
{%- else %}
* {{Fore.YELLOW}}Virtual Environment and setuptools:{{Fore.RESET}}
$ {{ test_run_cmd }}
* {{Fore.YELLOW}}Hatch:{{Fore.RESET}}
$ hatch env run -- {{ test_run_cmd }}
* {{Fore.YELLOW}}PDM:{{Fore.RESET}}
$ pdm run {{ test_run_cmd }}
{%- endif %}
_jinja_extensions:
- jinja2_time.TimeExtension
- snakebids.jinja2_ext.vcs.GitConfigExtension
- snakebids.jinja2_ext.colorama.ColoramaExtension
Loading

0 comments on commit bd6f78f

Please sign in to comment.