From f8026bab47cd89ab2d28878589f195c4d9973632 Mon Sep 17 00:00:00 2001 From: C-Achard Date: Wed, 4 Nov 2026 11:15:54 +0100 Subject: [PATCH] Add support for multiple licenses in project template - Introduced license options: Apache-2.0, MIT, and BSD-3-Clause. - Updated CLI to prompt for license selection and include it in generated files. - Modified README, pyproject.toml, and myst.yml templates to reflect chosen license. - Added LICENSE files for MIT and BSD-3-Clause. - Enhanced tests to verify license functionality and defaults. --- docs/guides/using-the-template.md | 11 +++--- src/scene_repo_template/cli.py | 34 ++++++++++++++----- .../skeleton/common/README.md.tmpl | 2 +- .../skeleton/common/pyproject.toml.tmpl | 2 +- .../skeleton/jupyter-book/docs/myst.yml.tmpl | 2 +- .../{common => licenses/Apache-2.0}/LICENSE | 0 .../licenses/BSD-3-Clause/LICENSE.tmpl | 28 +++++++++++++++ .../skeleton/licenses/MIT/LICENSE.tmpl | 21 ++++++++++++ tests/test_cli.py | 31 +++++++++++++++-- 9 files changed, 113 insertions(+), 18 deletions(-) rename src/scene_repo_template/skeleton/{common => licenses/Apache-2.0}/LICENSE (100%) create mode 100644 src/scene_repo_template/skeleton/licenses/BSD-3-Clause/LICENSE.tmpl create mode 100644 src/scene_repo_template/skeleton/licenses/MIT/LICENSE.tmpl diff --git a/docs/guides/using-the-template.md b/docs/guides/using-the-template.md index fd30061..452b1e9 100644 --- a/docs/guides/using-the-template.md +++ b/docs/guides/using-the-template.md @@ -48,6 +48,7 @@ You will need to enter: - A one-line description - The author - The GitHub URL +- The license: Apache-2.0, MIT or BSD-3-Clause (for others, see *3. Choose a license* below) - The documentation: Jupyter Book (guides and notebooks) or MkDocs (API reference from docstrings and notebooks) This will show a summary, and wait for confirmation. @@ -62,7 +63,7 @@ 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, or an MkDocs site with `mkdocs.yml`; -- `LICENSE` (Apache-2.0) and a short `README.md`. +- `LICENSE` and a short `README.md`. ### Which documentation backend? @@ -131,12 +132,14 @@ See *Code Formatting & Linting* in the [Project standards](guidelines.md) for an ## 3. Choose a license -Projects start with Apache-2.0. -To change it, replace `LICENSE` and update every place that names the license: +The template supports Apache-2.0 (the default), MIT or BSD-3-Clause, or `--license`. +For MIT and BSD-3-Clause, `LICENSE` starts with the year and the author as copyright holder; check that this is right. + +To change it later, or to use another license, replace `LICENSE` (see [choosealicense.com](https://choosealicense.com)) and update: - the `license` field in `pyproject.toml`; - the *License* section of `README.md`; -- `license` in `docs/myst.yml`. +- with Jupyter Book, `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 893acf8..1e74fb2 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 datetime import functools import keyword import re @@ -43,6 +44,13 @@ }, } +LICENSES = { + "Apache-2.0": "Apache-2.0: permissive, with a patent grant", + "MIT": "MIT: short and permissive", + "BSD-3-Clause": "BSD-3-Clause: permissive, and your name may not be used to promote derived work", +} +OTHER_LICENSES = "others: https://choosealicense.com, then swap LICENSE in by hand (see Using this template)" + 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 @@ -50,7 +58,7 @@ # 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 +# Folders shipped by default in every project # Otherwise a package of the same name would merge into them. RESERVED_PACKAGES = {"docs", "tests"} UNQUOTABLE = re.compile(r'["\\\x00-\x08\x0a-\x1f\x7f]') @@ -186,7 +194,7 @@ def text_or_none(data: bytes) -> str | 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`. + """Copy `common/`, the backend's folder and the license's, 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 @@ -194,7 +202,7 @@ def generate(dest: Path, values: dict[str, str], backend: str, dry_run: bool = F """ variables = {**values, **{key: value for key, value in BACKENDS[backend].items() if key != "label"}} written = [] - for layer in ("common", backend): + for layer in ("common", backend, f"licenses/{values['license']}"): root = SKELETON / layer for source in sorted(root.rglob("*")): relative = source.relative_to(root) @@ -241,13 +249,14 @@ def resolve(question: str, given: str | None, default: str | None, check: Check, 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)) +def resolve_choice(question: str, labels: dict[str, str], given: str | None, interactive: bool) -> str: + """Return the option given on the command line, else ask, else (with --yes) the first choice.""" + if given is not None or not interactive or len(labels) == 1: + return given or next(iter(labels)) import questionary - choices = [questionary.Choice(backend["label"], value=key) for key, backend in BACKENDS.items()] - answer = questionary.select("Documentation", choices=choices).ask() + choices = [questionary.Choice(label, value=key) for key, label in labels.items()] + answer = questionary.select(question, choices=choices).ask() if answer is None: raise KeyboardInterrupt return answer @@ -278,7 +287,11 @@ def new_project(args: argparse.Namespace) -> int: interactive, ) values["github_url"] = values["github_url"].rstrip("/").removesuffix(".git") - backend = resolve_backend(args.docs, interactive) + values["license"] = resolve_choice(f"License ({OTHER_LICENSES})", LICENSES, args.license, interactive) + values["year"] = str(datetime.date.today().year) + backend = resolve_choice( + "Documentation", {key: spec["label"] for key, spec in BACKENDS.items()}, args.docs, interactive + ) print(f"\nProject in {dest}:") for key, value in {**values, "docs": backend}.items(): @@ -326,6 +339,9 @@ def main(argv: list[str] | None = None) -> int: 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( + "--license", choices=list(LICENSES), help=f"license (default: {next(iter(LICENSES))}; {OTHER_LICENSES})" + ) 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) diff --git a/src/scene_repo_template/skeleton/common/README.md.tmpl b/src/scene_repo_template/skeleton/common/README.md.tmpl index 4054a23..6a18c42 100644 --- a/src/scene_repo_template/skeleton/common/README.md.tmpl +++ b/src/scene_repo_template/skeleton/common/README.md.tmpl @@ -22,4 +22,4 @@ $docs_readme ## License -Apache-2.0: see `LICENSE`. +${license}: see `LICENSE`. diff --git a/src/scene_repo_template/skeleton/common/pyproject.toml.tmpl b/src/scene_repo_template/skeleton/common/pyproject.toml.tmpl index 3c2f141..45dcb7f 100644 --- a/src/scene_repo_template/skeleton/common/pyproject.toml.tmpl +++ b/src/scene_repo_template/skeleton/common/pyproject.toml.tmpl @@ -6,7 +6,7 @@ requires = [ "setuptools>=77" ] name = "$dist_name" description = "$description" readme = "README.md" -license = "Apache-2.0" # keep in line with LICENSE +license = "${license}" # keep in line with LICENSE license-files = [ "LICENSE" ] authors = [ { name = "$author" } ] requires-python = ">=3.10" 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 f0620ce..6370a11 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 @@ -5,7 +5,7 @@ project: title: "$title" description: "$description" github: $github_url - license: Apache-2.0 + license: ${license} # 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: diff --git a/src/scene_repo_template/skeleton/common/LICENSE b/src/scene_repo_template/skeleton/licenses/Apache-2.0/LICENSE similarity index 100% rename from src/scene_repo_template/skeleton/common/LICENSE rename to src/scene_repo_template/skeleton/licenses/Apache-2.0/LICENSE diff --git a/src/scene_repo_template/skeleton/licenses/BSD-3-Clause/LICENSE.tmpl b/src/scene_repo_template/skeleton/licenses/BSD-3-Clause/LICENSE.tmpl new file mode 100644 index 0000000..39f4422 --- /dev/null +++ b/src/scene_repo_template/skeleton/licenses/BSD-3-Clause/LICENSE.tmpl @@ -0,0 +1,28 @@ +BSD 3-Clause License + +Copyright (c) ${year}, ${author} + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/src/scene_repo_template/skeleton/licenses/MIT/LICENSE.tmpl b/src/scene_repo_template/skeleton/licenses/MIT/LICENSE.tmpl new file mode 100644 index 0000000..a7c4ce6 --- /dev/null +++ b/src/scene_repo_template/skeleton/licenses/MIT/LICENSE.tmpl @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) ${year} ${author} + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/tests/test_cli.py b/tests/test_cli.py index 7dc1941..7bfc9dc 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,3 +1,4 @@ +import datetime import importlib import importlib.metadata import json @@ -14,7 +15,7 @@ from scene_repo_template import cli REPOSITORY = Path(__file__).parents[1] -ANSWERS = {"dist_name", "package", "title", "description", "author", "github_url"} +ANSWERS = {"dist_name", "package", "title", "description", "author", "github_url", "license", "year"} # Installing and building a generated project needs uv, Node.js and the network; CI sets this variable. E2E = pytest.mark.skipif( @@ -78,6 +79,31 @@ def test_the_backends_ship_the_same_logo_and_favicon(): assert len(copies) == 1, image +def test_every_license_has_its_text(): + assert {path.name for path in (cli.SKELETON / "licenses").iterdir()} == set(cli.LICENSES) + + +@pytest.mark.parametrize("license", cli.LICENSES) +def test_the_license_reaches_every_file_that_names_it(tmp_path, license): + dest = generate(tmp_path, f"--license={license}", "--author=A. Person") + assert f'license = "{license}"' in (dest / "pyproject.toml").read_text(encoding="utf-8") + assert f"{license}: see `LICENSE`." in (dest / "README.md").read_text(encoding="utf-8") + assert f"license: {license}\n" in (dest / "docs" / "myst.yml").read_text(encoding="utf-8") + text = (dest / "LICENSE").read_text(encoding="utf-8") + # Apache-2.0 has no copyright line to fill; the others start with one. + if license != "Apache-2.0": + assert f"{datetime.date.today().year}" in text.splitlines()[2] + assert "A. Person" in text.splitlines()[2] + + +def test_the_default_license_is_apache(tmp_path): + assert "Apache License" in (generate(tmp_path) / "LICENSE").read_text(encoding="utf-8") + + +def test_an_unknown_license_is_refused(tmp_path): + assert refused(tmp_path, "--license=GPL-3.0") + + def test_the_docs_extra_follows_the_backend(tmp_path): pyproject = (generate(tmp_path, "--docs=mkdocs") / "pyproject.toml").read_text(encoding="utf-8") assert '"mkdocstrings[python]>=0.27"' in pyproject @@ -172,13 +198,14 @@ def test_text_files_are_written_with_lf_and_binary_files_byte_for_byte(tmp_path, skeleton = tmp_path / "skeleton" (skeleton / "common").mkdir(parents=True) (skeleton / "jupyter-book").mkdir() + (skeleton / "licenses" / "MIT").mkdir(parents=True) (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") + cli.generate(dest, {"package": "pkg", "license": "MIT"}, "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