27 |
--------------------------------------------------------------------------------
/src/fair_python_cookiecutter/template/{{ cookiecutter.project_slug }}/docs/quickstart.md:
--------------------------------------------------------------------------------
1 | --8<-- "README.md:quickstart"
2 |
--------------------------------------------------------------------------------
/src/fair_python_cookiecutter/template/{{ cookiecutter.project_slug }}/docs/scripts/coverage_status.py:
--------------------------------------------------------------------------------
1 | """Mkdocs hook to run tests with coverage collection and generate a badge."""
2 |
3 | import logging
4 | from io import StringIO
5 | from pathlib import Path
6 |
7 | import anybadge
8 | import pytest
9 | from coverage import Coverage
10 |
11 | log = logging.getLogger("mkdocs")
12 |
13 |
14 | badge_colors = {
15 | 20.0: "red",
16 | 40.0: "orange",
17 | 60.0: "yellow",
18 | 80.0: "greenyellow",
19 | 90.0: "green",
20 | }
21 | """Colors for overall coverage percentage (0-100)."""
22 |
23 |
24 | def on_pre_build(config): # noqa
25 | """Generate coverage report if it is missing and create a badge."""
26 | if not Path("htmlcov").is_dir() or not Path(".coverage").is_file():
27 | log.info("Missing htmlcov or .coverage, running pytest to collect.")
28 | pytest.main(["--cov", "--cov-report=html"])
29 | else:
30 | log.info("Using existing coverage data.")
31 |
32 | cov = Coverage()
33 | cov.load()
34 | cov_percent = int(cov.report(file=StringIO()))
35 | log.info(f"Test Coverage: {cov_percent}%, generating badge.")
36 |
37 | badge = anybadge.Badge(
38 | "coverage",
39 | cov_percent,
40 | value_prefix=" ",
41 | value_suffix="% ",
42 | thresholds=badge_colors,
43 | )
44 |
45 | badge_svg = Path("docs/coverage_badge.svg")
46 | if badge_svg.is_file():
47 | badge_svg.unlink()
48 | badge.write_badge(badge_svg)
49 |
--------------------------------------------------------------------------------
/src/fair_python_cookiecutter/template/{{ cookiecutter.project_slug }}/docs/scripts/gen_ref_pages.py:
--------------------------------------------------------------------------------
1 | """Generate the code reference pages.
2 |
3 | See: https://mkdocstrings.github.io/recipes/
4 | """
5 |
6 | from pathlib import Path
7 |
8 | import mkdocs_gen_files
9 |
10 | nav = mkdocs_gen_files.Nav()
11 |
12 | for path in sorted(Path("src").rglob("*.py")):
13 | module_path = path.relative_to("src").with_suffix("")
14 | doc_path = path.relative_to("src").with_suffix(".md")
15 | full_doc_path = Path("reference", doc_path)
16 |
17 | parts = list(module_path.parts)
18 |
19 | if parts[-1] == "__init__":
20 | parts = parts[:-1]
21 | doc_path = doc_path.with_name("index.md")
22 | full_doc_path = full_doc_path.with_name("index.md")
23 | elif parts[-1] == "__main__":
24 | continue
25 |
26 | nav[parts] = doc_path.as_posix()
27 |
28 | with mkdocs_gen_files.open(full_doc_path, "w") as fd:
29 | identifier = ".".join(parts)
30 | print("::: " + identifier, file=fd)
31 |
32 | mkdocs_gen_files.set_edit_path(full_doc_path, path)
33 |
34 | with mkdocs_gen_files.open("reference/SUMMARY.md", "w") as nav_file:
35 | nav_file.writelines(nav.build_literate_nav())
36 |
--------------------------------------------------------------------------------
/src/fair_python_cookiecutter/template/{{ cookiecutter.project_slug }}/mkdocs.yml:
--------------------------------------------------------------------------------
1 | # basic configuration:
2 | site_name: "{{ cookiecutter.project_name }}"
3 | site_description: "{{ cookiecutter.project_description }}"
4 | repo_name: "{{ cookiecutter.project_slug }}"
5 |
6 | site_url: "{{ cookiecutter.project_pages_url }}"
7 | repo_url: "{{ cookiecutter.project_repo_url }}"
8 |
9 | # add support for buttons to edit a documentation page/section (both github and gitlab):
10 | edit_uri: "edit/main/docs/"
11 |
12 | # navigation structure (TOC + respective markdown files):
13 | nav:
14 | - Home:
15 | - Overview: index.md
16 | - Changelog: changelog.md
17 | - Credits: credits.md
18 | - License: license.md
19 | - Usage:
20 | - Quickstart: quickstart.md
21 | - API: reference/ # auto-generated (from Python docstrings)
22 | - Development:
23 | - How To Contribute: contributing.md
24 | - Developer Guide: dev_guide.md
25 | - Code of Conduct: code_of_conduct.md
26 | - Coverage Report: coverage.md # cov report (pytest --cov --cov-report html)
27 |
28 | extra:
29 | # social links in footer: https://squidfunk.github.io/mkdocs-material/setup/setting-up-the-footer/?h=social#social-links
30 | social:
31 | # you can add more various social links, if desired
32 | # - icon: 'fontawesome/solid/globe'
33 | # link: 'https://your-organization-homepage.org'
34 | # - icon: 'fontawesome/brands/github'
35 | # link: 'https://github.com/your_github_organization'
36 | # - icon: 'fontawesome/brands/gitlab'
37 | # link: 'https://your.gitlab.instance/your_organization'
38 |
39 | # versioned docs: https://squidfunk.github.io/mkdocs-material/setup/setting-up-versioning/
40 | version:
41 | provider: mike
42 |
43 | # optimization for offline usage:
44 | use_directory_urls: !ENV [OFFLINE, false]
45 |
46 | theme:
47 | # See here for customization guide: https://squidfunk.github.io/mkdocs-material/setup/
48 | name: "material"
49 | custom_dir: "docs/overrides"
50 |
51 | features:
52 | - content.action.edit
53 | - content.action.view
54 | # https://squidfunk.github.io/mkdocs-material/reference/code-blocks/#adding-annotations
55 | - content.code.annotate
56 | # https://squidfunk.github.io/mkdocs-material/setup/setting-up-the-header/
57 | - header.autohide
58 | # https://squidfunk.github.io/mkdocs-material/setup/setting-up-navigation/
59 | - navigation.footer
60 | - navigation.instant
61 | - navigation.tabs
62 | - navigation.tabs.sticky
63 | - navigation.tracking
64 | - navigation.top
65 | # https://squidfunk.github.io/mkdocs-material/setup/setting-up-site-search/
66 | - search.highlight
67 | - search.suggest
68 |
69 | # light/dark mode toggle: https://squidfunk.github.io/mkdocs-material/setup/changing-the-colors/
70 | palette:
71 | - media: "(prefers-color-scheme: light)"
72 | scheme: default
73 | toggle:
74 | icon: material/brightness-7
75 | name: Switch to dark mode
76 | - media: "(prefers-color-scheme: dark)"
77 | scheme: slate
78 | toggle:
79 | icon: material/brightness-4
80 | name: Switch to light mode
81 |
82 | extra_css:
83 | # list of extra CSS files to override and configure defaults:
84 | - css/style.css
85 |
86 | markdown_extensions:
87 | # Enable permalink to sections:
88 | - toc:
89 | permalink: true
90 | # Setting HTML/CSS attributes: https://python-markdown.github.io/extensions/attr_list/
91 | - attr_list
92 | # Definitions: https://python-markdown.github.io/extensions/definition_lists/
93 | - def_list
94 | # Footnotes: https://squidfunk.github.io/mkdocs-material/reference/footnotes/
95 | - footnotes
96 | # Various boxes: https://squidfunk.github.io/mkdocs-material/reference/admonitions/
97 | - admonition
98 | - pymdownx.details
99 | - pymdownx.superfences
100 | # smart links: https://facelessuser.github.io/pymdown-extensions/extensions/magiclink/
101 | - pymdownx.magiclink:
102 | repo_url_shorthand: true
103 | # Superscript: https://facelessuser.github.io/pymdown-extensions/extensions/caret/
104 | - pymdownx.caret
105 | # Strikethrough markup: https://facelessuser.github.io/pymdown-extensions/extensions/tilde/
106 | - pymdownx.tilde
107 | # Auto-Unicode for common symbols: https://facelessuser.github.io/pymdown-extensions/extensions/smartsymbols/
108 | - pymdownx.smartsymbols
109 | # Github-style task list: https://squidfunk.github.io/mkdocs-material/setup/extensions/python-markdown-extensions/#tasklist
110 | - pymdownx.tasklist:
111 | custom_checkbox: true
112 | # Tabbed boxes: https://facelessuser.github.io/pymdown-extensions/extensions/tabbed/
113 | - pymdownx.tabbed:
114 | alternate_style: true
115 | # Inlining markdown: https://facelessuser.github.io/pymdown-extensions/extensions/snippets/
116 | - pymdownx.snippets:
117 | check_paths: true
118 | # Icons and Emoji: https://squidfunk.github.io/mkdocs-material/reference/icons-emojis/
119 | - pymdownx.emoji:
120 | emoji_index: !!python/name:material.extensions.emoji.twemoji
121 | emoji_generator: !!python/name:material.extensions.emoji.to_svg
122 |
123 | plugins:
124 | # default search box (must be listed if plugins are added)
125 | - search
126 | # embed coverage report: https://pawamoy.github.io/mkdocs-coverage/
127 | - coverage
128 | # execute code (e.g. generate diagrams): https://pawamoy.github.io/markdown-exec/
129 | - markdown-exec
130 | # automatic API docs: https://mkdocstrings.github.io/recipes/#automatic-code-reference-pages
131 | - gen-files:
132 | scripts:
133 | - docs/scripts/gen_ref_pages.py
134 | - literate-nav:
135 | nav_file: SUMMARY.md
136 | - section-index
137 | - mkdocstrings:
138 | handlers:
139 | python:
140 | paths: [src]
141 | options:
142 | members_order: source
143 | separate_signature: true
144 | show_signature_annotations: true
145 | # https://squidfunk.github.io/mkdocs-material/setup/building-for-offline-usage/#built-in-offline-plugin
146 | # To allow building for offline usage, e.g. with: OFFLINE=true mkdocs build
147 | - offline:
148 | enabled: !ENV [OFFLINE, false]
149 | # to make multi-version docs work right
150 | - mike
151 |
152 | hooks:
153 | - docs/scripts/coverage_status.py
154 |
155 | strict: true
156 |
--------------------------------------------------------------------------------
/src/fair_python_cookiecutter/template/{{ cookiecutter.project_slug }}/pyproject.toml:
--------------------------------------------------------------------------------
1 | [project]
2 | # ---- DO NOT EDIT - core project metadata managed by somesy ----
3 | # to update, edit values in [tool.somesy.project] section
4 | # and run somesy: poetry run somesy
5 | name = "{{ cookiecutter.project_slug }}"
6 | version = "{{ cookiecutter.project_version }}"
7 | description = "{{ cookiecutter.project_description }}"
8 | license = {text = "{{ cookiecutter.project_license }}"}
9 |
10 | authors = [{name = "{{ cookiecutter.author_first_name }} {{ cookiecutter.author_last_name }}", email = "{{ cookiecutter.author_email }}"}]
11 | maintainers = [{name = "{{ cookiecutter.author_first_name }} {{ cookiecutter.author_last_name }}", email = "{{ cookiecutter.author_email }}"}]
12 |
13 | requires-python = ">=3.9"
14 | keywords = {{ cookiecutter.project_keywords.split() | jsonify }}
15 | dependencies = [
16 | {%- if cookiecutter.init_cli %}
17 | "typer[all]>=0.12.3",
18 | {%- endif %}
19 | {%- if cookiecutter.init_api %}
20 | "fastapi>=0.111.1",
21 | "uvicorn>=0.30.4",
22 | {%- endif %}
23 | ]
24 |
25 | # ----------------------------------------------------------------
26 |
27 | # Python- and Poetry-specific metadata
28 | # ------------------------------------
29 | readme = "README.md"
30 | classifiers = [
31 | # TODO: update the classifier strings
32 | # (see https://pypi.org/classifiers/)
33 | "Operating System :: POSIX :: Linux",
34 | "Intended Audience :: Science/Research",
35 | "Intended Audience :: Developers",
36 | ]
37 |
38 | # ---- managed by somesy, see .somesy.toml ----
39 | [project.urls]
40 | repository = "{{ cookiecutter.project_repo_url }}"
41 | homepage = "{{ cookiecutter.project_pages_url }}"
42 | documentation = "{{ cookiecutter.project_pages_url }}"
43 |
44 | [tool.poetry]
45 | # the Python packages that will be included in a built distribution:
46 | packages = [{include = "{{ cookiecutter.project_package }}", from = "src"}]
47 |
48 | # always include basic info for humans and core metadata in the distribution,
49 | # include files related to test and documentation only in sdist:
50 | include = [
51 | "*.md", "LICENSE", "LICENSES", "REUSE.toml", "CITATION.cff", "codemeta.json",
52 | { path = "mkdocs.yml", format = "sdist" },
53 | { path = "docs", format = "sdist" },
54 | { path = "tests", format = "sdist" },
55 | ]
56 |
57 | [tool.poetry.dependencies]
58 | python = ">=3.9,<4.0"
59 |
60 | [tool.poetry.group.dev.dependencies]
61 | poethepoet = "^0.27.0"
62 | pre-commit = "^3.5.0"
63 | pytest = "^8.3.2"
64 | pytest-cov = "^5.0.0"
65 | hypothesis = "^6.108.5"
66 | licensecheck = "^2024.2"
67 | {%- if cookiecutter.init_api %}
68 | httpx = "^0.27.0"
69 | {%- endif %}
70 |
71 | [tool.poetry.group.docs]
72 | optional = true
73 |
74 | [tool.poetry.group.docs.dependencies]
75 | mkdocs = "^1.6.0"
76 | mkdocstrings = {extras = ["python"], version = "^0.25.2"}
77 | mkdocs-material = "^9.5.30"
78 | mkdocs-gen-files = "^0.5.0"
79 | mkdocs-literate-nav = "^0.6.1"
80 | mkdocs-section-index = "^0.3.9"
81 | mkdocs-macros-plugin = "^1.0.5"
82 | markdown-include = "^0.8.1"
83 | pymdown-extensions = "^10.9"
84 | markdown-exec = {extras = ["ansi"], version = "^1.9.3"}
85 | mkdocs-coverage = "^1.1.0"
86 | mike = "^2.1.2"
87 | anybadge = "^1.14.0"
88 | interrogate = "^1.7.0"
89 | black = "^24.4.2"
90 |
91 | [project.scripts]
92 | # put your script entrypoints here
93 | # some-script = 'module.submodule:some_object'
94 | {%- if cookiecutter.init_cli %}
95 | {{ cookiecutter.project_slug }}-cli = '{{ cookiecutter.project_package }}.cli:app'
96 | {%- endif %}
97 | {%- if cookiecutter.init_api %}
98 | {{ cookiecutter.project_slug }}-api = '{{ cookiecutter.project_package }}.api:run'
99 | {%- endif %}
100 |
101 | [build-system]
102 | requires = ["poetry-core>=2.0"]
103 | build-backend = "poetry.core.masonry.api"
104 |
105 | # NOTE: You can run the following with "poetry poe TASK"
106 | [tool.poe.tasks]
107 | init-dev = "pre-commit install" # run once after clone to enable various tools
108 | lint = "pre-commit run" # pass --all-files to check everything
109 | test = "pytest" # pass --cov to also collect coverage info
110 | docs = "mkdocs build" # run this to generate local documentation
111 | licensecheck = "licensecheck" # run this when you add new deps
112 |
113 | # Tool Configurations
114 | # -------------------
115 | [tool.pytest.ini_options]
116 | pythonpath = ["src"]
117 | addopts = "--cov-report=term-missing:skip-covered"
118 | filterwarnings = [
119 | # Example:
120 | # "ignore::DeprecationWarning:importlib_metadata.*"
121 | ]
122 |
123 | [tool.coverage.run]
124 | source = ["{{ cookiecutter.project_package }}"]
125 |
126 | [tool.coverage.report]
127 | exclude_lines = [
128 | "pragma: no cover",
129 | "def __repr__",
130 | "if self.debug:",
131 | "if settings.DEBUG",
132 | "raise AssertionError",
133 | "raise NotImplementedError",
134 | "if 0:",
135 | "if TYPE_CHECKING:",
136 | "if __name__ == .__main__.:",
137 | "class .*\\bProtocol\\):",
138 | "@(abc\\.)?abstractmethod",
139 | ]
140 |
141 | [tool.ruff.lint]
142 | # see here: https://docs.astral.sh/ruff/rules/
143 | # ruff by default works like a mix of flake8, autoflake and black
144 | # we extend default linter rules to substitute:
145 | # flake8-bugbear, pydocstyle, isort, flake8-bandit
146 | extend-select = ["B", "D", "I", "S"]
147 | ignore = ["D203", "D213", "D407", "B008", "S101", "D102", "D103"]
148 |
149 | [tool.bandit]
150 | exclude_dirs = ["tests", "scripts"]
151 |
152 | [tool.licensecheck]
153 | using = "poetry"
154 |
--------------------------------------------------------------------------------
/src/fair_python_cookiecutter/template/{{ cookiecutter.project_slug }}/somesy.toml:
--------------------------------------------------------------------------------
1 | # NOTE: this repository uses the tool somesy to help you easily maintain
2 | # and synchronize all the high-level project metadata across multiple files.
3 | # To see which other metadata can be added, check out the somesy documentation
4 | # https://materials-data-science-and-informatics.github.io/somesy/main/
5 | [project]
6 | name = "{{ cookiecutter.project_slug }}"
7 | version = "{{ cookiecutter.project_version }}"
8 | description = "{{ cookiecutter.project_description }}"
9 | license = "{{ cookiecutter.project_license }}"
10 |
11 | keywords = {{ cookiecutter.project_keywords.split() | jsonify }}
12 | repository = "{{ cookiecutter.project_repo_url }}"
13 | homepage = "{{ cookiecutter.project_pages_url }}"
14 | documentation = "{{ cookiecutter.project_pages_url }}"
15 |
16 | [[project.people]]
17 | given-names = "{{ cookiecutter.author_first_name }}"
18 | family-names = "{{ cookiecutter.author_last_name }}"
19 | email = "{{ cookiecutter.author_email }}"
20 | {%- if cookiecutter.author_orcid_url %}
21 | orcid = "{{ cookiecutter.author_orcid_url }}"
22 | {%- else %}
23 | # orcid = "https://orcid.org/your-orcid"
24 | {%- endif %}
25 | author = true
26 | maintainer = true
27 |
28 | # You also can add more authors, maintainers or contributors here:
29 | # [[project.people]]
30 | # given-names = "Another"
31 | # family-names = "Contributor"
32 | # email = "contributor@email.com"
33 | # orcid = "https://orcid.org/0123-4567-8910-1112"
34 | # ...
35 |
36 | [config]
37 | verbose = true
38 |
--------------------------------------------------------------------------------
/src/fair_python_cookiecutter/template/{{ cookiecutter.project_slug }}/src/{{ cookiecutter.project_package }}/__init__.py:
--------------------------------------------------------------------------------
1 | """Top level module of the project."""
2 |
3 | from importlib.metadata import version
4 | from typing import Final
5 |
6 | # Set version, it will use version from pyproject.toml if defined
7 | __version__: Final[str] = version(__package__ or __name__)
8 |
--------------------------------------------------------------------------------
/src/fair_python_cookiecutter/template/{{ cookiecutter.project_slug }}/src/{{ cookiecutter.project_package }}/api.py:
--------------------------------------------------------------------------------
1 | """API of {{ cookiecutter.project_slug }}."""
2 | from fastapi import FastAPI, HTTPException
3 |
4 | from {{ cookiecutter.project_package }}.lib import CalcOperation, calculate
5 |
6 | app = FastAPI()
7 |
8 |
9 | @app.get("/calculate/{op}")
10 | def calc(op: CalcOperation, x: int, y: int = 0):
11 | """Return result of calculation on two integers."""
12 | try:
13 | return calculate(op, x, y)
14 |
15 | except (ZeroDivisionError, ValueError, NotImplementedError) as e:
16 | if isinstance(e, ZeroDivisionError):
17 | err = f"Cannot divide x={x} by y=0!"
18 | else:
19 | err = str(e)
20 | raise HTTPException(status_code=422, detail=err) from e
21 |
22 | def run():
23 | import uvicorn
24 | uvicorn.run(app, host="127.0.0.1", port=8000)
25 |
--------------------------------------------------------------------------------
/src/fair_python_cookiecutter/template/{{ cookiecutter.project_slug }}/src/{{ cookiecutter.project_package }}/cli.py:
--------------------------------------------------------------------------------
1 | """CLI of {{ cookiecutter.project_slug }}."""
2 |
3 | import typer
4 |
5 | from {{ cookiecutter.project_package }}.lib import CalcOperation, calculate
6 |
7 | # create subcommand app
8 | say = typer.Typer()
9 |
10 | # create main app
11 | app = typer.Typer()
12 | app.add_typer(say, name="say")
13 |
14 | # ----
15 |
16 |
17 | @app.command()
18 | def calc(op: CalcOperation, x: int, y: int):
19 | """Compute the result of applying an operation on x and y."""
20 | result: int = calculate(op, x, y)
21 | typer.echo(f"Result: {result}")
22 |
23 |
24 | # ----
25 |
26 |
27 | @say.command()
28 | def hello(name: str):
29 | """Greet a person."""
30 | print(f"Hello {name}")
31 |
32 |
33 | @say.command()
34 | def goodbye(name: str, formal: bool = False):
35 | """Say goodbye to a person."""
36 | if formal:
37 | print(f"Goodbye {name}. Have a good day.")
38 | else:
39 | print(f"Bye {name}!")
40 |
--------------------------------------------------------------------------------
/src/fair_python_cookiecutter/template/{{ cookiecutter.project_slug }}/src/{{ cookiecutter.project_package }}/lib.py:
--------------------------------------------------------------------------------
1 | """Core functionality of {{ cookiecutter.project_slug }}.
2 |
3 | This module can be used directly, or the functionality can be
4 | exposed using some other interface, such as CLI, GUI or an API.
5 | """
6 |
7 | from enum import Enum
8 |
9 |
10 | class CalcOperation(str, Enum):
11 | """Supported operations of `calculate`."""
12 |
13 | add = "add"
14 | multiply = "multiply"
15 | subtract = "subtract"
16 | divide = "divide"
17 | power = "power"
18 |
19 |
20 | def calculate(op: CalcOperation, x: int, y: int):
21 | """Calculate result of an operation on two integer numbers."""
22 | if not isinstance(op, CalcOperation):
23 | raise ValueError(f"Unknown operation: {op}")
24 |
25 | if op == CalcOperation.add:
26 | return x + y
27 | elif op == CalcOperation.multiply:
28 | return x * y
29 | elif op == CalcOperation.subtract:
30 | return x - y
31 | elif op == CalcOperation.divide:
32 | return x // y
33 |
34 | err = f"Operation {op} is not implemented!"
35 | raise NotImplementedError(err)
36 |
--------------------------------------------------------------------------------
/src/fair_python_cookiecutter/template/{{ cookiecutter.project_slug }}/temp-REUSE.toml:
--------------------------------------------------------------------------------
1 | version = 1
2 | SPDX-PackageName = "{{ cookiecutter.project_slug }}"
3 | SPDX-PackageSupplier = "{{ cookiecutter.author_name_email }}"
4 | SPDX-PackageDownloadLocation = "{{ cookiecutter.project_repo_url }}"
5 |
6 | [[annotations]]
7 | path = [".gitignore", "pyproject.toml", "poetry.lock", ".pre-commit-config.yaml", "codemeta.json", "CITATION.cff", "README.md", "RELEASE_NOTES.md", "CHANGELOG.md", "CODE_OF_CONDUCT.md", "AUTHORS.md", "CONTRIBUTING.md", ".gitlab-ci.yml", ".gitlab/**", ".github/**", "mkdocs.yml", "docs/**", "somesy.toml"]
8 | precedence = "aggregate"
9 | SPDX-FileCopyrightText = "{{ cookiecutter.copyright_text }}"
10 | SPDX-License-Identifier = "CC0-1.0"
11 |
12 | [[annotations]]
13 | path = ["src/{{ cookiecutter.project_package }}/**", "tests/**"]
14 | precedence = "aggregate"
15 | SPDX-FileCopyrightText = "{{ cookiecutter.copyright_text }}"
16 | SPDX-License-Identifier = "{{ cookiecutter.project_license }}"
17 |
--------------------------------------------------------------------------------
/src/fair_python_cookiecutter/template/{{ cookiecutter.project_slug }}/tests/__init__.py:
--------------------------------------------------------------------------------
1 | """Tests for {{ cookiecutter.project_slug }}."""
2 |
--------------------------------------------------------------------------------
/src/fair_python_cookiecutter/template/{{ cookiecutter.project_slug }}/tests/conftest.py:
--------------------------------------------------------------------------------
1 | """Global pytest configuration."""
2 |
3 | # NOTE: you can put your pytest fixtures here.
4 | #
5 | # Fixtures are very useful for automating repetitive test preparations
6 | # (such as accessing resources or creating test input files)
7 | # which are needed in multiple similar tests.
8 | #
9 | # see: https://docs.pytest.org/en/6.2.x/fixture.html
10 |
--------------------------------------------------------------------------------
/src/fair_python_cookiecutter/template/{{ cookiecutter.project_slug }}/tests/test_api.py:
--------------------------------------------------------------------------------
1 | """Test API."""
2 | from fastapi.testclient import TestClient
3 |
4 | from {{ cookiecutter.project_package }}.api import app
5 |
6 | client = TestClient(app)
7 |
8 |
9 | def test_calculate():
10 | response = client.get("/calculate/divide?x=5&y=2")
11 | assert response.status_code == 200
12 | assert response.json() == 2 # int division
13 |
14 | response = client.get("/calculate/divide?x=5&y=0")
15 | assert response.status_code == 422
16 | assert "y=0" in response.json()["detail"] # division by 0
17 |
18 | response = client.get("/calculate/add?x=3.14")
19 | assert response.status_code == 422 # float input
20 |
21 | response = client.get("/calculate/power?x=5")
22 | assert response.status_code == 422 # unsupported op
23 |
--------------------------------------------------------------------------------
/src/fair_python_cookiecutter/template/{{ cookiecutter.project_slug }}/tests/test_cli.py:
--------------------------------------------------------------------------------
1 | """Tests for the CLI."""
2 |
3 | import pytest
4 | from hypothesis import given
5 | from hypothesis import strategies as st
6 | from typer.testing import CliRunner
7 |
8 | from {{ cookiecutter.project_package }}.cli import app
9 |
10 | runner = CliRunner()
11 |
12 |
13 | def test_calc_addition():
14 | result = runner.invoke(app, ["calc", "add", "20", "22"])
15 |
16 | assert result.exit_code == 0
17 | assert result.stdout.strip() == "Result: 42"
18 |
19 |
20 | person_names = ["Jane", "John"]
21 |
22 |
23 | @pytest.mark.parametrize(
24 | "name, formal",
25 | [(name, formal) for name in person_names for formal in [False, True]],
26 | )
27 | def test_goodbye(name: str, formal: bool):
28 | args = ["say", "goodbye", name]
29 | if formal:
30 | args += ["--formal"]
31 |
32 | result = runner.invoke(app, args)
33 |
34 | assert result.exit_code == 0
35 | assert name in result.stdout
36 | if formal:
37 | assert "good day" in result.stdout
38 |
39 |
40 | # Example of hypothesis auto-generated inputs,
41 | # here the names are generated from a regular expression.
42 |
43 | # NOTE: this is not really a good regex for names!
44 | person_name_regex = r"^[A-Z]\w+$"
45 |
46 |
47 | @given(st.from_regex(person_name_regex))
48 | def test_hello(name: str):
49 | result = runner.invoke(app, ["say", "hello", name])
50 |
51 | assert result.exit_code == 0
52 | assert f"Hello {name}" in result.stdout
53 |
--------------------------------------------------------------------------------
/src/fair_python_cookiecutter/template/{{ cookiecutter.project_slug }}/tests/test_lib.py:
--------------------------------------------------------------------------------
1 | """Test for core library."""
2 |
3 | import pytest
4 | from hypothesis import assume, given
5 | from hypothesis import strategies as st
6 |
7 | from {{ cookiecutter.project_package }}.lib import CalcOperation, calculate
8 |
9 |
10 | def test_calculate_invalid():
11 | with pytest.raises(ZeroDivisionError):
12 | calculate(CalcOperation.divide, 123, 0)
13 |
14 | with pytest.raises(ValueError):
15 | calculate("invalid", 123, 0) # type: ignore
16 |
17 | with pytest.raises(NotImplementedError):
18 | calculate(CalcOperation.power, 2, 3)
19 |
20 |
21 | # Example of how hypothesis can be used to generate different
22 | # combinations of inputs automatically:
23 |
24 |
25 | @given(st.sampled_from(CalcOperation), st.integers(), st.integers())
26 | def test_calculate(op, x, y):
27 | # assume can be used to ad-hoc filter outputs -
28 | # if the assumption is violated, the test instance is skipped.
29 | # (better: use strategy combinators for filtering)
30 | assume(op != CalcOperation.divide or y != 0)
31 | assume(op != CalcOperation.power) # not supported
32 |
33 | # we basically just check that there is no exception and the type is right
34 | result = calculate(op, x, y)
35 | assert isinstance(result, int)
36 |
--------------------------------------------------------------------------------
/src/fair_python_cookiecutter/utils.py:
--------------------------------------------------------------------------------
1 | """Utilities for creation of template repository instances."""
2 |
3 | import json
4 | import platform
5 | import shutil
6 | import subprocess
7 | import sys
8 | from pathlib import Path
9 | from typing import Optional
10 | from uuid import uuid1
11 |
12 | from importlib_resources import files
13 | from platformdirs import user_runtime_path
14 |
15 | from .config import CookiecutterJson
16 |
17 |
18 | class TempDir:
19 | """Cross-platform temporary directory."""
20 |
21 | path: Path
22 |
23 | def __init__(self, prefix_dir: Optional[Path] = None, *, keep: bool = False):
24 | """Create a cross-platform temporary directory."""
25 | dir: Path = prefix_dir or user_runtime_path(ensure_exists=True)
26 | if dir and not dir.is_dir():
27 | raise ValueError(
28 | "Passed directory path does not exist or is not a directory!"
29 | )
30 |
31 | self.path = dir / f"template_{uuid1()}"
32 | self.path.mkdir()
33 | self.keep = keep
34 |
35 | def __enter__(self):
36 | """Enter context manager."""
37 | return self.path
38 |
39 | def __exit__(self, type, value, traceback):
40 | """Exit context manager."""
41 | if not self.keep:
42 | shutil.rmtree(self.path)
43 |
44 |
45 | TEMPLATE_DIR = files("fair_python_cookiecutter") / "template"
46 |
47 |
48 | def copy_template(
49 | tmp_dir: Optional[Path] = None, *, cookiecutter_json: CookiecutterJson = None
50 | ) -> Path:
51 | """Create final template based on given configuration, returns template root directory."""
52 | # if no config is given, use dummy default values (useful for testing)
53 | if not cookiecutter_json:
54 | with open(TEMPLATE_DIR / "cookiecutter.json", "r") as ccjson:
55 | ccjson_dct = json.load(ccjson)
56 | cookiecutter_json = CookiecutterJson.model_validate(ccjson_dct)
57 |
58 | # copy the meta-template (we do not fully hardcode the paths for robustness)
59 | template_root = None
60 | for path in TEMPLATE_DIR.glob("*"):
61 | trg_path = tmp_dir / path.name
62 | if path.is_dir():
63 | if path.name.startswith("{{ cookiecutter"):
64 | template_root = path
65 |
66 | shutil.copytree(path, trg_path)
67 | else:
68 | shutil.copyfile(path, trg_path)
69 |
70 | # write a fresh cookiecutter.json based on user configuration
71 | with open(tmp_dir / "cookiecutter.json", "w", encoding="utf-8") as f:
72 | f.write(cookiecutter_json.model_dump_json(indent=2, by_alias=True))
73 |
74 | if not template_root:
75 | raise RuntimeError(
76 | "Template root directory not identified, this must be a bug!"
77 | )
78 | return template_root
79 |
80 |
81 | def get_venv_path() -> Optional[Path]:
82 | """Return path of venv, if we detect being inside one."""
83 | return Path(sys.prefix) if sys.base_prefix != sys.prefix else None
84 |
85 |
86 | VENV_PATH: Optional[Path] = get_venv_path()
87 | """If set, the path of the virtual environment this tool is running in."""
88 |
89 |
90 | def venv_activate_cmd(venv_path: Path):
91 | if platform.system() != "Windows":
92 | return "source " + str(venv_path / "bin" / "activate")
93 | else:
94 | return str(venv_path / "Scripts" / "activate")
95 |
96 |
97 | def run_cmd(cmd: str, cwd: Path = None):
98 | subprocess.run(cmd.split(), cwd=cwd, check=True) # noqa: S603
99 |
--------------------------------------------------------------------------------
/tests/demo.yaml:
--------------------------------------------------------------------------------
1 | fair_python_cookiecutter:
2 | last_name: Pirogov
3 | first_name: Anton
4 | email: a.pirogov@fz-juelich.de
5 | orcid: 0000-0002-5077-7497
6 |
7 | affiliation: "Forschungszentrum Jülich GmbH - Institute for Materials Data Science and Informatics (IAS9)"
8 | copyright_holder: "Forschungszentrum Jülich GmbH - Institute for Materials Data Science and Informatics (IAS9) - Stefan Sandfeld