From 186977933f35b86055f222657fa11f9dd632f09b Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Wed, 28 Oct 2026 11:00:00 +0100 Subject: [PATCH 01/11] Update documentation assets and structure for SCENE template --- docs/index.md | 2 +- docs/myst.yml | 8 ++++---- .../skeleton/jupyter-book/docs}/_static/footer.md | 0 .../jupyter-book/docs}/_static/scene-palette.json | 0 .../skeleton/jupyter-book/docs}/_static/scene.css | 0 .../skeleton/jupyter-book/docs}/images/favicon.png | Bin .../jupyter-book/docs}/images/scene-logo.png | Bin 7 files changed, 5 insertions(+), 5 deletions(-) rename {docs => src/scene_repo_template/skeleton/jupyter-book/docs}/_static/footer.md (100%) rename {docs => src/scene_repo_template/skeleton/jupyter-book/docs}/_static/scene-palette.json (100%) rename {docs => src/scene_repo_template/skeleton/jupyter-book/docs}/_static/scene.css (100%) rename {docs => src/scene_repo_template/skeleton/jupyter-book/docs}/images/favicon.png (100%) rename {docs => src/scene_repo_template/skeleton/jupyter-book/docs}/images/scene-logo.png (100%) diff --git a/docs/index.md b/docs/index.md index 0242005..96b91f1 100644 --- a/docs/index.md +++ b/docs/index.md @@ -2,7 +2,7 @@ title: SCENE Code & Data Guidelines --- -```{image} images/scene-logo.png +```{image} ../src/scene_repo_template/skeleton/jupyter-book/docs/images/scene-logo.png :alt: SCENE logo :width: 140px :align: center diff --git a/docs/myst.yml b/docs/myst.yml index a30f4c9..4205c65 100644 --- a/docs/myst.yml +++ b/docs/myst.yml @@ -26,9 +26,9 @@ project: site: template: book-theme options: - logo: images/scene-logo.png + logo: ../src/scene_repo_template/skeleton/jupyter-book/docs/images/scene-logo.png logo_text: SCENE - favicon: images/favicon.png - style: _static/scene.css + favicon: ../src/scene_repo_template/skeleton/jupyter-book/docs/images/favicon.png + style: ../src/scene_repo_template/skeleton/jupyter-book/docs/_static/scene.css parts: - primary_sidebar_footer: _static/footer.md + primary_sidebar_footer: ../src/scene_repo_template/skeleton/jupyter-book/docs/_static/footer.md diff --git a/docs/_static/footer.md b/src/scene_repo_template/skeleton/jupyter-book/docs/_static/footer.md similarity index 100% rename from docs/_static/footer.md rename to src/scene_repo_template/skeleton/jupyter-book/docs/_static/footer.md diff --git a/docs/_static/scene-palette.json b/src/scene_repo_template/skeleton/jupyter-book/docs/_static/scene-palette.json similarity index 100% rename from docs/_static/scene-palette.json rename to src/scene_repo_template/skeleton/jupyter-book/docs/_static/scene-palette.json diff --git a/docs/_static/scene.css b/src/scene_repo_template/skeleton/jupyter-book/docs/_static/scene.css similarity index 100% rename from docs/_static/scene.css rename to src/scene_repo_template/skeleton/jupyter-book/docs/_static/scene.css diff --git a/docs/images/favicon.png b/src/scene_repo_template/skeleton/jupyter-book/docs/images/favicon.png similarity index 100% rename from docs/images/favicon.png rename to src/scene_repo_template/skeleton/jupyter-book/docs/images/favicon.png diff --git a/docs/images/scene-logo.png b/src/scene_repo_template/skeleton/jupyter-book/docs/images/scene-logo.png similarity index 100% rename from docs/images/scene-logo.png rename to src/scene_repo_template/skeleton/jupyter-book/docs/images/scene-logo.png From 29fb745c93b2ce1de0208adcb9a69b5d93d8d78f Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Wed, 28 Oct 2026 11:10:57 +0100 Subject: [PATCH 02/11] nitialize scene_repo_template project structure and CLI - Added __init__.py and version.py for package versioning. - Implemented CLI in cli.py for creating new SCENE projects. - Included skeleton files for common project structure, including LICENSE, README, and pyproject.toml templates. - Set up GitHub Actions workflows for linting and testing. - Created Jupyter Book documentation structure with templates. - Updated tests to reflect new package name and structure. - Added questionary dependency for interactive CLI prompts. --- .gitignore | 2 +- placeholder/__init__.py | 3 - pyproject.toml | 13 +- src/scene_repo_template/__init__.py | 3 + src/scene_repo_template/cli.py | 271 ++++++++++++++++++ .../skeleton/common/LICENSE | 201 +++++++++++++ .../skeleton/common/README.md.tmpl | 25 ++ .../skeleton/common/__package__/__init__.py | 3 + .../skeleton/common/__package__}/version.py | 0 .../common/dot-github/workflows/lint.yml | 36 +++ .../common/dot-github/workflows/tests.yml | 37 +++ .../skeleton/common/dot-gitignore | 197 +++++++++++++ .../common/dot-pre-commit-config.yaml | 52 ++++ .../skeleton/common/pyproject.toml.tmpl | 49 ++++ .../common/tests/test_version.py.tmpl | 8 + .../skeleton/jupyter-book/docs/index.md.tmpl | 7 + .../skeleton/jupyter-book/docs/myst.yml.tmpl | 20 ++ .../dot-github/workflows/docs.yml | 53 ++++ src/scene_repo_template/version.py | 1 + tests/test_version.py | 6 +- uv.lock | 18 +- 21 files changed, 992 insertions(+), 13 deletions(-) delete mode 100644 placeholder/__init__.py create mode 100644 src/scene_repo_template/__init__.py create mode 100644 src/scene_repo_template/cli.py create mode 100644 src/scene_repo_template/skeleton/common/LICENSE create mode 100644 src/scene_repo_template/skeleton/common/README.md.tmpl create mode 100644 src/scene_repo_template/skeleton/common/__package__/__init__.py rename {placeholder => src/scene_repo_template/skeleton/common/__package__}/version.py (100%) create mode 100644 src/scene_repo_template/skeleton/common/dot-github/workflows/lint.yml create mode 100644 src/scene_repo_template/skeleton/common/dot-github/workflows/tests.yml create mode 100644 src/scene_repo_template/skeleton/common/dot-gitignore create mode 100644 src/scene_repo_template/skeleton/common/dot-pre-commit-config.yaml create mode 100644 src/scene_repo_template/skeleton/common/pyproject.toml.tmpl create mode 100644 src/scene_repo_template/skeleton/common/tests/test_version.py.tmpl create mode 100644 src/scene_repo_template/skeleton/jupyter-book/docs/index.md.tmpl create mode 100644 src/scene_repo_template/skeleton/jupyter-book/docs/myst.yml.tmpl create mode 100644 src/scene_repo_template/skeleton/jupyter-book/dot-github/workflows/docs.yml create mode 100644 src/scene_repo_template/version.py diff --git a/.gitignore b/.gitignore index 6aa0f10..2cb7dc7 100644 --- a/.gitignore +++ b/.gitignore @@ -177,7 +177,7 @@ cython_debug/ # Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore # that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore # and can be added to the global gitignore or merged into this file. However, if you prefer, -# you could uncomment the following to ignore the enitre vscode folder +# you could uncomment the following to ignore the entire vscode folder # .vscode/ # Ruff stuff: diff --git a/placeholder/__init__.py b/placeholder/__init__.py deleted file mode 100644 index 8367a1e..0000000 --- a/placeholder/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from placeholder.version import __version__ - -__all__ = ["__version__"] diff --git a/pyproject.toml b/pyproject.toml index 701544d..34035d9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,8 +3,8 @@ build-backend = "setuptools.build_meta" requires = [ "setuptools>=77" ] [project] -name = "SCENE-placeholder" # Please update -description = "A SCENE collaboration package for..." +name = "scene-repo-template" +description = "Create SCENE code projects: packaging, tests, CI and documentation already set up." readme = "README.md" license = "Apache-2.0" # keep in line with LICENSE license-files = [ "LICENSE" ] @@ -17,18 +17,21 @@ classifiers = [ "Topic :: Scientific/Engineering", ] dynamic = [ "version" ] -dependencies = [] +dependencies = [ "questionary>=2" ] optional-dependencies.dev = [ "pre-commit", "pytest", "pytest-cov" ] optional-dependencies.docs = [ "jupyter-book>=2" ] +scripts.scene-repo-template = "scene_repo_template.cli:main" urls."Bug Tracker" = "https://github.com/SCENE-Collaboration/CodeAndDocsTemplate/issues" urls.Source = "https://github.com/SCENE-Collaboration/CodeAndDocsTemplate" [tool.setuptools] -packages = [ "placeholder" ] +packages.find.where = [ "src" ] +# The project skeleton ships as data: generated projects are copied from it. +package-data.scene_repo_template = [ "skeleton/**/*" ] [tool.setuptools.dynamic] -version = { attr = "placeholder.version.__version__" } +version = { attr = "scene_repo_template.version.__version__" } [tool.pytest.ini_options] testpaths = [ "tests" ] diff --git a/src/scene_repo_template/__init__.py b/src/scene_repo_template/__init__.py new file mode 100644 index 0000000..281412d --- /dev/null +++ b/src/scene_repo_template/__init__.py @@ -0,0 +1,3 @@ +from scene_repo_template.version import __version__ + +__all__ = ["__version__"] diff --git a/src/scene_repo_template/cli.py b/src/scene_repo_template/cli.py new file mode 100644 index 0000000..3526450 --- /dev/null +++ b/src/scene_repo_template/cli.py @@ -0,0 +1,271 @@ +"""Create a SCENE code project from the skeleton: ``scene-repo-template new``.""" + +from __future__ import annotations + +import argparse +import keyword +import re +import subprocess +import sys +import unicodedata +from collections.abc import Callable +from pathlib import Path +from string import Template + +SKELETON = Path(__file__).parent / "skeleton" + +# Docs backends: the skeleton folder copied over `common/`, and the values its templates need. +BACKENDS = { + "jupyter-book": { + "label": "Jupyter Book: guides and notebooks", + "docs_dependencies": '"jupyter-book>=2"', + "docs_readme": ( + "Built from `docs/` with [Jupyter Book](https://jupyterbook.org): " + 'after `pip install -e ".[docs]"` (or `uv sync --extra docs`), `jupyter book start` in `docs/` serves it ' + "locally. " + "`.github/workflows/docs.yml` publishes it to GitHub Pages once Pages is enabled with GitHub Actions as its source." + ), + }, +} + +TEMPLATE_SUFFIX = ".tmpl" # rendered with string.Template, then the suffix is dropped +DOT_PREFIX = "dot-" # dot-github -> .github: hidden files would be skipped by package-data globs +PACKAGE_DIR = "__package__" # renamed to the import name + +DIST_NAME = re.compile(r"^[A-Za-z0-9]([A-Za-z0-9._-]*[A-Za-z0-9])?$") +PACKAGE = re.compile(r"^[a-z_][a-z0-9_]*$") +# Folders every project already has; a package of the same name would merge into them. +RESERVED_PACKAGES = {"docs", "tests"} +# Free text is written inside double-quoted TOML and YAML strings, which take neither these nor control characters. +UNQUOTABLE = re.compile(r'["\\\x00-\x08\x0a-\x1f\x7f]') + +Check = Callable[[str], "str | None"] + + +class ProjectError(ValueError): + """Invalid or missing answer.""" + + +def check_dist_name(value: str) -> str | None: + if not DIST_NAME.match(value): + return "use letters, digits, '-', '_' or '.', starting and ending with a letter or digit" + return None + + +def check_package(value: str) -> str | None: + if not PACKAGE.match(value) or keyword.iskeyword(value): + return "use a lowercase Python identifier, e.g. my_project" + if value in sys.stdlib_module_names: + return f"{value} is a standard-library module; importing it would not reach your package" + if value in RESERVED_PACKAGES: + return f"{value}/ is already a folder of the project" + return None + + +def check_text(value: str) -> str | None: + if not value.strip(): + return "must not be empty" + if UNQUOTABLE.search(value): + return 'must be one line, without " or \\ or control characters' + return None + + +def check_url(value: str) -> str | None: + if not value.startswith(("https://", "http://")) or " " in value or UNQUOTABLE.search(value): + return "must be an http(s) URL" + return None + + +def check_dest(value: str) -> str | None: + if not value: + return "required" + path = Path(value) + if path.exists() and (not path.is_dir() or any(path.iterdir())): + return f"{path} exists and is not an empty folder" + return None + + +def default_dist_name(folder: str) -> str: + """`My Projét` -> `My-Projet`: accents dropped, other invalid characters replaced with `-`.""" + ascii_name = unicodedata.normalize("NFKD", folder).encode("ascii", "ignore").decode() + name = re.sub(r"[^A-Za-z0-9._-]+", "-", ascii_name).strip("-._") + return name if check_dist_name(name) is None else "project" + + +def default_package(dist_name: str) -> str: + package = re.sub(r"[^a-z0-9_]", "_", dist_name.lower()).lstrip("0123456789_") + return package if check_package(package) is None else "project" + + +def git_user_name() -> str: + try: + result = subprocess.run(["git", "config", "user.name"], capture_output=True, text=True, check=False) + except OSError: + return "" + return result.stdout.strip() + + +def output_name(part: str, package: str) -> str: + if part == PACKAGE_DIR: + return package + if part.startswith(DOT_PREFIX): + return "." + part[len(DOT_PREFIX) :] + return part + + +def text_or_none(data: bytes) -> str | None: + if b"\0" in data: + return None + try: + return data.decode("utf-8") + except UnicodeDecodeError: + return None + + +def generate(dest: Path, values: dict[str, str], backend: str, dry_run: bool = False) -> list[Path]: + """Copy `common/`, then the backend's folder, into `dest`; render `.tmpl` files with `values`. + + Text files are written with LF line endings, whatever checkout the skeleton was installed from; binary files are + copied byte for byte. With `dry_run`, templates are still rendered, so missing values fail the same way, but + nothing is written. + """ + variables = {**values, **{key: value for key, value in BACKENDS[backend].items() if key != "label"}} + written = [] + for layer in ("common", backend): + root = SKELETON / layer + for source in sorted(root.rglob("*")): + relative = source.relative_to(root) + if not source.is_file() or "__pycache__" in relative.parts: + continue + target = dest.joinpath(*(output_name(part, values["package"]) for part in relative.parts)) + content = source.read_bytes() + text = text_or_none(content) + if target.suffix == TEMPLATE_SUFFIX: + target = target.with_suffix("") + text = Template(text).substitute(variables) + if text is not None: + content = text.replace("\r\n", "\n").encode("utf-8") + written.append(target) + if dry_run: + continue + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(content) + return written + + +def resolve(question: str, given: str | None, default: str | None, check: Check, interactive: bool) -> str: + """Return the option given on the command line, else ask, else (with --yes) the default.""" + if given is None and interactive: + import questionary + + answer = questionary.text(question, default=default or "", validate=lambda v: check(v) or True).ask() + if answer is None: + raise KeyboardInterrupt + return answer + value = given if given is not None else default + if value is None: + raise ProjectError(f"{question}: required with --yes") + error = check(value) + if error: + raise ProjectError(f"{question}: {error}") + return value + + +def resolve_backend(given: str | None, interactive: bool) -> str: + if given is not None or not interactive or len(BACKENDS) == 1: + return given or next(iter(BACKENDS)) + import questionary + + choices = [questionary.Choice(backend["label"], value=key) for key, backend in BACKENDS.items()] + answer = questionary.select("Documentation", choices=choices).ask() + if answer is None: + raise KeyboardInterrupt + return answer + + +def new_project(args: argparse.Namespace) -> int: + interactive = not args.yes + dest = Path(resolve("Folder to create", args.dest, None, check_dest, interactive)) + name = resolve( + "Distribution name (pip install ...)", + args.name, + default_dist_name(dest.resolve().name), + check_dist_name, + interactive, + ) + values = {"dist_name": name} + values["package"] = resolve("Import name", args.package, default_package(name), check_package, interactive) + values["title"] = resolve("Title", args.title, name, check_text, interactive) + values["description"] = resolve( + "One-line description", args.description, "A SCENE code project.", check_text, interactive + ) + values["author"] = resolve("Author", args.author, git_user_name() or "SCENE Collaboration", check_text, interactive) + values["github_url"] = resolve( + "GitHub repository URL", + args.github, + f"https://github.com/SCENE-Collaboration/{name}", + check_url, + interactive, + ) + values["github_url"] = values["github_url"].rstrip("/").removesuffix(".git") + backend = resolve_backend(args.docs, interactive) + + print(f"\nProject in {dest}:") + for key, value in {**values, "docs": backend}.items(): + print(f" {key:<12} {value}") + if interactive: + import questionary + + if not questionary.confirm("Create it?", default=True).ask(): + print("Nothing written.") + return 1 + + written = generate(dest, values, backend, dry_run=args.dry_run) + if args.dry_run: + print(f"\nDry run: would create {len(written)} files; nothing written.") + for path in written: + print(f" {path.relative_to(dest).as_posix()}") + return 0 + print(f"\nCreated {len(written)} files. Next, in {dest}:") + print(" git init") + print(" with uv: uv sync --extra dev, then uv run pre-commit install and uv run pytest") + print( + ' with pip or conda: pip install -e ".[dev]" in an activated environment, then pre-commit install and pytest' + ) + print(" see Using this template in the SCENE guidelines for each step") + print(f"then create {values['github_url']} on GitHub, commit and push.") + return 0 + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(prog="scene-repo-template", description="Create SCENE code projects.") + commands = parser.add_subparsers(dest="command", required=True) + new = commands.add_parser( + "new", + help="create a project", + description="Create a project. Options not given are asked for; with --yes, their defaults are used.", + ) + new.add_argument("dest", nargs="?", help="folder to create; must not exist, or be empty") + new.add_argument("--name", help="distribution name, the one people pip install (default: the folder name)") + new.add_argument("--package", help="import name (default: derived from --name)") + new.add_argument("--title", help="project title (default: --name)") + new.add_argument("--description", help="one-line description") + new.add_argument("--author", help="author (default: git config user.name)") + new.add_argument("--github", help="repository URL (default: https://github.com/SCENE-Collaboration/)") + new.add_argument("--docs", choices=list(BACKENDS), help="documentation backend") + new.add_argument("-y", "--yes", action="store_true", help="use defaults for options not given; do not confirm") + new.add_argument("-n", "--dry-run", action="store_true", help="ask everything, list the files, write nothing") + args = parser.parse_args(argv) + try: + return new_project(args) + except ProjectError as error: + parser.exit(2, f"scene-repo-template: error: {error}\n") + except OSError as error: + parser.exit(1, f"scene-repo-template: error: {error}\nFiles written before the error are left in place.\n") + except KeyboardInterrupt: + print("\nCancelled; nothing written.", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/scene_repo_template/skeleton/common/LICENSE b/src/scene_repo_template/skeleton/common/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/src/scene_repo_template/skeleton/common/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/src/scene_repo_template/skeleton/common/README.md.tmpl b/src/scene_repo_template/skeleton/common/README.md.tmpl new file mode 100644 index 0000000..4054a23 --- /dev/null +++ b/src/scene_repo_template/skeleton/common/README.md.tmpl @@ -0,0 +1,25 @@ +# $title + +$description + +## Development + +In an activated environment (venv or conda): + +```bash +pip install -e ".[dev]" +pre-commit install +pytest +``` + +With uv: `uv sync --extra dev`, then `uv run pre-commit install` and `uv run pytest`. + +Add dependencies under `dependencies` in `pyproject.toml`. The `uv-lock` pre-commit hook updates `uv.lock`; commit it with the change. + +## Documentation + +$docs_readme + +## License + +Apache-2.0: see `LICENSE`. diff --git a/src/scene_repo_template/skeleton/common/__package__/__init__.py b/src/scene_repo_template/skeleton/common/__package__/__init__.py new file mode 100644 index 0000000..2d73571 --- /dev/null +++ b/src/scene_repo_template/skeleton/common/__package__/__init__.py @@ -0,0 +1,3 @@ +from .version import __version__ + +__all__ = ["__version__"] diff --git a/placeholder/version.py b/src/scene_repo_template/skeleton/common/__package__/version.py similarity index 100% rename from placeholder/version.py rename to src/scene_repo_template/skeleton/common/__package__/version.py diff --git a/src/scene_repo_template/skeleton/common/dot-github/workflows/lint.yml b/src/scene_repo_template/skeleton/common/dot-github/workflows/lint.yml new file mode 100644 index 0000000..9740407 --- /dev/null +++ b/src/scene_repo_template/skeleton/common/dot-github/workflows/lint.yml @@ -0,0 +1,36 @@ +name: Lint + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + pre-commit: + # CI runs on public repositories only: private ones draw on the org's Actions quota, which can be billed. Delete this line to opt in. + if: ${{ !github.event.repository.private }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 + with: + python-version: "3.13" + - name: A lockfile is committed + run: | + for f in uv.lock poetry.lock pdm.lock pixi.lock conda-lock.yml renv.lock Manifest.toml package-lock.json Cargo.lock; do + [ -f "$f" ] && exit 0 + done + echo "::error::No lockfile committed; run uv lock and commit uv.lock." + exit 1 + - run: pip install pre-commit + # Check-only hooks: see .pre-commit-config.yaml. + - run: pre-commit run --all-files --hook-stage manual --show-diff-on-failure diff --git a/src/scene_repo_template/skeleton/common/dot-github/workflows/tests.yml b/src/scene_repo_template/skeleton/common/dot-github/workflows/tests.yml new file mode 100644 index 0000000..f2b476b --- /dev/null +++ b/src/scene_repo_template/skeleton/common/dot-github/workflows/tests.yml @@ -0,0 +1,37 @@ +name: Tests + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + pytest: + # CI runs on public repositories only: private ones draw on the org's Actions quota, which can be billed. Delete this line to opt in. + if: ${{ !github.event.repository.private }} + name: pytest (${{ matrix.os }}, Python ${{ matrix.python-version }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest] + python-version: ["3.10", "3.13"] + include: + - os: windows-latest + python-version: "3.13" + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2 + with: + python-version: ${{ matrix.python-version }} + - run: uv sync --extra dev + - run: uv run pytest diff --git a/src/scene_repo_template/skeleton/common/dot-gitignore b/src/scene_repo_template/skeleton/common/dot-gitignore new file mode 100644 index 0000000..2cb7dc7 --- /dev/null +++ b/src/scene_repo_template/skeleton/common/dot-gitignore @@ -0,0 +1,197 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# UV +# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +#uv.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/latest/usage/project/#working-with-version-control +.pdm.toml +.pdm-python +.pdm-build/ + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +# Abstra +# Abstra is an AI-powered process automation framework. +# Ignore directories containing user credentials, local state, and settings. +# Learn more at https://abstra.io/docs +.abstra/ + +# Visual Studio Code +# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore +# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore +# and can be added to the global gitignore or merged into this file. However, if you prefer, +# you could uncomment the following to ignore the entire vscode folder +# .vscode/ + +# Ruff stuff: +.ruff_cache/ + +# PyPI configuration file +.pypirc + +# Cursor +# Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to +# exclude from AI features like autocomplete and code analysis. Recommended for sensitive data +# refer to https://docs.cursor.com/context/ignore-files +.cursorignore +.cursorindexingignore + +# Jupyter Book +_build/ diff --git a/src/scene_repo_template/skeleton/common/dot-pre-commit-config.yaml b/src/scene_repo_template/skeleton/common/dot-pre-commit-config.yaml new file mode 100644 index 0000000..4606395 --- /dev/null +++ b/src/scene_repo_template/skeleton/common/dot-pre-commit-config.yaml @@ -0,0 +1,52 @@ +# Locally, hooks fix what they can: `pre-commit install` once, then commit as usual. +# CI runs the same hooks check-only: `pre-commit run --all-files --hook-stage manual`. +default_stages: [pre-commit, manual] + +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: 3e8a8703264a2f4a69428a0aa4dcb512790b2c8c # frozen: v6.0.0 + hooks: + - id: check-added-large-files + - id: check-yaml + - id: check-toml + - id: check-json + - id: check-merge-conflict + - id: name-tests-test + args: [--pytest-test-first] + - id: end-of-file-fixer + - id: trailing-whitespace + + - repo: https://github.com/abravalheri/validate-pyproject + rev: 82d21874c3242e4dc27bb47641c996902ef64eef # frozen: v0.27 + hooks: + - id: validate-pyproject + + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: 25bcf95026b96e2dd3c0aec3090c3d6ef6ca2e6c # frozen: v0.17.3 + hooks: + - id: ruff-check + name: ruff-check (fix) + args: [--fix] + stages: [pre-commit] + - id: ruff-format + name: ruff-format (write) + stages: [pre-commit] + - id: ruff-check + name: ruff-check (ci) + args: [--output-format=github] + stages: [manual] + - id: ruff-format + name: ruff-format (ci) + args: [--check, --diff] + stages: [manual] + + - repo: https://github.com/astral-sh/uv-pre-commit + rev: 46787482b9fb2003c56e0f7592e312ef381829d9 # frozen: 0.13.6 + hooks: + - id: uv-lock + + - repo: https://github.com/codespell-project/codespell + rev: 57b21406f092110c18776e39b0bda50d37c945c8 # frozen: v2.4.3 + hooks: + - id: codespell + additional_dependencies: [tomli] diff --git a/src/scene_repo_template/skeleton/common/pyproject.toml.tmpl b/src/scene_repo_template/skeleton/common/pyproject.toml.tmpl new file mode 100644 index 0000000..3c2f141 --- /dev/null +++ b/src/scene_repo_template/skeleton/common/pyproject.toml.tmpl @@ -0,0 +1,49 @@ +[build-system] +build-backend = "setuptools.build_meta" +requires = [ "setuptools>=77" ] + +[project] +name = "$dist_name" +description = "$description" +readme = "README.md" +license = "Apache-2.0" # keep in line with LICENSE +license-files = [ "LICENSE" ] +authors = [ { name = "$author" } ] +requires-python = ">=3.10" +classifiers = [ + "Intended Audience :: Science/Research", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3 :: Only", + "Topic :: Scientific/Engineering", +] +dynamic = [ "version" ] +dependencies = [] + +optional-dependencies.dev = [ "pre-commit", "pytest", "pytest-cov" ] +optional-dependencies.docs = [ $docs_dependencies ] +urls."Bug Tracker" = "$github_url/issues" +urls.Source = "$github_url" + +[tool.setuptools] +packages = [ "$package" ] + +[tool.setuptools.dynamic] +version = { attr = "$package.version.__version__" } + +[tool.pytest.ini_options] +testpaths = [ "tests" ] +addopts = [ "-ra", "--strict-markers" ] +xfail_strict = true + +[tool.ruff] +line-length = 120 +force-exclude = true + +[tool.ruff.lint] +# Rules that catch bugs or that ruff fixes itself; style is left to `ruff format`. +select = [ "E4", "E7", "E9", "F", "B", "I", "UP", "PIE" ] +ignore = [ "E741", "B007" ] +per-file-ignores."*.ipynb" = [ "E402" ] + +[tool.codespell] +skip = "*.ipynb,*.png,*.svg,_build" diff --git a/src/scene_repo_template/skeleton/common/tests/test_version.py.tmpl b/src/scene_repo_template/skeleton/common/tests/test_version.py.tmpl new file mode 100644 index 0000000..f37acde --- /dev/null +++ b/src/scene_repo_template/skeleton/common/tests/test_version.py.tmpl @@ -0,0 +1,8 @@ +from importlib.metadata import packages_distributions, version + +import $package + + +def test_installed_version_matches_package_version(): + (distribution,) = set(packages_distributions()["$package"]) + assert version(distribution) == $package.__version__ diff --git a/src/scene_repo_template/skeleton/jupyter-book/docs/index.md.tmpl b/src/scene_repo_template/skeleton/jupyter-book/docs/index.md.tmpl new file mode 100644 index 0000000..0df09f0 --- /dev/null +++ b/src/scene_repo_template/skeleton/jupyter-book/docs/index.md.tmpl @@ -0,0 +1,7 @@ +--- +title: "$title" +--- + +$description + +Add pages next to this one and list them under `toc:` in `myst.yml`. diff --git a/src/scene_repo_template/skeleton/jupyter-book/docs/myst.yml.tmpl b/src/scene_repo_template/skeleton/jupyter-book/docs/myst.yml.tmpl new file mode 100644 index 0000000..9b7fd7f --- /dev/null +++ b/src/scene_repo_template/skeleton/jupyter-book/docs/myst.yml.tmpl @@ -0,0 +1,20 @@ +# Jupyter Book 2 configuration: https://jupyterbook.org +# Preview locally with `jupyter book start`; build with `jupyter book build --html`. +version: 1 +project: + title: "$title" + description: "$description" + github: $github_url + license: Apache-2.0 + toc: + - file: index.md + +site: + template: book-theme + options: + logo: images/scene-logo.png + logo_text: SCENE + favicon: images/favicon.png + style: _static/scene.css + parts: + primary_sidebar_footer: _static/footer.md diff --git a/src/scene_repo_template/skeleton/jupyter-book/dot-github/workflows/docs.yml b/src/scene_repo_template/skeleton/jupyter-book/dot-github/workflows/docs.yml new file mode 100644 index 0000000..90028c4 --- /dev/null +++ b/src/scene_repo_template/skeleton/jupyter-book/dot-github/workflows/docs.yml @@ -0,0 +1,53 @@ +name: Docs + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +jobs: + build: + # CI runs on public repositories only: private ones draw on the org's Actions quota, which can be billed. Delete this line to opt in. + if: ${{ !github.event.repository.private }} + runs-on: ubuntu-latest + env: + # Pages serves the book under //. + BASE_URL: /${{ github.event.repository.name }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: 24.x + - uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2 + with: + python-version: "3.13" + - run: uv sync --extra docs + - run: uv run jupyter book build --html + working-directory: docs + - uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 + with: + path: docs/_build/html + + deploy: + # Pull requests only check that the book builds. + if: ${{ github.event_name == 'push' && !github.event.repository.private }} + needs: build + runs-on: ubuntu-latest + permissions: + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + concurrency: + group: pages + cancel-in-progress: false + steps: + - uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0 + - id: deployment + uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5.0.1 diff --git a/src/scene_repo_template/version.py b/src/scene_repo_template/version.py new file mode 100644 index 0000000..f102a9c --- /dev/null +++ b/src/scene_repo_template/version.py @@ -0,0 +1 @@ +__version__ = "0.0.1" diff --git a/tests/test_version.py b/tests/test_version.py index 19bd7cd..934a487 100644 --- a/tests/test_version.py +++ b/tests/test_version.py @@ -1,8 +1,8 @@ from importlib.metadata import packages_distributions, version -import placeholder +import scene_repo_template def test_installed_version_matches_package_version(): - (distribution,) = set(packages_distributions()["placeholder"]) - assert version(distribution) == placeholder.__version__ + (distribution,) = set(packages_distributions()["scene_repo_template"]) + assert version(distribution) == scene_repo_template.__version__ diff --git a/uv.lock b/uv.lock index 4382931..e3f953a 100644 --- a/uv.lock +++ b/uv.lock @@ -1750,6 +1750,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/77/a3/f134603a671c114c6b56eb912bba890f09e2d43b8a28d243be5a5507cf2f/pyzmq-27.2.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:3ee8dd7031d5e23f632e0e7eee67183ca7d2536e0de35dc1e5d69f3471a791e8", size = 553961, upload-time = "2026-08-20T19:08:09.651Z" }, ] +[[package]] +name = "questionary" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "prompt-toolkit" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f6/45/eafb0bba0f9988f6a2520f9ca2df2c82ddfa8d67c95d6625452e97b204a5/questionary-2.1.1.tar.gz", hash = "sha256:3d7e980292bb0107abaa79c68dd3eee3c561b83a0f89ae482860b181c8bd412d", size = 25845, upload-time = "2025-08-28T19:00:20.851Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/26/1062c7ec1b053db9e499b4d2d5bc231743201b74051c973dadeac80a8f43/questionary-2.1.1-py3-none-any.whl", hash = "sha256:a51af13f345f1cdea62347589fbb6df3b290306ab8930713bfae4d475a7d4a59", size = 36753, upload-time = "2025-08-28T19:00:19.56Z" }, +] + [[package]] name = "referencing" version = "0.37.0" @@ -2064,8 +2076,11 @@ wheels = [ ] [[package]] -name = "scene-placeholder" +name = "scene-repo-template" source = { editable = "." } +dependencies = [ + { name = "questionary" }, +] [package.optional-dependencies] dev = [ @@ -2084,6 +2099,7 @@ requires-dist = [ { name = "pre-commit", marker = "extra == 'dev'" }, { name = "pytest", marker = "extra == 'dev'" }, { name = "pytest-cov", marker = "extra == 'dev'" }, + { name = "questionary", specifier = ">=2" }, ] provides-extras = ["dev", "docs"] From 864ff9b1e41e5891706fa6b9626597b0fd59d2d7 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Wed, 28 Oct 2026 11:13:26 +0100 Subject: [PATCH 03/11] Update CI test workflow and add end-to-end tests for project generation --- .github/workflows/tests.yml | 6 + tests/test_cli.py | 226 ++++++++++++++++++++++++++++++++++++ 2 files changed, 232 insertions(+) create mode 100644 tests/test_cli.py diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index f2b476b..019028f 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -30,8 +30,14 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: 24.x - uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2 with: python-version: ${{ matrix.python-version }} - run: uv sync --extra dev - run: uv run pytest + env: + # Also generate a project, then install, lint, test and build it: see tests/test_cli.py. + SCENE_TEMPLATE_E2E: "1" diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..efc5d83 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,226 @@ +import os +import shutil +import subprocess +from pathlib import Path +from string import Template + +import pytest + +from scene_repo_template import cli + +REPOSITORY = Path(__file__).parents[1] +ANSWERS = {"dist_name", "package", "title", "description", "author", "github_url"} + +# Installing and building a generated project needs uv, Node.js and the network; CI sets this variable. +E2E = pytest.mark.skipif( + not os.environ.get("SCENE_TEMPLATE_E2E"), reason="set SCENE_TEMPLATE_E2E=1 to install and build a generated project" +) + + +def generate(tmp_path, *options, folder="my-project"): + dest = tmp_path / folder + assert cli.main(["new", str(dest), "--yes", *options]) == 0 + return dest + + +def files_of(root): + return {path.relative_to(root).as_posix() for path in root.rglob("*") if path.is_file()} + + +def refused(tmp_path, *options, folder="my-project"): + with pytest.raises(SystemExit) as exit_info: + cli.main(["new", str(tmp_path / folder), "--yes", *options]) + return exit_info.value.code == 2 + + +def test_a_project_gets_the_common_files_and_its_docs_backend(tmp_path): + expected = { + ".github/workflows/docs.yml", + ".github/workflows/lint.yml", + ".github/workflows/tests.yml", + ".gitignore", + ".pre-commit-config.yaml", + "LICENSE", + "README.md", + "docs/_static/footer.md", + "docs/_static/scene.css", + "docs/images/favicon.png", + "docs/images/scene-logo.png", + "docs/index.md", + "docs/myst.yml", + "my_project/__init__.py", + "my_project/version.py", + "pyproject.toml", + "tests/test_version.py", + } + assert expected <= files_of(generate(tmp_path)) + + +def test_no_skeleton_naming_is_left_in_a_project(tmp_path): + skeleton = files_of(cli.SKELETON) + for marker in (cli.TEMPLATE_SUFFIX, cli.DOT_PREFIX, cli.PACKAGE_DIR): + assert any(marker in name for name in skeleton) + project = files_of(generate(tmp_path)) + assert [ + name for name in project if any(m in name for m in (cli.TEMPLATE_SUFFIX, cli.DOT_PREFIX, cli.PACKAGE_DIR)) + ] == [] + + +def test_none_of_the_guidelines_site_is_copied(tmp_path): + assert (REPOSITORY / "docs" / "guides").is_dir() + assert (REPOSITORY / "docs" / "contributed-examples").is_dir() + project = files_of(generate(tmp_path)) + assert [name for name in project if "guides/" in name or "contributed-examples" in name] == [] + + +def test_answers_reach_the_rendered_files(tmp_path): + dest = generate( + tmp_path, + "--name=scene-gaze", + "--package=gaze", + "--title=Gaze: tracking", + "--description=Eye tracking in freely moving animals.", + "--author=A. Person", + "--github=https://github.com/lab/scene-gaze/", + ) + pyproject = (dest / "pyproject.toml").read_text(encoding="utf-8") + assert 'name = "scene-gaze"' in pyproject + assert 'authors = [ { name = "A. Person" } ]' in pyproject + assert 'packages = [ "gaze" ]' in pyproject + assert 'version = { attr = "gaze.version.__version__" }' in pyproject + assert 'urls.Source = "https://github.com/lab/scene-gaze"' in pyproject + assert 'optional-dependencies.docs = [ "jupyter-book>=2" ]' in pyproject + myst = (dest / "docs" / "myst.yml").read_text(encoding="utf-8") + assert 'title: "Gaze: tracking"' in myst + assert "github: https://github.com/lab/scene-gaze\n" in myst + assert "import gaze\n" in (dest / "tests" / "test_version.py").read_text(encoding="utf-8") + assert (dest / "gaze" / "__init__.py").is_file() + + +def test_the_rendered_pyproject_is_valid_toml(tmp_path): + tomllib = pytest.importorskip("tomllib") + pyproject = tomllib.loads((generate(tmp_path) / "pyproject.toml").read_text(encoding="utf-8")) + assert pyproject["project"]["name"] == "my-project" + + +def test_github_actions_expressions_are_copied_verbatim(tmp_path): + docs = (generate(tmp_path) / ".github" / "workflows" / "docs.yml").read_text(encoding="utf-8") + assert "BASE_URL: /${{ github.event.repository.name }}" in docs + + +def test_templates_use_only_the_values_the_generator_provides(): + provided = ANSWERS | {key for backend in cli.BACKENDS.values() for key in backend if key != "label"} + used = set() + for path in cli.SKELETON.rglob(f"*{cli.TEMPLATE_SUFFIX}"): + template = Template(path.read_text(encoding="utf-8")) + for match in template.pattern.finditer(template.template): + assert match.group("invalid") is None, path + used.add(match.group("named") or match.group("braced")) + used.discard(None) + assert used + assert used <= provided + + +def test_the_default_import_name_comes_from_the_folder_name(tmp_path): + dest = generate(tmp_path, folder="2-My.Project") + assert (dest / "my_project" / "__init__.py").is_file() + + +def test_a_folder_name_with_spaces_and_accents_gives_a_valid_default_name(tmp_path): + dest = generate(tmp_path, folder="My Projét") + assert 'name = "My-Projet"' in (dest / "pyproject.toml").read_text(encoding="utf-8") + assert (dest / "my_projet" / "__init__.py").is_file() + + +def test_a_github_url_ending_in_git_is_stored_without_it(tmp_path): + dest = generate(tmp_path, "--github=https://github.com/lab/proj.git/") + pyproject = (dest / "pyproject.toml").read_text(encoding="utf-8") + assert 'urls.Source = "https://github.com/lab/proj"' in pyproject + assert 'urls."Bug Tracker" = "https://github.com/lab/proj/issues"' in pyproject + + +def test_text_files_are_written_with_lf_and_binary_files_byte_for_byte(tmp_path, monkeypatch): + skeleton = tmp_path / "skeleton" + (skeleton / "common").mkdir(parents=True) + (skeleton / "jupyter-book").mkdir() + (skeleton / "common" / "plain.txt").write_bytes(b"one\r\ntwo\r\n") + (skeleton / "common" / "rendered.txt.tmpl").write_bytes(b"$package\r\n") + png = b"\x89PNG\r\n\x1a\n\x00\x00\r\n" + (skeleton / "common" / "logo.png").write_bytes(png) + monkeypatch.setattr(cli, "SKELETON", skeleton) + dest = tmp_path / "out" + cli.generate(dest, {"package": "pkg"}, "jupyter-book") + assert (dest / "plain.txt").read_bytes() == b"one\ntwo\n" + assert (dest / "rendered.txt").read_bytes() == b"pkg\n" + assert (dest / "logo.png").read_bytes() == png + + +def test_no_generated_text_file_has_crlf(tmp_path): + dest = generate(tmp_path) + texts = {path: cli.text_or_none(path.read_bytes()) for path in dest.rglob("*") if path.is_file()} + assert any(text is None for text in texts.values()) # the logo and favicon are binary + assert [path for path, text in texts.items() if text is not None and "\r" in text] == [] + + +def test_an_unwritable_destination_is_reported_without_a_traceback(tmp_path, capsys): + (tmp_path / "a-file").write_text("", encoding="utf-8") + with pytest.raises(SystemExit) as exit_info: + cli.main(["new", str(tmp_path / "a-file" / "proj"), "--yes", "--name=proj"]) + assert exit_info.value.code == 1 + assert "Traceback" not in capsys.readouterr().err + + +def test_a_dry_run_lists_the_files_of_a_real_run_and_writes_nothing(tmp_path, capsys): + assert cli.main(["new", str(tmp_path / "dry"), "--yes", "--dry-run"]) == 0 + assert not (tmp_path / "dry").exists() + listing = capsys.readouterr().out.split("Dry run:")[1] + listed = {line.strip() for line in listing.splitlines() if line.startswith(" ")} + assert listed == files_of(generate(tmp_path, folder="dry")) + + +def test_a_non_empty_folder_is_refused(tmp_path): + dest = tmp_path / "taken" + dest.mkdir() + (dest / "keep.txt").write_text("kept", encoding="utf-8") + assert refused(tmp_path, folder="taken") + assert files_of(dest) == {"keep.txt"} + + +@pytest.mark.parametrize("package", ["1project", "my-project", "MyProject", "class", "json", "tests", "docs"]) +def test_an_invalid_import_name_is_refused(tmp_path, package): + assert refused(tmp_path, f"--package={package}") + assert not (tmp_path / "my-project").exists() + + +@pytest.mark.parametrize("title", ['A "quoted" title', "back\\slash", "", "bell\x07", "two\nlines"]) +def test_a_title_that_cannot_be_quoted_is_refused(tmp_path, title): + assert refused(tmp_path, f"--title={title}") + + +def test_a_github_url_must_be_a_url(tmp_path): + assert refused(tmp_path, "--github=SCENE-Collaboration/my-project") + + +def run(dest, *command): + # Run in the generated project's own environment, not this repository's. + env = {key: value for key, value in os.environ.items() if key not in ("VIRTUAL_ENV", "UV_PROJECT_ENVIRONMENT")} + subprocess.run(command, cwd=dest, env=env, check=True) + + +@E2E +def test_a_generated_project_installs_lints_and_passes_its_tests(tmp_path): + dest = generate(tmp_path) + run(dest, "uv", "lock") + run(dest, "uv", "sync", "--extra", "dev") + run(dest, "uv", "run", "pytest") + run(dest, "git", "init", "--quiet") + run(dest, "git", "add", "--all") + run(dest, "uv", "run", "pre-commit", "run", "--all-files", "--hook-stage", "manual") + + +@E2E +@pytest.mark.skipif(shutil.which("node") is None, reason="Jupyter Book needs Node.js") +def test_a_generated_project_builds_its_docs(tmp_path): + dest = generate(tmp_path) + run(dest, "uv", "sync", "--extra", "docs") + run(dest / "docs", "uv", "run", "jupyter", "book", "build", "--html", "--strict") From b1dbf2d4ebf75399ee5d70fe58a3af4ce0f363fa Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Wed, 28 Oct 2026 11:14:41 +0100 Subject: [PATCH 04/11] Refine README and documentation for clarity; update TODO and CLI comments --- README.md | 4 +- TODO.md | 16 ++-- docs/guides/using-the-template.md | 144 +++++++++++++++++++++++++----- src/scene_repo_template/cli.py | 7 +- 4 files changed, 132 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index 42aa3ae..328207e 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,9 @@ **Documentation: [scene-collaboration.github.io/CodeAndDocsTemplate](https://scene-collaboration.github.io/CodeAndDocsTemplate/)** -This repository is the starting point for SCENE code projects: a template repository with packaging, formatting, tests, continuous integration and documentation already set up. +This repository holds SCENE's code and data guidelines, and `scene-repo-template`, which creates new projects with packaging, formatting, tests, continuous integration and documentation already set up. -SCENE's shared code lives in the [SCENE-Collaboration](https://github.com/SCENE-Collaboration) GitHub organisation. The template may be used for new repositories here or elsewhere in the consortium. +SCENE's shared code lives in the [SCENE-Collaboration](https://github.com/SCENE-Collaboration) GitHub organisation. Generated projects may live there or elsewhere in the consortium. > [!NOTE] > **The project template will be added soon!** Please stay tuned until #1 merges. diff --git a/TODO.md b/TODO.md index e221865..b3eca77 100644 --- a/TODO.md +++ b/TODO.md @@ -1,21 +1,15 @@ # TODO -Open work and open decisions for this template. +Open work and open decisions for this repository. ## Work -- **Project generator** (`scripts/new_project.py`, stdlib only): - - copies an explicit allowlist of template files, never the contributed examples; - - renames `placeholder` everywhere `docs/guides/using-the-template.md` lists; - - asks which docs backend to keep: Jupyter Book (`docs/myst.yml`) or MkDocs (`api-docs/`, branch `cy/scene-docs-mkdocs`); - - has tests in `tests/`, run by `tests.yml` on Linux and Windows; - - then: rewrite `docs/guides/using-the-template.md` around it, and restore the README's directory tree, generated from its allowlist. -- **CI on the template itself:** run the workflows on the template repository whatever its visibility, with `if: ${{ github.event.repository.is_template || !github.event.repository.private }}`, so repositories created from it keep the public-only rule. Check in a real run that `is_template` is set in the event payload. -- **Review and test pass:** `pytest` on Windows and Linux, `pre-commit run --all-files --hook-stage manual`, a strict book build with link checking, `actionlint` and `zizmor`, the generator end to end (generate, lock, install, test, build the docs), and every page checked in light and dark mode. +- **MkDocs backend for the generator:** `src/scene_repo_template/skeleton/mkdocs/`, from the prototype on `cy/scene-docs-mkdocs` (worktree `../CodeAndDocsTemplate-mkdocs`, `api-docs/` uncommitted). It writes `docs/`, with its own docs extra (`docs_dependencies`), docs workflow and SCENE stylesheet from `scene-palette.json`; add it to `BACKENDS` in `cli.py` and to the end-to-end docs test. The logo and favicon are in `skeleton/jupyter-book/docs/images/` because `footer.md` links them relatively; move them to `common/` only if both backends can reach them there. +- **CI on this repository:** its workflows skip private repositories, like those of generated projects. If the new repository is private, delete the `if:` lines in `.github/workflows/` here; the skeleton's keep them. +- **Review and test pass:** `pytest` on Windows and Linux with `SCENE_TEMPLATE_E2E=1`, `pre-commit run --all-files --hook-stage manual`, a strict book build with link checking, `actionlint` and `zizmor` on `.github/` and the skeleton's `dot-github/`, the interactive prompts by hand, and every page checked in light and dark mode. ## Decisions -- **Host the MkDocs API site next to the Jupyter Book?** One Pages site per repository, so both builds would go into one artifact (book at `/`, MkDocs under `/api/`): one more step in `.github/workflows/docs.yml`, a second toolchain in `uv.lock`, two themes to keep alike. Decide once the generator offers the MkDocs option, so its output can be shown. -- **New repository:** this work moves to a new SCENE repository; its name and visibility are open. After the move, update the repository links in `pyproject.toml` (`urls`) and `docs/myst.yml` (`github`), and in `api-docs/mkdocs.yml` if kept. +- **New repository:** this work moves to a new SCENE repository; its name and visibility are open. After the move, update the repository links in `pyproject.toml` (`urls`), `docs/myst.yml` (`github`) and the `uvx --from` command in `docs/guides/using-the-template.md`. `uvx --from git+https://…` works without credentials only on a public repository. Do not mark it as a GitHub template repository: projects come from the generator. - **Footer funder line:** "Simons Foundation" now; SCENE's own wording is "supported by a Simons Foundation International grant administered by the Simons Foundation". - **MLAI example headers:** authors are given as initials (C. Benquet, T. Sainsbury); the permission line has no date. diff --git a/docs/guides/using-the-template.md b/docs/guides/using-the-template.md index b0f7fb1..e85152a 100644 --- a/docs/guides/using-the-template.md +++ b/docs/guides/using-the-template.md @@ -1,44 +1,144 @@ # Using this template -Steps to start a new project from this repository, in order. +`scene-repo-template` creates a new project with packaging, tests, CI and a documentation site already set up. -## 1. Create your repository +It works with uv, pip or conda; it needs Python 3.10 or later and [git](https://git-scm.com/downloads). -On GitHub, click **Use this template** → **Create a new repository**. See GitHub's [guide to creating a repository from a template](https://docs.github.com/en/repositories/creating-and-managing-repositories/creating-a-repository-from-a-template). +## 1. Create the project -## 2. Rename the package +::::{tab-set} -The template's package is called `placeholder`. Rename it everywhere: +:::{tab-item} uv -- the `placeholder/` folder; -- `pyproject.toml`: `name` (the name people `pip install`), `packages`, and the `version` line under `[tool.setuptools.dynamic]`; -- `placeholder/__init__.py` and `tests/test_version.py`, which import it. +```bash +uvx --from git+https://github.com/SCENE-Collaboration/CodeAndDocsTemplate scene-repo-template new my-project +``` -Then update the links to this repository: `urls` in `pyproject.toml`, and `github` and `title` in `docs/myst.yml`. +`uvx` runs the generator without installing it. See [installing uv](https://docs.astral.sh/uv/getting-started/installation/). +::: -## 3. Choose a license +:::{tab-item} pip +In any Python environment: + +```bash +pip install git+https://github.com/SCENE-Collaboration/CodeAndDocsTemplate +scene-repo-template new my-project +``` + +::: + +:::{tab-item} conda +The generator is installed with pip, into a conda environment of its own: + +```bash +conda create -n scene-tools python pip +conda activate scene-tools +pip install git+https://github.com/SCENE-Collaboration/CodeAndDocsTemplate +scene-repo-template new my-project +``` + +::: + +:::: + +You will need to enter: +- The distribution name (for `pip install`) +- The import name +- The project title +- A one-line description +- The author +- The GitHub URL + +This will show a summary, and wait for confirmation. + +```{note} +Use `--dry-run` for a preview. +``` + + +The project then contains: + +- `pyproject.toml`, the package folder and a first test example in `tests/`; +- `.pre-commit-config.yaml` and three GitHub Actions workflows: lint, tests and docs; +- `docs/`, a Jupyter Book with SCENE's logo, colours and footer; +- `LICENSE` (Apache-2.0) and a short `README.md`. + +## 2. Set up your environment -`LICENSE` and the `license` field in `pyproject.toml` must name the same license. The template uses Apache-2.0; if you change it, change both. +In the new project, with your preferred package manager: -## 4. Set up your environment +::::{tab-set} + +:::{tab-item} uv + +```bash +cd my-project +git init +uv sync --extra dev +uv run pre-commit install +uv run pytest +``` + +::: + +:::{tab-item} pip ```bash -uv sync --extra dev # or: pip install -e ".[dev]" +cd my-project +git init +python3 -m venv .venv # Windows: py -m venv .venv +source .venv/bin/activate # Windows: .venv\Scripts\activate +pip install -e ".[dev]" pre-commit install -pre-commit run --all-files +pytest ``` -*Code Formatting & Linting* in the [Guidelines](guidelines.md) explains what the hooks do. +On Ubuntu and Debian, `python3 -m venv` needs the `python3-venv` package (`sudo apt install python3-venv`). -## 5. Add your dependencies +::: -List them under `dependencies` in `pyproject.toml`, then run `uv lock` and commit `uv.lock` with the change. The `uv-lock` hook does this for you on commit. +:::{tab-item} conda -## 6. Turn on CI and the docs site +```bash +cd my-project +git init +conda create -n my-project python pip +conda activate my-project +pip install -e ".[dev]" +pre-commit install +pytest +``` -- CI runs as soon as you push, but only if the repository is public: see *Continuous Integration* in the [Guidelines](guidelines.md). -- To publish this book, enable GitHub Pages once: see [Documentation with Jupyter Book](jupyter-book.md#publish). +Install packages that only exist on conda (or that you prefer from conda-forge) with `conda install`, before `pip install -e`. +::: -## 7. Remove what you do not need +:::: -Delete `docs/contributed-examples/` or the docs workflow (`.github/workflows/docs.yml`) if your project has no use for them, and replace the README with your own. +`pip install -e` installs the package in editable mode: changes to its code take effect without reinstalling. +See *Code Formatting & Linting* in the [Guidelines](guidelines.md) for an explanation of the hooks. + +## 3. Choose a license + +`LICENSE` and the `license` field in `pyproject.toml` must point to the same license. +Projects start with Apache-2.0; keep both in sync. + +## 4. Add your dependencies + +List them under `dependencies` in `pyproject.toml`, then reinstall (`uv sync --extra dev`, or `pip install -e ".[dev]"`). + +The `uv-lock` pre-commit hook writes `uv.lock` on every commit that changes `pyproject.toml`, also for pip and conda users. +Commit `uv.lock` with the change. + +```{note} +`uv.lock` covers only `pyproject.toml`: record conda-only packages in a separate lockfile, see *Lockfiles* in the [Guidelines](guidelines.md). +``` + +## 5. Turn on CI and the docs site + +- Create the repository on GitHub at the URL you decided, then commit and push. +- CI runs as soon as you push, if the repository is public and Actions is enabled (**Settings → Actions**): see *Continuous Integration* in the [Guidelines](guidelines.md). +- To publish the docs, enable GitHub Pages once: see [Documentation with Jupyter Book](jupyter-book.md#publish). + +```{important} +No docs needed? Delete `docs/` and `.github/workflows/docs.yml`. +``` diff --git a/src/scene_repo_template/cli.py b/src/scene_repo_template/cli.py index 3526450..c0c7496 100644 --- a/src/scene_repo_template/cli.py +++ b/src/scene_repo_template/cli.py @@ -14,7 +14,6 @@ SKELETON = Path(__file__).parent / "skeleton" -# Docs backends: the skeleton folder copied over `common/`, and the values its templates need. BACKENDS = { "jupyter-book": { "label": "Jupyter Book: guides and notebooks", @@ -28,15 +27,15 @@ }, } -TEMPLATE_SUFFIX = ".tmpl" # rendered with string.Template, then the suffix is dropped +TEMPLATE_SUFFIX = ".tmpl" DOT_PREFIX = "dot-" # dot-github -> .github: hidden files would be skipped by package-data globs PACKAGE_DIR = "__package__" # renamed to the import name DIST_NAME = re.compile(r"^[A-Za-z0-9]([A-Za-z0-9._-]*[A-Za-z0-9])?$") PACKAGE = re.compile(r"^[a-z_][a-z0-9_]*$") -# Folders every project already has; a package of the same name would merge into them. +# Folders every project ships with +# Otherwise a package of the same name would merge into them. RESERVED_PACKAGES = {"docs", "tests"} -# Free text is written inside double-quoted TOML and YAML strings, which take neither these nor control characters. UNQUOTABLE = re.compile(r'["\\\x00-\x08\x0a-\x1f\x7f]') Check = Callable[[str], "str | None"] From c7859c5cdff73020f70e8a18502c701a5eda1752 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Tue, 3 Nov 2026 17:23:24 +0100 Subject: [PATCH 05/11] Enhance validation for project names and add tests for edge cases --- src/scene_repo_template/cli.py | 19 +++++++++++++++---- tests/test_cli.py | 15 ++++++++++++++- 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/src/scene_repo_template/cli.py b/src/scene_repo_template/cli.py index c0c7496..54b8a9b 100644 --- a/src/scene_repo_template/cli.py +++ b/src/scene_repo_template/cli.py @@ -3,6 +3,7 @@ from __future__ import annotations import argparse +import functools import keyword import re import subprocess @@ -31,8 +32,9 @@ DOT_PREFIX = "dot-" # dot-github -> .github: hidden files would be skipped by package-data globs PACKAGE_DIR = "__package__" # renamed to the import name -DIST_NAME = re.compile(r"^[A-Za-z0-9]([A-Za-z0-9._-]*[A-Za-z0-9])?$") -PACKAGE = re.compile(r"^[a-z_][a-z0-9_]*$") +# Used with fullmatch: `$` would accept a trailing newline +DIST_NAME = re.compile(r"[A-Za-z0-9]([A-Za-z0-9._-]*[A-Za-z0-9])?") +PACKAGE = re.compile(r"[a-z_][a-z0-9_]*") # Folders every project ships with # Otherwise a package of the same name would merge into them. RESERVED_PACKAGES = {"docs", "tests"} @@ -45,19 +47,28 @@ class ProjectError(ValueError): """Invalid or missing answer.""" +@functools.cache +def gitignored_names() -> frozenset[str]: + """Import names the generated .gitignore excludes as folders (build/, lib/, env/, ...).""" + lines = (SKELETON / "common" / f"{DOT_PREFIX}gitignore").read_text(encoding="utf-8").splitlines() + return frozenset(name for line in lines if PACKAGE.fullmatch(name := line.strip().strip("/"))) + + def check_dist_name(value: str) -> str | None: - if not DIST_NAME.match(value): + if not DIST_NAME.fullmatch(value): return "use letters, digits, '-', '_' or '.', starting and ending with a letter or digit" return None def check_package(value: str) -> str | None: - if not PACKAGE.match(value) or keyword.iskeyword(value): + if not PACKAGE.fullmatch(value) or keyword.iskeyword(value): return "use a lowercase Python identifier, e.g. my_project" if value in sys.stdlib_module_names: return f"{value} is a standard-library module; importing it would not reach your package" if value in RESERVED_PACKAGES: return f"{value}/ is already a folder of the project" + if value in gitignored_names(): + return f"{value}/ is in the project's .gitignore, so the code would not be committed" return None diff --git a/tests/test_cli.py b/tests/test_cli.py index efc5d83..c4b3702 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -186,12 +186,25 @@ def test_a_non_empty_folder_is_refused(tmp_path): assert files_of(dest) == {"keep.txt"} -@pytest.mark.parametrize("package", ["1project", "my-project", "MyProject", "class", "json", "tests", "docs"]) +@pytest.mark.parametrize( + "package", + ["1project", "my-project", "MyProject", "class", "json", "tests", "docs", "lib", "build", "env", "my_project\n"], +) def test_an_invalid_import_name_is_refused(tmp_path, package): assert refused(tmp_path, f"--package={package}") assert not (tmp_path / "my-project").exists() +def test_a_distribution_name_with_a_trailing_newline_is_refused(tmp_path): + # Title and URL given, else their defaults (built from the name) would be what refuses the newline. + assert refused(tmp_path, "--name=my-project\n", "--title=My project", "--github=https://github.com/x/my-project") + + +def test_a_folder_named_like_an_ignored_folder_gets_a_committable_default_import_name(tmp_path): + dest = generate(tmp_path, folder="build") + assert (dest / "project" / "__init__.py").is_file() + + @pytest.mark.parametrize("title", ['A "quoted" title', "back\\slash", "", "bell\x07", "two\nlines"]) def test_a_title_that_cannot_be_quoted_is_refused(tmp_path, title): assert refused(tmp_path, f"--title={title}") From 7eaaa132da2bdd7a7845b45348c65eae98829977 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Tue, 3 Nov 2026 18:09:17 +0100 Subject: [PATCH 06/11] Add dev tool name validation and enhance version test coverage --- src/scene_repo_template/cli.py | 10 ++++++++ .../common/tests/test_version.py.tmpl | 11 ++++---- tests/test_cli.py | 25 +++++++++++++++++-- 3 files changed, 39 insertions(+), 7 deletions(-) diff --git a/src/scene_repo_template/cli.py b/src/scene_repo_template/cli.py index 54b8a9b..4f4aca1 100644 --- a/src/scene_repo_template/cli.py +++ b/src/scene_repo_template/cli.py @@ -54,6 +54,14 @@ def gitignored_names() -> frozenset[str]: return frozenset(name for line in lines if PACKAGE.fullmatch(name := line.strip().strip("/"))) +@functools.cache +def dev_tool_names() -> frozenset[str]: + """Import names of the generated project's dev extra (pytest, pre_commit, ...), read from its pyproject.""" + text = (SKELETON / "common" / f"pyproject.toml{TEMPLATE_SUFFIX}").read_text(encoding="utf-8") + dev = re.search(r"^optional-dependencies\.dev = \[(.*)\]$", text, re.MULTILINE).group(1) + return frozenset(re.sub(r"[-.]", "_", name.lower()) for name in re.findall(r'"([A-Za-z0-9._-]+)', dev)) + + def check_dist_name(value: str) -> str | None: if not DIST_NAME.fullmatch(value): return "use letters, digits, '-', '_' or '.', starting and ending with a letter or digit" @@ -69,6 +77,8 @@ def check_package(value: str) -> str | None: return f"{value}/ is already a folder of the project" if value in gitignored_names(): return f"{value}/ is in the project's .gitignore, so the code would not be committed" + if value in dev_tool_names(): + return f"{value} is one of the project's dev tools; a package of that name would shadow it" return None diff --git a/src/scene_repo_template/skeleton/common/tests/test_version.py.tmpl b/src/scene_repo_template/skeleton/common/tests/test_version.py.tmpl index f37acde..c89d283 100644 --- a/src/scene_repo_template/skeleton/common/tests/test_version.py.tmpl +++ b/src/scene_repo_template/skeleton/common/tests/test_version.py.tmpl @@ -1,8 +1,9 @@ -from importlib.metadata import packages_distributions, version - -import $package +import importlib +import importlib.metadata def test_installed_version_matches_package_version(): - (distribution,) = set(packages_distributions()["$package"]) - assert version(distribution) == $package.__version__ + # import_module binds no name, so no import name can shadow the ones used here. + package = importlib.import_module("$package") + (distribution,) = set(importlib.metadata.packages_distributions()["$package"]) + assert importlib.metadata.version(distribution) == package.__version__ diff --git a/tests/test_cli.py b/tests/test_cli.py index c4b3702..406955c 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,6 +1,9 @@ +import importlib +import importlib.metadata import os import shutil import subprocess +import sys from pathlib import Path from string import Template @@ -93,7 +96,7 @@ def test_answers_reach_the_rendered_files(tmp_path): myst = (dest / "docs" / "myst.yml").read_text(encoding="utf-8") assert 'title: "Gaze: tracking"' in myst assert "github: https://github.com/lab/scene-gaze\n" in myst - assert "import gaze\n" in (dest / "tests" / "test_version.py").read_text(encoding="utf-8") + assert 'importlib.import_module("gaze")' in (dest / "tests" / "test_version.py").read_text(encoding="utf-8") assert (dest / "gaze" / "__init__.py").is_file() @@ -188,13 +191,31 @@ def test_a_non_empty_folder_is_refused(tmp_path): @pytest.mark.parametrize( "package", - ["1project", "my-project", "MyProject", "class", "json", "tests", "docs", "lib", "build", "env", "my_project\n"], + [ + *["1project", "my-project", "MyProject", "class", "json", "tests", "docs", "lib", "build", "env"], + *["my_project\n", "pytest", "pre_commit"], + ], ) def test_an_invalid_import_name_is_refused(tmp_path, package): assert refused(tmp_path, f"--package={package}") assert not (tmp_path / "my-project").exists() +@pytest.mark.parametrize("package", ["version", "set", "distribution"]) +def test_the_generated_version_test_passes_whatever_the_import_name(tmp_path, monkeypatch, package): + dest = generate(tmp_path, f"--package={package}") + monkeypatch.syspath_prepend(str(dest)) + # Stand-ins for the installed metadata: what is at stake is the names the generated test binds. + monkeypatch.setattr(importlib.metadata, "packages_distributions", lambda: {package: ["my-project"]}) + monkeypatch.setattr(importlib.metadata, "version", lambda _: importlib.import_module(package).__version__) + namespace = {} + try: + exec((dest / "tests" / "test_version.py").read_text(encoding="utf-8"), namespace) + namespace["test_installed_version_matches_package_version"]() + finally: + sys.modules.pop(package, None) + + def test_a_distribution_name_with_a_trailing_newline_is_refused(tmp_path): # Title and URL given, else their defaults (built from the name) would be what refuses the newline. assert refused(tmp_path, "--name=my-project\n", "--title=My project", "--github=https://github.com/x/my-project") From 1b98b7e5da77ca6b22e3b0ccc04f87c5bc22c02d Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Wed, 4 Nov 2026 09:36:34 +0100 Subject: [PATCH 07/11] Enhance validation for distribution names and GitHub URLs in CLI; add corresponding tests --- src/scene_repo_template/cli.py | 37 ++++++++++++++++++++++++++++++---- tests/test_cli.py | 11 ++++++++-- 2 files changed, 42 insertions(+), 6 deletions(-) diff --git a/src/scene_repo_template/cli.py b/src/scene_repo_template/cli.py index 4f4aca1..97f79ed 100644 --- a/src/scene_repo_template/cli.py +++ b/src/scene_repo_template/cli.py @@ -9,6 +9,7 @@ import subprocess import sys import unicodedata +import urllib.parse from collections.abc import Callable from pathlib import Path from string import Template @@ -54,17 +55,43 @@ def gitignored_names() -> frozenset[str]: return frozenset(name for line in lines if PACKAGE.fullmatch(name := line.strip().strip("/"))) +def canonical_name(dist_name: str) -> str: + """PEP 503 form, so `Pre_Commit` and `pre-commit` compare equal.""" + return re.sub(r"[-_.]+", "-", dist_name).lower() + + +def requirement_names(requirements: str) -> set[str]: + """Canonical distribution names in a list of quoted requirements: `"pytest>=8", "mkdocstrings[python]"`.""" + return {canonical_name(name) for name in re.findall(r'"([A-Za-z0-9._-]+)', requirements)} + + @functools.cache -def dev_tool_names() -> frozenset[str]: - """Import names of the generated project's dev extra (pytest, pre_commit, ...), read from its pyproject.""" +def dev_distributions() -> frozenset[str]: + """The generated project's dev extra (pre-commit, pytest, ...), read from its pyproject.""" text = (SKELETON / "common" / f"pyproject.toml{TEMPLATE_SUFFIX}").read_text(encoding="utf-8") - dev = re.search(r"^optional-dependencies\.dev = \[(.*)\]$", text, re.MULTILINE).group(1) - return frozenset(re.sub(r"[-.]", "_", name.lower()) for name in re.findall(r'"([A-Za-z0-9._-]+)', dev)) + return frozenset( + requirement_names(re.search(r"^optional-dependencies\.dev = \[(.*)\]$", text, re.MULTILINE).group(1)) + ) + + +@functools.cache +def dev_tool_names() -> frozenset[str]: + """Import names of the dev extra (pytest, pre_commit, ...).""" + return frozenset(name.replace("-", "_") for name in dev_distributions()) + + +@functools.cache +def toolchain_distributions() -> frozenset[str]: + """Every distribution the generated extras depend on: dev, and docs for each backend.""" + docs = set().union(*(requirement_names(backend["docs_dependencies"]) for backend in BACKENDS.values())) + return dev_distributions() | docs def check_dist_name(value: str) -> str | None: if not DIST_NAME.fullmatch(value): return "use letters, digits, '-', '_' or '.', starting and ending with a letter or digit" + if canonical_name(value) in toolchain_distributions(): + return f"{value} is one of the project's own dependencies; the project would depend on itself" return None @@ -93,6 +120,8 @@ def check_text(value: str) -> str | None: def check_url(value: str) -> str | None: if not value.startswith(("https://", "http://")) or " " in value or UNQUOTABLE.search(value): return "must be an http(s) URL" + if not urllib.parse.urlsplit(value).hostname: + return "must be an http(s) URL with a host, e.g. https://github.com/lab/project" return None diff --git a/tests/test_cli.py b/tests/test_cli.py index 406955c..0fc9f30 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -221,6 +221,12 @@ def test_a_distribution_name_with_a_trailing_newline_is_refused(tmp_path): assert refused(tmp_path, "--name=my-project\n", "--title=My project", "--github=https://github.com/x/my-project") +@pytest.mark.parametrize("name", ["pytest", "Pre_Commit", "pytest.cov", "jupyter-book"]) +def test_a_distribution_name_of_a_toolchain_dependency_is_refused(tmp_path, name): + # An explicit import name, else the default (derived from the name) would be what is refused. + assert refused(tmp_path, f"--name={name}", "--package=my_project") + + def test_a_folder_named_like_an_ignored_folder_gets_a_committable_default_import_name(tmp_path): dest = generate(tmp_path, folder="build") assert (dest / "project" / "__init__.py").is_file() @@ -231,8 +237,9 @@ def test_a_title_that_cannot_be_quoted_is_refused(tmp_path, title): assert refused(tmp_path, f"--title={title}") -def test_a_github_url_must_be_a_url(tmp_path): - assert refused(tmp_path, "--github=SCENE-Collaboration/my-project") +@pytest.mark.parametrize("url", ["SCENE-Collaboration/my-project", "https://", "https:///my-project"]) +def test_a_github_url_must_be_a_url_with_a_host(tmp_path, url): + assert refused(tmp_path, f"--github={url}") def run(dest, *command): From 115c5225f68ffbcedab117a7826eb1aa40ad966f Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Wed, 4 Nov 2026 09:43:23 +0100 Subject: [PATCH 08/11] Update references from "Guidelines" to "Project standards" in documentation --- docs/guides/using-the-template.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/guides/using-the-template.md b/docs/guides/using-the-template.md index e85152a..585c5ea 100644 --- a/docs/guides/using-the-template.md +++ b/docs/guides/using-the-template.md @@ -115,7 +115,7 @@ Install packages that only exist on conda (or that you prefer from conda-forge) :::: `pip install -e` installs the package in editable mode: changes to its code take effect without reinstalling. -See *Code Formatting & Linting* in the [Guidelines](guidelines.md) for an explanation of the hooks. +See *Code Formatting & Linting* in the [Project standards](guidelines.md) for an explanation of the hooks. ## 3. Choose a license @@ -130,13 +130,13 @@ The `uv-lock` pre-commit hook writes `uv.lock` on every commit that changes `pyp Commit `uv.lock` with the change. ```{note} -`uv.lock` covers only `pyproject.toml`: record conda-only packages in a separate lockfile, see *Lockfiles* in the [Guidelines](guidelines.md). +`uv.lock` covers only `pyproject.toml`: record conda-only packages in a separate lockfile, see *Lockfiles* in the [Project standards](guidelines.md). ``` ## 5. Turn on CI and the docs site - Create the repository on GitHub at the URL you decided, then commit and push. -- CI runs as soon as you push, if the repository is public and Actions is enabled (**Settings → Actions**): see *Continuous Integration* in the [Guidelines](guidelines.md). +- CI runs as soon as you push, if the repository is public and Actions is enabled (**Settings → Actions**): see *Continuous Integration* in the [Project standards](guidelines.md). - To publish the docs, enable GitHub Pages once: see [Documentation with Jupyter Book](jupyter-book.md#publish). ```{important} From 3eafead4eb3524bf15f4b8efc016130de434e51b Mon Sep 17 00:00:00 2001 From: C-Achard Date: Wed, 4 Nov 2026 10:56:33 +0100 Subject: [PATCH 09/11] Revert "Update README to include note about upcoming project template (#5)" This reverts commit 638c36b29c725d6ec5980e5fc433605d246c82f7. --- README.md | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/README.md b/README.md index 328207e..e721404 100644 --- a/README.md +++ b/README.md @@ -6,11 +6,8 @@ This repository holds SCENE's code and data guidelines, and `scene-repo-template SCENE's shared code lives in the [SCENE-Collaboration](https://github.com/SCENE-Collaboration) GitHub organisation. Generated projects may live there or elsewhere in the consortium. -> [!NOTE] -> **The project template will be added soon!** Please stay tuned until #1 merges. +> **Starting a new project?** Follow [Using this template](https://scene-collaboration.github.io/CodeAndDocsTemplate/using-the-template/), step by step. - -**Documentation:** - [Project standards](https://scene-collaboration.github.io/CodeAndDocsTemplate/guidelines/): project layout, formatting and linting, CI, lockfiles, containers and DataJoint. - [Collaborating on code](https://scene-collaboration.github.io/CodeAndDocsTemplate/development-guide/): Git workflow, code style, issues, pull requests and code review. - [Documentation with Jupyter Book](https://scene-collaboration.github.io/CodeAndDocsTemplate/jupyter-book/), built from [`docs/`](docs/) and published to GitHub Pages. From 9699780e551d264f5f8aef080371a5a54bd825cf Mon Sep 17 00:00:00 2001 From: C-Achard Date: Wed, 4 Nov 2026 10:57:41 +0100 Subject: [PATCH 10/11] Update documentation and enhance CLI interaction - Clarify license synchronization steps in documentation. - Improve error handling and user prompts in CLI for better interactivity. - Update output messages to include direct links to the template guide. - Add tests for interactive CLI behavior and validation of user inputs. --- docs/guides/using-the-template.md | 8 ++++-- src/scene_repo_template/cli.py | 22 +++++++++++----- tests/test_cli.py | 42 +++++++++++++++++++++++++++++++ 3 files changed, 64 insertions(+), 8 deletions(-) diff --git a/docs/guides/using-the-template.md b/docs/guides/using-the-template.md index 585c5ea..a9242e2 100644 --- a/docs/guides/using-the-template.md +++ b/docs/guides/using-the-template.md @@ -119,8 +119,12 @@ See *Code Formatting & Linting* in the [Project standards](guidelines.md) for an ## 3. Choose a license -`LICENSE` and the `license` field in `pyproject.toml` must point to the same license. -Projects start with Apache-2.0; keep both in sync. +Projects start with Apache-2.0. +To change it, replace `LICENSE` and update every place that names the license: + +- the `license` field in `pyproject.toml`; +- the *License* section of `README.md`; +- `license` in `docs/myst.yml`. ## 4. Add your dependencies diff --git a/src/scene_repo_template/cli.py b/src/scene_repo_template/cli.py index 97f79ed..176b283 100644 --- a/src/scene_repo_template/cli.py +++ b/src/scene_repo_template/cli.py @@ -203,11 +203,18 @@ def generate(dest: Path, values: dict[str, str], backend: str, dry_run: bool = F def resolve(question: str, given: str | None, default: str | None, check: Check, interactive: bool) -> str: - """Return the option given on the command line, else ask, else (with --yes) the default.""" - if given is None and interactive: + """Return the option given on the command line, else ask, else (with --yes) the default. + + Interactively, an invalid option is reported and asked again, starting from what was given. + """ + error = check(given) if given is not None else None + if interactive and (given is None or error): import questionary - answer = questionary.text(question, default=default or "", validate=lambda v: check(v) or True).ask() + if error: + print(f"{question}: {error}", file=sys.stderr) + start = given if given is not None else default or "" + answer = questionary.text(question, default=start, validate=lambda v: check(v) or True).ask() if answer is None: raise KeyboardInterrupt return answer @@ -262,7 +269,7 @@ def new_project(args: argparse.Namespace) -> int: print(f"\nProject in {dest}:") for key, value in {**values, "docs": backend}.items(): print(f" {key:<12} {value}") - if interactive: + if interactive and not args.dry_run: import questionary if not questionary.confirm("Create it?", default=True).ask(): @@ -281,7 +288,10 @@ def new_project(args: argparse.Namespace) -> int: print( ' with pip or conda: pip install -e ".[dev]" in an activated environment, then pre-commit install and pytest' ) - print(" see Using this template in the SCENE guidelines for each step") + print( + " see Using this template in the SCENE guidelines for each step: " + "https://scene-collaboration.github.io/CodeAndDocsTemplate/using-the-template/" + ) print(f"then create {values['github_url']} on GitHub, commit and push.") return 0 @@ -312,7 +322,7 @@ def main(argv: list[str] | None = None) -> int: except OSError as error: parser.exit(1, f"scene-repo-template: error: {error}\nFiles written before the error are left in place.\n") except KeyboardInterrupt: - print("\nCancelled; nothing written.", file=sys.stderr) + print("\nCancelled.", file=sys.stderr) return 1 diff --git a/tests/test_cli.py b/tests/test_cli.py index 0fc9f30..7b2b874 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -4,6 +4,7 @@ import shutil import subprocess import sys +import types from pathlib import Path from string import Template @@ -181,6 +182,47 @@ def test_a_dry_run_lists_the_files_of_a_real_run_and_writes_nothing(tmp_path, ca assert listed == files_of(generate(tmp_path, folder="dry")) +class Prompts: + """Stands in for questionary: every question is answered with its default, as if by pressing Enter.""" + + def __init__(self): + self.asked = [] + + def text(self, question, default="", validate=None): + self.asked.append((question, default)) + return types.SimpleNamespace(ask=lambda: default) + + def confirm(self, question, default=True): + self.asked.append((question, default)) + return types.SimpleNamespace(ask=lambda: default) + + def select(self, question, choices): + self.asked.append((question, choices[0].value)) + return types.SimpleNamespace(ask=lambda: choices[0].value) + + def Choice(self, title, value): + return types.SimpleNamespace(title=title, value=value) + + +@pytest.fixture +def prompts(monkeypatch): + fake = Prompts() + monkeypatch.setitem(sys.modules, "questionary", fake) + return fake + + +def test_an_interactive_dry_run_does_not_ask_to_create(tmp_path, prompts): + assert cli.main(["new", str(tmp_path / "dry"), "--dry-run"]) == 0 + assert "Create it?" not in [question for question, _ in prompts.asked] + assert not (tmp_path / "dry").exists() + + +def test_an_invalid_option_is_asked_again_interactively(tmp_path, prompts, capsys): + cli.main(["new", str(tmp_path / "proj"), "--package=json", "--dry-run"]) + assert ("Import name", "json") in prompts.asked + assert "Import name: json is a standard-library module" in capsys.readouterr().err + + def test_a_non_empty_folder_is_refused(tmp_path): dest = tmp_path / "taken" dest.mkdir() From d93c57a5cd196d48237e8edd818d934e759ebdc2 Mon Sep 17 00:00:00 2001 From: C-Achard Date: Wed, 4 Nov 2026 11:29:18 +0100 Subject: [PATCH 11/11] Improve Jupyter Book documentation for page addition and configuration clarity --- docs/guides/jupyter-book.md | 4 ++-- .../skeleton/jupyter-book/docs/index.md.tmpl | 2 +- .../skeleton/jupyter-book/docs/myst.yml.tmpl | 6 ++++-- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/docs/guides/jupyter-book.md b/docs/guides/jupyter-book.md index 7f89c46..cfca98f 100644 --- a/docs/guides/jupyter-book.md +++ b/docs/guides/jupyter-book.md @@ -7,9 +7,9 @@ This template uses Jupyter Book 2. The configuration is `docs/myst.yml`. ## Write - Pages are Markdown files and notebooks in `docs/`; the landing page is `docs/index.md`. -- Add a page by creating the file and listing it under `toc:` in `myst.yml`. Pages not listed there are not built. +- Add a page by creating the file in `docs/`: every page there is built and listed in the sidebar, in alphabetical order, with folders as sections. To choose the order, add a [`toc:`](https://mystmd.org/guide/table-of-contents) to `myst.yml`; pages not listed there are then not built. - Images go in `docs/images/`. -- Notebooks (`.ipynb`) can be listed like any page. They are shown with the outputs saved in them, not re-executed: [Figure 5 kinematics](../contributed-examples/MLAI/figure-5-kinematics.ipynb) is an example. +- Notebooks (`.ipynb`) are pages like any other. They are shown with the outputs saved in them, not re-executed: [Figure 5 kinematics](../contributed-examples/MLAI/figure-5-kinematics.ipynb) is an example. ## Preview locally diff --git a/src/scene_repo_template/skeleton/jupyter-book/docs/index.md.tmpl b/src/scene_repo_template/skeleton/jupyter-book/docs/index.md.tmpl index 0df09f0..986d47b 100644 --- a/src/scene_repo_template/skeleton/jupyter-book/docs/index.md.tmpl +++ b/src/scene_repo_template/skeleton/jupyter-book/docs/index.md.tmpl @@ -4,4 +4,4 @@ title: "$title" $description -Add pages next to this one and list them under `toc:` in `myst.yml`. +Add pages, Markdown files or notebooks, anywhere in `docs/`: they appear in the sidebar on their own. diff --git a/src/scene_repo_template/skeleton/jupyter-book/docs/myst.yml.tmpl b/src/scene_repo_template/skeleton/jupyter-book/docs/myst.yml.tmpl index 9b7fd7f..f0620ce 100644 --- a/src/scene_repo_template/skeleton/jupyter-book/docs/myst.yml.tmpl +++ b/src/scene_repo_template/skeleton/jupyter-book/docs/myst.yml.tmpl @@ -6,8 +6,10 @@ project: description: "$description" github: $github_url license: Apache-2.0 - toc: - - file: index.md + # No `toc:`: every page in docs/ is built and listed, in alphabetical order. Add a `toc:` to set the order. + # _static/ holds parts of the theme (the footer), not pages. + exclude: + - _static/** site: template: book-theme