├── .coveragerc
├── .github
├── FUNDING.yml
├── disabled-workflows
│ ├── publish.yaml
│ └── test-slow.yaml
└── workflows
│ └── test.yaml
├── .gitignore
├── .pre-commit-config.yaml
├── LICENSE
├── README.md
├── pyproject.toml
├── templatize
├── tests
├── __init__.py
├── conftest.py
└── test_nothing.py
└── {{REPO_NAME_SNAKECASE}}
└── __init__.py
/.coveragerc:
--------------------------------------------------------------------------------
1 | [run]
2 | source = {{REPO_NAME_SNAKECASE}}
3 |
4 | [report]
5 | exclude_lines =
6 | # Have to re-enable the standard pragma
7 | pragma: no cover
8 |
9 | # Don't complain about missing debug-only code:
10 | def __repr__
11 | if self\.debug
12 |
13 | # Don't complain if tests don't hit defensive assertion code:
14 | raise AssertionError
15 | raise NotImplementedError
16 |
17 | # Don't complain if non-importable code isn't run:
18 | if __name__ == .__main__.:
--------------------------------------------------------------------------------
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | # These are supported funding model platforms
2 |
3 | github: [fkodom]
4 | custom: [fkodom.substack.com]
5 | # patreon: # Replace with a single Patreon username
6 |
--------------------------------------------------------------------------------
/.github/disabled-workflows/publish.yaml:
--------------------------------------------------------------------------------
1 | name: Publish
2 |
3 | on:
4 | workflow_dispatch: {}
5 | release:
6 | types:
7 | - created
8 |
9 | env:
10 | PYTHON_VERSION: 3.11
11 |
12 | permissions:
13 | contents: read # required for 'actions/checkout@v2'
14 | id-token: write # required for 'Build and Publish'
15 |
16 | jobs:
17 | publish:
18 | runs-on: ubuntu-latest
19 | steps:
20 | - uses: actions/checkout@v2
21 |
22 | - name: Install uv
23 | uses: astral-sh/setup-uv@v5
24 | with:
25 | python-version: ${{ env.PYTHON_VERSION }}
26 |
27 | - name: Build and Publish
28 | env:
29 | {{REPO_NAME_ALLCAPS}}_VERSION: ${{ github.event.release.tag_name }}
30 | run: |
31 | uv build
32 | uv publish
33 |
--------------------------------------------------------------------------------
/.github/disabled-workflows/test-slow.yaml:
--------------------------------------------------------------------------------
1 | name: Test Slow
2 |
3 | on:
4 | workflow_dispatch: {}
5 | pull_request:
6 | types: [opened]
7 |
8 | jobs:
9 | test:
10 | name: Test
11 | runs-on: ubuntu-latest
12 | continue-on-error: true
13 |
14 | strategy:
15 | matrix:
16 | python: ["3.10"]
17 |
18 | steps:
19 | - name: Checkout
20 | uses: actions/checkout@v2
21 |
22 | - name: Install uv
23 | uses: astral-sh/setup-uv@v5
24 | with:
25 | python-version: ${{ matrix.python }}
26 |
27 | - name: Templatize
28 | # Templatize the repo before attempting to run tests. (Tests will fail
29 | # otherwise, due to syntax errors.)
30 | # NOTE: Check if the 'templatize' script exists, so that this doesn't
31 | # immediately fail for repos that have already run the script.
32 | run: |
33 | if test -f "templatize"; then
34 | ./templatize
35 | fi
36 |
37 | - name: Install Package
38 | run: |
39 | uv pip install -e .[test]
40 |
41 | - name: Test
42 | run: |
43 | uv run pytest --slow --cov --cov-report term-missing --cov-fail-under 80 tests/
44 |
--------------------------------------------------------------------------------
/.github/workflows/test.yaml:
--------------------------------------------------------------------------------
1 | name: Test
2 |
3 | on:
4 | workflow_dispatch: {}
5 | push: {}
6 |
7 | jobs:
8 | test:
9 | name: Test
10 | runs-on: ubuntu-latest
11 | continue-on-error: true
12 |
13 | strategy:
14 | matrix:
15 | python: ["3.10", "3.11", "3.12"]
16 |
17 | steps:
18 | - name: Checkout
19 | uses: actions/checkout@v2
20 |
21 | - name: Install uv
22 | uses: astral-sh/setup-uv@v5
23 | with:
24 | python-version: ${{ matrix.python }}
25 |
26 | - name: Templatize
27 | # Templatize the repo before attempting to run tests. (Tests will fail
28 | # otherwise, due to syntax errors.)
29 | # NOTE: Check if the 'templatize' script exists, so that this doesn't
30 | # immediately fail for repos that have already run the script.
31 | run: |
32 | if test -f "templatize"; then
33 | ./templatize
34 | fi
35 |
36 | - name: Install Package
37 | run: |
38 | uv pip install -e .[test]
39 |
40 | - name: Test
41 | run: |
42 | uv run ruff check .
43 | uv run pytest --cov --cov-report term-missing --cov-fail-under 80 tests/
44 | uv run mypy
45 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .vscode
2 | worktrees/
3 |
4 | # Byte-compiled / optimized / DLL files
5 | __pycache__/
6 | *.py[cod]
7 | *$py.class
8 |
9 | # C extensions
10 | *.so
11 |
12 | # Distribution / packaging
13 | .Python
14 | build/
15 | develop-eggs/
16 | dist/
17 | downloads/
18 | eggs/
19 | .eggs/
20 | lib/
21 | lib64/
22 | parts/
23 | sdist/
24 | var/
25 | wheels/
26 | pip-wheel-metadata/
27 | share/python-wheels/
28 | *.egg-info/
29 | .installed.cfg
30 | *.egg
31 | MANIFEST
32 |
33 | # PyInstaller
34 | # Usually these files are written by a python script from a template
35 | # before PyInstaller builds the exe, so as to inject date/other infos into it.
36 | *.manifest
37 | *.spec
38 |
39 | # Installer logs
40 | pip-log.txt
41 | pip-delete-this-directory.txt
42 |
43 | # Unit test / coverage reports
44 | htmlcov/
45 | .tox/
46 | .nox/
47 | .coverage
48 | .coverage.*
49 | .cache
50 | nosetests.xml
51 | coverage.xml
52 | *.cover
53 | *.py,cover
54 | .hypothesis/
55 | .pytest_cache/
56 |
57 | # Translations
58 | *.mo
59 | *.pot
60 |
61 | # Django stuff:
62 | *.log
63 | local_settings.py
64 | db.sqlite3
65 | db.sqlite3-journal
66 |
67 | # Flask stuff:
68 | instance/
69 | .webassets-cache
70 |
71 | # Scrapy stuff:
72 | .scrapy
73 |
74 | # Sphinx documentation
75 | docs/_build/
76 |
77 | # PyBuilder
78 | target/
79 |
80 | # Jupyter Notebook
81 | .ipynb_checkpoints
82 |
83 | # IPython
84 | profile_default/
85 | ipython_config.py
86 |
87 | # pyenv
88 | .python-version
89 |
90 | # pipenv
91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies
93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not
94 | # install all needed dependencies.
95 | #Pipfile.lock
96 |
97 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow
98 | __pypackages__/
99 |
100 | # Celery stuff
101 | celerybeat-schedule
102 | celerybeat.pid
103 |
104 | # SageMath parsed files
105 | *.sage.py
106 |
107 | # Environments
108 | .env
109 | .venv
110 | env/
111 | venv/
112 | ENV/
113 | env.bak/
114 | venv.bak/
115 |
116 | # Spyder project settings
117 | .spyderproject
118 | .spyproject
119 |
120 | # Rope project settings
121 | .ropeproject
122 |
123 | # mkdocs documentation
124 | /site
125 |
126 | # mypy
127 | .mypy_cache/
128 | .dmypy.json
129 | dmypy.json
130 |
131 | # Pyre type checker
132 | .pyre/
133 |
--------------------------------------------------------------------------------
/.pre-commit-config.yaml:
--------------------------------------------------------------------------------
1 | repos:
2 | - repo: local
3 | hooks:
4 | - id: black
5 | name: black
6 | stages: [pre-commit]
7 | language: system
8 | entry: black
9 | types: [python]
10 |
11 | - id: ruff
12 | name: ruff check
13 | stages: [pre-commit]
14 | language: system
15 | entry: ruff check
16 | types: [python]
17 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2022 Frank Odom
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # {{REPO_NAME}}
2 |
3 | A simple template for Python projects, with CI/CD configured through GitHub Actions. Compatible with any virtual environment manager (e.g. `uv`, `venv`, `pyenv`, `poetry`, `conda`).
4 |
5 |
6 | ## Usage
7 |
8 | 1. Create a new repository, using this one as a template.
9 | 2. Run the `templatize` script:
10 | ```bash
11 | ./templatize
12 | ```
13 |
14 | This updates placeholders like `{{REPO_NAME}}`, so everything is configured with your username, repo name, email, etc.
15 | 3. Commit and push the changes.
16 | ```bash
17 | git add .
18 | git commit -m "Templatize"
19 | git push
20 | ```
21 | 4. (Probably) delete this section of the README.
22 |
23 | ## Install
24 |
25 | ```bash
26 | pip install "{{REPO_NAME}} @ git+ssh://git@github.com/{{REPO_OWNER}}/{{REPO_NAME}}.git"
27 |
28 | # Install all dev dependencies (tests etc.)
29 | pip install "{{REPO_NAME}}[test] @ git+ssh://git@github.com/{{REPO_OWNER}}/{{REPO_NAME}}.git"
30 |
31 | # Setup pre-commit hooks
32 | pre-commit install
33 | ```
34 |
35 |
36 | ## Tooling
37 |
38 | | Tool | Description | Runs on |
39 | | --- | --- | --- |
40 | | [black](https://github.com/psf/black) | Code formatter | - `git commit` (through `pre-commit`)
- `git push`
- pull requests |
41 | | [ruff](https://github.com/astral-sh/ruff) | Code linter | - `git commit` (through `pre-commit`)
- `git push`
- pull requests |
42 | | [pytest](https://github.com/pytest-dev/pytest) | Unit testing framework | - `git push`
- pull requests |
43 | | [mypy](https://github.com/python/mypy) | Static type checker | - `git push`
- pull requests |
44 | | [pre-commit](https://github.com/pre-commit/pre-commit) | Pre-commit hooks | - `git commit` |
45 | | [twine](https://github.com/pypa/twine) $\dagger$ | PyPI package uploader | - New release (`git tag`) |
46 |
47 | > $\dagger$ Requires enabling the `publish.yaml` workflow. To activate, move the file from `.github/disabled-workflows/publish.yaml.disabled` to `.github/workflows/publish.yaml`, and set a valid PyPI token as `PYPI_API_TOKEN` in the repo secrets.
48 | >
49 | > Then tag a new release of this repo, and GHA will automatically build and publish a Python wheel (`.whl`) to PyPI.
50 |
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
1 | [build-system]
2 | requires = ["setuptools", "setuptools-scm"]
3 |
4 | [project]
5 | name = "{{REPO_NAME}}"
6 | authors = [
7 | {name = "{{GIT_USER_NAME}}", email = "{{GIT_USER_EMAIL}}"},
8 | ]
9 | description = "{{REPO_NAME}}"
10 | license = {text = "MIT"}
11 | dynamic = ["version", "readme"] # NOTE: Must be in sync with [tool.setuptools.dynamic] below
12 | dependencies = []
13 | requires-python = ">=3.8"
14 | classifiers = ["Programming Language :: Python :: 3"]
15 |
16 | [tool.setuptools.dynamic]
17 | # NOTE: Must be in sync with 'project.dynamic' above
18 | version = {attr = "{{REPO_NAME_SNAKECASE}}.VERSION"}
19 | readme = {file = ["README.md"], content-type = "text/markdown"}
20 |
21 | [tool.setuptools.packages.find]
22 | exclude = ["tests"]
23 |
24 | # extra packages (e.g. pip install .[test])
25 | [project.optional-dependencies]
26 | test = [
27 | "black",
28 | "mypy",
29 | "pre-commit",
30 | "pytest",
31 | "pytest-cov",
32 | "ruff",
33 | ]
34 |
35 |
36 | # ----- Linting, Formatting, and Typing -----
37 |
38 | [tool.black]
39 | line-length = 88
40 |
41 | [tool.mypy]
42 | files = "{{REPO_NAME_SNAKECASE}}/"
43 | check_untyped_defs = true
44 | ignore_missing_imports = true
45 |
46 | [tool.pytest.ini_options]
47 | testpaths = ["tests"]
48 | addopts = "--cov --cov-report term-missing --cov-fail-under 80"
49 | filterwarnings = "ignore:.*.:DeprecationWarning"
50 |
51 | [tool.ruff]
52 | line-length = 88
53 | [tool.ruff.lint]
54 | ignore = ["B905", "E501"]
55 | select = [
56 | "B",
57 | "C",
58 | "E",
59 | "F",
60 | "I",
61 | "W"
62 | ]
63 | # Exclude a variety of commonly ignored directories.
64 | exclude = [
65 | ".bzr",
66 | ".direnv",
67 | ".eggs",
68 | ".git",
69 | ".hg",
70 | ".mypy_cache",
71 | ".nox",
72 | ".pants.d",
73 | ".ruff_cache",
74 | ".svn",
75 | ".tox",
76 | ".venv",
77 | "__pypackages__",
78 | "_build",
79 | "buck-out",
80 | "build",
81 | "dist",
82 | "node_modules",
83 | "venv",
84 | ]
85 | [tool.ruff.lint.mccabe]
86 | max-complexity = 18
--------------------------------------------------------------------------------
/templatize:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 |
3 | import shutil
4 | from pathlib import Path
5 | from subprocess import getoutput
6 | from typing import Dict, List, Sequence, Tuple, Union
7 |
8 | THIS_FILE = Path(__file__).expanduser().absolute()
9 | REPO_DIR = THIS_FILE.parent
10 |
11 |
12 | def get_git_info(key: str) -> str:
13 | return getoutput(f"git config {key}").strip()
14 |
15 |
16 | def get_repo_owner_and_name() -> Tuple[str, str]:
17 | owner, name = (
18 | get_git_info("remote.origin.url")
19 | .replace("https://github.com/", "")
20 | .replace("git@github.com:", "")
21 | .replace(".git", "")
22 | .split("/", maxsplit=1)
23 | )
24 | return owner, name
25 |
26 |
27 | def recursive_get_files(
28 | root_dir: Union[Path, str], excepted_files: Sequence[Union[str, Path]] = ()
29 | ) -> List[Path]:
30 | if isinstance(root_dir, str):
31 | root_dir = Path(root_dir)
32 | return [x for x in root_dir.glob("**/*") if x not in excepted_files and x.is_file()]
33 |
34 |
35 | REPO_OWNER, REPO_NAME = get_repo_owner_and_name()
36 | PLACEHOLDERS = {
37 | "{{REPO_NAME}}": REPO_NAME,
38 | "{{REPO_OWNER}}": REPO_OWNER,
39 | "{{REPO_NAME_SNAKECASE}}": REPO_NAME.replace("-", "_"),
40 | "{{REPO_NAME_ALLCAPS}}": REPO_NAME.upper().replace("-", "_"),
41 | "{{GIT_USER_NAME}}": get_git_info("user.name"),
42 | # NOTE: Need a default value for email, so pyproject is valid in GHA workflows.
43 | "{{GIT_USER_EMAIL}}": get_git_info("user.email") or "user@mail.com",
44 | }
45 |
46 |
47 | def substitute_placeholders(path: Union[str, Path], placeholder_map: Dict[str, str]):
48 | # Replace placeholders in file names
49 | for placeholder, value in placeholder_map.items():
50 | if placeholder in str(path):
51 | dest = Path(str(path).replace(placeholder, value))
52 | dest.parent.mkdir(exist_ok=True)
53 | path = shutil.move(path, dest)
54 |
55 | # Attempt to read *text* contents from file
56 | try:
57 | with open(path, "r") as f:
58 | content = f.read()
59 | except UnicodeDecodeError:
60 | # file is binary
61 | return
62 |
63 | # Replace placeholders in file contents
64 | for placeholder, value in placeholder_map.items():
65 | if placeholder in content:
66 | content = content.replace(placeholder, value)
67 |
68 | # Write modified contents back to file
69 | with open(path, "w") as f:
70 | f.write(content)
71 |
72 |
73 | def main():
74 | git_files = recursive_get_files(REPO_DIR / ".git")
75 | for filename in recursive_get_files(REPO_DIR, excepted_files=git_files):
76 | substitute_placeholders(filename, placeholder_map=PLACEHOLDERS)
77 |
78 | THIS_FILE.unlink()
79 | Path("{{REPO_NAME_SNAKECASE}}").rmdir()
80 |
81 |
82 | if __name__ == "__main__":
83 | main()
84 |
--------------------------------------------------------------------------------
/tests/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/fkodom/python-repo-template/32fe02cc97dfbd89211dfebf90ea4af4bf68ea2e/tests/__init__.py
--------------------------------------------------------------------------------
/tests/conftest.py:
--------------------------------------------------------------------------------
1 | import pytest
2 |
3 |
4 | def pytest_addoption(parser):
5 | parser.addoption("--slow", action="store_true")
6 |
7 |
8 | def pytest_configure(config):
9 | config.addinivalue_line("markers", "slow: slow to run")
10 |
11 |
12 | def pytest_collection_modifyitems(config, items):
13 | run_slow = config.getoption("--slow")
14 | skip_fast = pytest.mark.skip(reason="remove --slow option to run")
15 | skip_slow = pytest.mark.skip(reason="need --slow option to run")
16 |
17 | for item in items:
18 | if ("slow" in item.keywords) and (not run_slow):
19 | item.add_marker(skip_slow)
20 | if ("slow" not in item.keywords) and (run_slow):
21 | item.add_marker(skip_fast)
22 |
--------------------------------------------------------------------------------
/tests/test_nothing.py:
--------------------------------------------------------------------------------
1 | import {{REPO_NAME_SNAKECASE}}
2 |
3 |
4 | def test_nothing():
5 | print({{REPO_NAME_SNAKECASE}})
6 |
--------------------------------------------------------------------------------
/{{REPO_NAME_SNAKECASE}}/__init__.py:
--------------------------------------------------------------------------------
1 | import os
2 | from subprocess import getoutput
3 |
4 |
5 | def get_version_tag() -> str:
6 | try:
7 | env_key = "{{REPO_NAME_ALLCAPS}}_VERSION".upper()
8 | version = os.environ[env_key]
9 | except KeyError:
10 | version = getoutput("git describe --tags --abbrev=0")
11 |
12 | if version.lower().startswith("fatal"):
13 | version = "0.0.0"
14 |
15 | return version
16 |
17 |
18 | VERSION = get_version_tag()
19 |
--------------------------------------------------------------------------------