├── src └── calcipy │ ├── py.typed │ ├── tasks │ ├── __init__.py │ ├── nox.py │ ├── types.py │ ├── defaults.py │ ├── most_tasks.py │ ├── tags.py │ ├── cl.py │ ├── executable_utils.py │ ├── doc.py │ ├── lint.py │ ├── all_tasks.py │ ├── test.py │ └── pack.py │ ├── experiments │ ├── __init__.py │ ├── bump_programmatically.py │ ├── check_duplicate_test_names.py │ └── sync_package_dependencies.py │ ├── _corallium │ ├── __init__.py │ └── file_helpers.py │ ├── dot_dict │ ├── __init__.py │ └── _dot_dict.py │ ├── noxfile │ ├── __init__.py │ └── _noxfile.py │ ├── code_tag_collector │ └── __init__.py │ ├── md_writer │ ├── __init__.py │ └── _writer.py │ ├── __init__.py │ ├── can_skip.py │ ├── invoke_helpers.py │ ├── markdown_table.py │ ├── scripts.py │ ├── _runtime_type_check_setup.py │ ├── file_search.py │ ├── cli.py │ └── collection.py ├── tests ├── tasks │ ├── __init__.py │ ├── test_nox.py │ ├── test_doc.py │ ├── test_types.py │ ├── test_cl.py │ ├── test_all_tasks.py │ ├── test_lint.py │ ├── test_test.py │ ├── test_pack.py │ └── test_tags.py ├── md_writer │ ├── __init__.py │ ├── __snapshots__ │ │ └── test_writer.ambr │ └── test_writer.py ├── noxfile │ ├── __init__.py │ └── test_noxfile.py ├── experiments │ ├── __init__.py │ ├── test_intentional_duplicate.py │ ├── test_check_duplicate_test_names.py │ └── test_sync_package_dependencies.py ├── code_tag_collector │ ├── __init__.py │ ├── __snapshots__ │ │ └── test_collector.ambr │ └── test_collector.py ├── data │ ├── sample_doc_files │ │ ├── .dotfile │ │ └── README.md │ └── README.md ├── test_scripts.py ├── __init__.py ├── test_zz_calcipy.py ├── test_file_search.py ├── configuration.py ├── conftest.py ├── test_dot_dict.py ├── test_can_skip.py └── test_file_helpers.py ├── docs ├── CNAME ├── Calcipy.sketch ├── _javascript │ └── tables.js ├── _styles │ └── mkdocstrings.css ├── docs │ ├── CODE_TAG_SUMMARY.md │ ├── DEVELOPER_GUIDE.md │ ├── STYLE_GUIDE.md │ └── MIGRATION.md ├── gen_ref_nav.py ├── calcipy-square.svg ├── calcipy-banner.svg ├── calcipy-banner-wide.svg └── README.md ├── run ├── mise.toml ├── noxfile.py ├── .github ├── ISSUE_TEMPLATE │ ├── question.md │ ├── config.yml │ ├── bug_report.md │ └── feature_request.md ├── PULL_REQUEST_TEMPLATE.md ├── actions │ └── setup │ │ └── action.yml ├── workflows │ ├── update_docs.yml │ └── ci_pipeline.yml └── ABOUT.md ├── .calcipy.json ├── README.md ├── ideas.txt ├── .editorconfig ├── .copier-answers.yml ├── LICENSE ├── .pre-commit-hooks.yaml ├── plan ├── future-tooling-optimization.md ├── tooling-refactor.md └── code-tags-cleanup.md ├── mkdocs.yml ├── .pre-commit-config.yaml ├── .gitignore └── pyproject.toml /src/calcipy/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/tasks/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/md_writer/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/noxfile/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/calcipy/tasks/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/experiments/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/CNAME: -------------------------------------------------------------------------------- 1 | calcipy.kyleking.me 2 | -------------------------------------------------------------------------------- /src/calcipy/experiments/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/code_tag_collector/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/data/sample_doc_files/.dotfile: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/data/README.md: -------------------------------------------------------------------------------- 1 | # Test Data 2 | 3 | Static files used for package tests 4 | -------------------------------------------------------------------------------- /docs/Calcipy.sketch: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KyleKing/calcipy/HEAD/docs/Calcipy.sketch -------------------------------------------------------------------------------- /run: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | 3 | # shellcheck disable=SC2048,SC2086 4 | uv run calcipy -v $* 5 | -------------------------------------------------------------------------------- /mise.toml: -------------------------------------------------------------------------------- 1 | [env] 2 | _.python.venv = {path = ".venv"} 3 | 4 | [tools] 5 | python = ["3.12.5", "3.9.13"] 6 | -------------------------------------------------------------------------------- /tests/test_scripts.py: -------------------------------------------------------------------------------- 1 | from calcipy.scripts import start # noqa: F401 # Smoke test the scripts import 2 | -------------------------------------------------------------------------------- /noxfile.py: -------------------------------------------------------------------------------- 1 | # ruff: noqa: F401 2 | """nox configuration file.""" 3 | 4 | from calcipy.noxfile import tests 5 | -------------------------------------------------------------------------------- /src/calcipy/_corallium/__init__.py: -------------------------------------------------------------------------------- 1 | from corallium.file_helpers import read_pyproject 2 | 3 | __all__ = ['read_pyproject'] 4 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/question.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Question 3 | about: This is a general catch-all template 4 | assignees: kyleking 5 | --- 6 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # Configuration: https://help.github.com/en/github/building-a-strong-community/configuring-issue-templates-for-your-repository 3 | 4 | blank_issues_enabled: false 5 | -------------------------------------------------------------------------------- /src/calcipy/dot_dict/__init__.py: -------------------------------------------------------------------------------- 1 | try: 2 | from ._dot_dict import ddict 3 | except ImportError as exc: 4 | raise RuntimeError("The 'calcipy[ddict]' extras are missing") from exc 5 | 6 | __all__ = ('ddict',) 7 | -------------------------------------------------------------------------------- /src/calcipy/noxfile/__init__.py: -------------------------------------------------------------------------------- 1 | try: 2 | from ._noxfile import tests 3 | except ImportError as exc: 4 | raise RuntimeError("The 'calcipy[nox]' extras are missing") from exc 5 | 6 | __all__ = ('tests',) 7 | -------------------------------------------------------------------------------- /src/calcipy/code_tag_collector/__init__.py: -------------------------------------------------------------------------------- 1 | try: 2 | from ._collector import write_code_tag_file 3 | except ImportError as exc: 4 | raise RuntimeError("The 'calcipy[tags]' extras are missing") from exc 5 | 6 | __all__ = ('write_code_tag_file',) 7 | -------------------------------------------------------------------------------- /tests/experiments/test_intentional_duplicate.py: -------------------------------------------------------------------------------- 1 | 2 | def intentional_duplicate(): 3 | """Intentional duplicate, but should be ignored.""" 4 | 5 | 6 | def test_intentional_duplicate(): 7 | """Intentional duplicate of a test function with the same name.""" 8 | -------------------------------------------------------------------------------- /src/calcipy/md_writer/__init__.py: -------------------------------------------------------------------------------- 1 | try: 2 | from ._writer import write_template_formatted_md_sections 3 | except ImportError as exc: 4 | raise RuntimeError("The 'calcipy[doc]' extras are missing") from exc 5 | 6 | __all__ = ('write_template_formatted_md_sections',) 7 | -------------------------------------------------------------------------------- /.calcipy.json: -------------------------------------------------------------------------------- 1 | { 2 | "tags": { 3 | "filename": "CODE_TAG_SUMMARY.md" 4 | }, 5 | "test": { 6 | "min_cover": 75, 7 | "out_dir": "releases/tests" 8 | }, 9 | "type": { 10 | "out_dir": "releases/tests/mypy_html" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # calcipy 2 | 3 | ![docs/calcipy-banner-wide.svg](docs/calcipy-banner-wide.svg) 4 | 5 | Python package to simplify development 6 | 7 | Documentation can be found on [GitHub (./docs)](./docs), [PyPi](https://pypi.org/project/calcipy/), or [Hosted](https://calcipy.kyleking.me/)! 8 | -------------------------------------------------------------------------------- /docs/_javascript/tables.js: -------------------------------------------------------------------------------- 1 | // From: https://squidfunk.github.io/mkdocs-material/reference/data-tables/ 2 | document$.subscribe(function () { 3 | var tables = document.querySelectorAll("article table"); 4 | tables.forEach(function (table) { 5 | new Tablesort(table); 6 | }); 7 | }); 8 | -------------------------------------------------------------------------------- /src/calcipy/__init__.py: -------------------------------------------------------------------------------- 1 | """calcipy.""" 2 | 3 | from ._runtime_type_check_setup import configure_runtime_type_checking_mode 4 | 5 | __version__ = '5.0.0' 6 | __pkg_name__ = 'calcipy' 7 | 8 | configure_runtime_type_checking_mode() 9 | 10 | 11 | # == Above code must always be first == 12 | -------------------------------------------------------------------------------- /ideas.txt: -------------------------------------------------------------------------------- 1 | - Instead of noxfile being imported, move to calcipy-template 2 | - Move most of the CLI to mise.toml (and maybe see if it can be extended?) - which is distributed with copier-template rather than calcipy as well 3 | - Only have the first-party tooling in calcipy after this is done (everything else is just wrappers around flake8, etc.) 4 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 7 | -------------------------------------------------------------------------------- /docs/_styles/mkdocstrings.css: -------------------------------------------------------------------------------- 1 | /* Provide more vertical spacing between docstrings 2 | 3 | Source: ;https://github.com/pawamoy/copier-pdm/blob/79135565c4c7f756204a5f460e87129649f8b704/project/docs/css/mkdocstrings.css 4 | 5 | */ 6 | div.doc-contents:not(.first) { 7 | padding-left: 25px; 8 | border-left: 4px solid rgba(230, 230, 230); 9 | margin-bottom: 80px; 10 | } 11 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from os import environ, getenv 3 | 4 | DEF_MODE = 'ERROR' if sys.version_info >= (3, 10) else 'WARNING' 5 | environ['RUNTIME_TYPE_CHECKING_MODE'] = getenv('RUNTIME_TYPE_CHECKING_MODE', DEF_MODE) 6 | 7 | # Set for testing the `publish` task 8 | environ['UV_PUBLISH_USERNAME'] = 'pypi_user' 9 | environ['UV_PUBLISH_PASSWORD'] = 'pypi_password' # noqa: S105 10 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_size = 4 5 | insert_final_newline = true 6 | trim_trailing_whitespace = true 7 | indent_style = space 8 | 9 | [**.{toml,yml,yaml}] 10 | indent_size = 2 11 | 12 | [**.{js,jsx,ts,tsx,html}] 13 | indent_size = 2 14 | 15 | [**.{json,jsonc}] 16 | indent_size = 4 17 | 18 | [*.lua] 19 | indent_size = 4 20 | 21 | [*.md] 22 | indent_size = 4 23 | 24 | [*.py] 25 | indent_size = 4 26 | max_line_length = 120 27 | 28 | [*.sh] 29 | indent_size = 4 30 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Found a problem, us know! 4 | assignees: kyleking 5 | --- 6 | 7 | **Describe the bug** 8 | 9 | 10 | **To Reproduce** 11 | 12 | 13 | **Expected behavior** 14 | 15 | -------------------------------------------------------------------------------- /tests/test_zz_calcipy.py: -------------------------------------------------------------------------------- 1 | """Final test alphabetically (zz) to catch general integration cases.""" 2 | 3 | from pathlib import Path 4 | 5 | from corallium.tomllib import tomllib 6 | 7 | from calcipy import __version__ 8 | 9 | 10 | def test_version(): 11 | """Check that PyProject and package __version__ are equivalent.""" 12 | data = Path('pyproject.toml').read_text(encoding='utf-8') 13 | 14 | result = tomllib.loads(data)['project']['version'] 15 | 16 | assert result == __version__ 17 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Please share your ideas for calcipy! 4 | assignees: kyleking 5 | --- 6 | 7 | **Describe the solution you'd like** 8 | 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | 12 | 13 | **Describe alternatives you've considered** 14 | 15 | -------------------------------------------------------------------------------- /tests/tasks/test_nox.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import call 2 | 3 | import pytest 4 | 5 | from calcipy.tasks.executable_utils import python_dir 6 | from calcipy.tasks.nox import noxfile 7 | 8 | 9 | @pytest.mark.parametrize( 10 | ('task', 'kwargs', 'commands'), 11 | [ 12 | (noxfile, {}, [f'{python_dir() / "nox"} --error-on-missing-interpreters ']), 13 | ], 14 | ) 15 | def test_nox(ctx, task, kwargs, commands): 16 | task(ctx, **kwargs) 17 | 18 | ctx.run.assert_has_calls([ 19 | call(cmd) if isinstance(cmd, str) else cmd 20 | for cmd in commands 21 | ]) 22 | -------------------------------------------------------------------------------- /src/calcipy/tasks/nox.py: -------------------------------------------------------------------------------- 1 | """Nox CLI.""" 2 | 3 | from invoke.context import Context 4 | 5 | from calcipy.cli import task 6 | from calcipy.invoke_helpers import run 7 | 8 | from .executable_utils import python_dir 9 | 10 | 11 | @task( 12 | default=True, 13 | help={ 14 | 'session': 'Optional session to run', 15 | }, 16 | ) 17 | def noxfile(ctx: Context, *, session: str = '') -> None: 18 | """Run nox from the local noxfile.""" 19 | cli_args = ['--session', session] if session else [] 20 | run(ctx, f'{python_dir() / "nox"} --error-on-missing-interpreters {" ".join(cli_args)}') 21 | -------------------------------------------------------------------------------- /src/calcipy/tasks/types.py: -------------------------------------------------------------------------------- 1 | # noqa: A005 2 | """Types CLI.""" 3 | 4 | from invoke.context import Context 5 | 6 | from calcipy.cli import task 7 | from calcipy.invoke_helpers import run 8 | 9 | from .executable_utils import PYRIGHT_MESSAGE, check_installed, python_dir 10 | 11 | 12 | @task() 13 | def pyright(ctx: Context) -> None: 14 | """Run pyright using the config in `pyproject.toml`.""" 15 | check_installed(ctx, executable='pyright', message=PYRIGHT_MESSAGE) 16 | run(ctx, 'pyright') 17 | 18 | 19 | @task() 20 | def mypy(ctx: Context) -> None: 21 | """Run mypy.""" 22 | run(ctx, f'{python_dir() / "mypy"}') 23 | -------------------------------------------------------------------------------- /tests/data/sample_doc_files/README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # Title 4 | 5 | > Test markdown formatting from [recipes](https://github.com/KyleKing/recipes) 6 | 7 | 8 | Personal rating: *Not yet rated* 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /tests/noxfile/test_noxfile.py: -------------------------------------------------------------------------------- 1 | from calcipy.noxfile._noxfile import _installable_ci_dependencies 2 | 3 | 4 | def test__installable_ci_dependencies(): 5 | pyproject_data = { 6 | 'dependency-groups': { 7 | 'ci': [ 8 | 'hypothesis[cli] >=6.112.4', 9 | 'pytest-asyncio >=0.24.0', 10 | 'types-setuptools >=75.1.0.20240917', 11 | ], 12 | }, 13 | } 14 | 15 | result = _installable_ci_dependencies(pyproject_data) 16 | 17 | assert result == [ 18 | 'hypothesis[cli] >=6.112.4', 19 | 'pytest-asyncio >=0.24.0', 20 | 'types-setuptools >=75.1.0.20240917', 21 | ] 22 | -------------------------------------------------------------------------------- /tests/tasks/test_doc.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import call 2 | 3 | import pytest 4 | 5 | from calcipy.tasks.doc import build, deploy, get_out_dir 6 | from calcipy.tasks.executable_utils import python_dir 7 | 8 | 9 | @pytest.mark.parametrize( 10 | ('task', 'kwargs', 'commands'), 11 | [ 12 | (build, {}, [f'{python_dir() / "mkdocs"} build --site-dir {get_out_dir()}']), 13 | (deploy, {}, [f'{python_dir() / "mkdocs"} gh-deploy --force']), 14 | ], 15 | ) 16 | def test_doc(ctx, task, kwargs, commands): 17 | task(ctx, **kwargs) 18 | 19 | ctx.run.assert_has_calls([ 20 | call(cmd) if isinstance(cmd, str) else cmd 21 | for cmd in commands 22 | ]) 23 | -------------------------------------------------------------------------------- /tests/tasks/test_types.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import call 2 | 3 | import pytest 4 | 5 | from calcipy.tasks.executable_utils import python_dir 6 | from calcipy.tasks.types import mypy, pyright 7 | 8 | 9 | @pytest.mark.parametrize( 10 | ('task', 'kwargs', 'commands'), 11 | [ 12 | (pyright, {}, [ 13 | call('which pyright', warn=True, hide=True), 14 | 'pyright', 15 | ]), 16 | (mypy, {}, [f'{python_dir() / "mypy"}']), 17 | ], 18 | ) 19 | def test_types(ctx, task, kwargs, commands): 20 | task(ctx, **kwargs) 21 | 22 | ctx.run.assert_has_calls([ 23 | call(cmd) if isinstance(cmd, str) else cmd 24 | for cmd in commands 25 | ]) 26 | -------------------------------------------------------------------------------- /tests/test_file_search.py: -------------------------------------------------------------------------------- 1 | from corallium.file_helpers import get_relative 2 | 3 | from calcipy.file_search import find_project_files_by_suffix 4 | 5 | from .configuration import TEST_DATA_DIR 6 | 7 | SAMPLE_README_DIR = TEST_DATA_DIR / 'sample_doc_files' 8 | 9 | 10 | def test_find_project_files_by_suffix(): 11 | expected_suffixes = ['', 'md'] 12 | 13 | result = find_project_files_by_suffix(SAMPLE_README_DIR, ignore_patterns=[]) 14 | 15 | assert len(result) != 0 16 | assert sorted(result.keys()) == expected_suffixes 17 | assert result[''][0].name == '.dotfile' 18 | rel_pth = get_relative(result['md'][-1], SAMPLE_README_DIR) 19 | assert rel_pth 20 | assert rel_pth.as_posix() == 'README.md' 21 | -------------------------------------------------------------------------------- /tests/experiments/test_check_duplicate_test_names.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from calcipy.experiments.check_duplicate_test_names import run 4 | 5 | 6 | class ClassTest: 7 | """Test check_duplicate_test_names for searching by Class.""" 8 | 9 | def method_test(self) -> None: 10 | """Test check_duplicate_test_names for searching by method.""" 11 | 12 | 13 | def intentional_duplicate(): 14 | """Intentional duplicate, but should be ignored.""" 15 | 16 | 17 | def test_intentional_duplicate(): 18 | """Intentional duplicate of a test function with the same name.""" 19 | 20 | 21 | def test_check_duplicate_test_names(): 22 | duplicates = run(Path(__file__).parents[1]) 23 | 24 | assert duplicates == ['test_intentional_duplicate'] 25 | -------------------------------------------------------------------------------- /tests/configuration.py: -------------------------------------------------------------------------------- 1 | """Global variables for testing.""" 2 | 3 | from pathlib import Path 4 | 5 | from corallium.file_helpers import delete_dir, ensure_dir 6 | 7 | TEST_DIR = Path(__file__).resolve().parent 8 | """Path to the `test` directory that contains this file and all other tests.""" 9 | 10 | APP_DIR = TEST_DIR.parent 11 | """Application directory.""" 12 | 13 | TEST_DATA_DIR = TEST_DIR / 'data' 14 | """Path to subdirectory with test data within the Test Directory.""" 15 | 16 | TEST_TMP_CACHE = TEST_DIR / '_tmp_cache' 17 | """Path to the temporary cache folder in the Test directory.""" 18 | 19 | 20 | def clear_test_cache() -> None: 21 | """Remove the test cache directory if present.""" 22 | delete_dir(TEST_TMP_CACHE) 23 | ensure_dir(TEST_TMP_CACHE) 24 | -------------------------------------------------------------------------------- /tests/tasks/test_cl.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import call 2 | 3 | import pytest 4 | 5 | from calcipy.tasks.cl import bump 6 | from calcipy.tasks.executable_utils import python_dir 7 | 8 | 9 | @pytest.mark.parametrize( 10 | ('task', 'kwargs', 'commands'), 11 | [ 12 | (bump, {}, [ 13 | f'{python_dir() / "cz"} bump --annotated-tag --no-verify --gpg-sign', 14 | 'git push origin --tags --no-verify', 15 | 'gh release create --generate-notes $(git tag --list --sort=-creatordate | head -n 1)', 16 | ]), 17 | ], 18 | ) 19 | def test_cl(ctx, task, kwargs, commands): 20 | task(ctx, **kwargs) 21 | 22 | ctx.run.assert_has_calls([ 23 | call(cmd) if isinstance(cmd, str) else cmd 24 | for cmd in commands 25 | ]) 26 | -------------------------------------------------------------------------------- /tests/tasks/test_all_tasks.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import call 2 | 3 | import pytest 4 | 5 | from calcipy.tasks.all_tasks import main, other, release 6 | from calcipy.tasks.executable_utils import python_dir 7 | 8 | 9 | @pytest.mark.parametrize( 10 | ('task', 'kwargs', 'commands'), 11 | [ 12 | (main, {}, []), 13 | (other, {}, []), 14 | (release, {}, [ 15 | f'{python_dir() / "cz"} bump --annotated-tag --no-verify --gpg-sign', 16 | 'git push origin --tags --no-verify', 17 | 'gh release create --generate-notes $(git tag --list --sort=-creatordate | head -n 1)', 18 | ]), 19 | ], 20 | ) 21 | def test_all_tasks(ctx, task, kwargs, commands): 22 | task(ctx, **kwargs) 23 | 24 | ctx.run.assert_has_calls([ 25 | call(cmd) if isinstance(cmd, str) else cmd 26 | for cmd in commands 27 | ]) 28 | -------------------------------------------------------------------------------- /src/calcipy/dot_dict/_dot_dict.py: -------------------------------------------------------------------------------- 1 | """Dotted dictionary for consistent interface. 2 | 3 | Consider moving to Corallium, but I don't have any uses for it yet. 4 | 5 | """ 6 | 7 | from beartype.typing import Any, Dict, Union 8 | from box import Box 9 | 10 | DdictType = Union[Dict[str, Any], Box] 11 | """Return type from `ddict()`.""" 12 | 13 | 14 | def ddict(**kwargs: Any) -> DdictType: 15 | """Return a dotted dictionary that can also be accessed normally. 16 | 17 | - Currently uses `python-box` 18 | - Could consider `cleverdict` which had updates as recently as 2022 19 | - There are numerous other variations that haven't been updated since 2020, such as `munch`, `bunch`, `ddict` 20 | 21 | Args: 22 | **kwargs: keyword arguments formatted into dictionary 23 | 24 | Returns: 25 | DdictType: dotted dictionary 26 | 27 | """ 28 | return Box(kwargs) 29 | -------------------------------------------------------------------------------- /.copier-answers.yml: -------------------------------------------------------------------------------- 1 | # yamllint disable-file 2 | # Answer file maintained by Copier for: https://github.com/KyleKing/calcipy_template 3 | # DO NOT MODIFY THIS FILE. Edit by re-running copier and changing responses to the questions 4 | # Check into version control. 5 | _commit: 3.1.1 6 | _src_path: gh:KyleKing/calcipy_template 7 | author_email: dev.act.kyle@gmail.com 8 | author_name: Kyle King 9 | author_username: kyleking 10 | cname: calcipy.kyleking.me 11 | copyright_date: '2023' 12 | development_branch: main 13 | doc_dir: docs 14 | extends_calcipy: true 15 | minimum_python: 3.9.13 16 | minimum_python_short: '3.9' 17 | package_name_py: calcipy 18 | project_description: Python package to simplify development 19 | project_name: calcipy 20 | repository_namespace: kyleking 21 | repository_provider: https://github.com 22 | repository_source_url: https://github.com/kyleking/calcipy/blob/main 23 | repository_url: https://github.com/kyleking/calcipy 24 | 25 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | """Pytest configuration.""" 2 | 3 | from pathlib import Path 4 | 5 | import pytest 6 | from invoke.context import MockContext 7 | 8 | from .configuration import TEST_TMP_CACHE, clear_test_cache 9 | 10 | 11 | @pytest.fixture 12 | def fix_test_cache() -> Path: 13 | """Fixture to clear and return the test cache directory for use. 14 | 15 | Returns: 16 | Path: Path to the test cache directory 17 | 18 | """ 19 | clear_test_cache() 20 | return TEST_TMP_CACHE 21 | 22 | 23 | @pytest.fixture 24 | def ctx() -> MockContext: 25 | """Return Mock Invoke Context. 26 | 27 | Adapted from: 28 | https://github.com/pyinvoke/invocations/blob/4e3578e9c49dbbff2ec00ef3c8d37810fba511fa/tests/conftest.py#L13-L19 29 | 30 | Documentation: https://docs.pyinvoke.org/en/stable/concepts/testing.html 31 | 32 | """ 33 | MockContext.run_command = property(lambda self: self.run.call_args[0][0]) # type: ignore[attr-defined] 34 | return MockContext(run=True) 35 | -------------------------------------------------------------------------------- /tests/code_tag_collector/__snapshots__/test_collector.ambr: -------------------------------------------------------------------------------- 1 | # serializer version: 1 2 | # name: test__format_report 3 | ''' 4 | 5 | | Type | Comment | Last Edit | Source File | 6 | |------|-----------|-----------|---------------------| 7 | | TODO | Example 2 | N/A | data/test_project:1 | 8 | 9 | Found code tags for TODO (1) 10 | 11 | ''' 12 | # --- 13 | # name: test__search_lines 14 | list([ 15 | _CodeTag(lineno=2, tag='FIXME', text='Show README.md in the documentation (may need to update paths?)")'), 16 | _CodeTag(lineno=3, tag='FYI', text='Replace src_examples_dir and make more generic to specify code to include in documentation'), 17 | _CodeTag(lineno=4, tag='HACK', text='Show table of contents in __init__.py file'), 18 | _CodeTag(lineno=7, tag='REVIEW', text='Show table of contents in __init__.py file'), 19 | _CodeTag(lineno=10, tag='HACK', text='Support unconventional dashed code tags'), 20 | _CodeTag(lineno=13, tag='FIXME', text='and FYI: in the same line, but only match the first'), 21 | ]) 22 | # --- 23 | -------------------------------------------------------------------------------- /tests/tasks/test_lint.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import call 2 | 3 | import pytest 4 | 5 | from calcipy.tasks.executable_utils import python_m 6 | from calcipy.tasks.lint import ALL_PRE_COMMIT_HOOK_STAGES, check, fix, pre_commit, watch 7 | 8 | 9 | @pytest.mark.parametrize( 10 | ('task', 'kwargs', 'commands'), 11 | [ 12 | (check, {}, [f'{python_m()} ruff check "./src/calcipy" ./tests']), 13 | (fix, {}, [f'{python_m()} ruff check "./src/calcipy" ./tests --fix']), 14 | (watch, {}, [f'{python_m()} ruff check "./src/calcipy" ./tests --watch']), 15 | (pre_commit, {}, [ 16 | call('which prek', warn=True, hide=True), 17 | 'prek install', 18 | 'prek autoupdate', 19 | 'prek run --all-files ' + ' '.join(f'--hook-stage {stg}' for stg in ALL_PRE_COMMIT_HOOK_STAGES), 20 | ]), 21 | ], 22 | ) 23 | def test_lint(ctx, task, kwargs, commands): 24 | task(ctx, **kwargs) 25 | 26 | ctx.run.assert_has_calls([ 27 | call(cmd) if isinstance(cmd, str) else cmd 28 | for cmd in commands 29 | ]) 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Kyle King 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 | -------------------------------------------------------------------------------- /.github/actions/setup/action.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Setup Action 3 | description: Install requested uvx dependencies, configure the system python, and install uv and the package dependencies 4 | 5 | inputs: 6 | python-tools: 7 | description: List of Python packages installed as CLI tools by uv 8 | default: "" 9 | uv-version: 10 | description: Astral/uv version. Defers to `[tool.uv].required-version` in `pyproject.toml` 11 | default: "" 12 | python-version: 13 | description: Python version 14 | required: true 15 | 16 | runs: 17 | using: composite 18 | steps: 19 | - name: Install 'uv==%{{ inputs.uv-version }}' 20 | uses: astral-sh/setup-uv@v5 21 | with: 22 | enable-cache: true 23 | python-version: ${{ inputs.python-version }} 24 | version: ${{ inputs.uv-version }} 25 | 26 | - name: Install '${{ inputs.python-tools }}' 27 | if: ${{ inputs.python-tools }} 28 | run: | 29 | for tool in ${{ inputs.python-tools }}; do 30 | uv tool install $tool 31 | done 32 | shell: bash 33 | 34 | - name: Install Project and Minimum Subset of Dependencies 35 | run: uv sync --all-extras 36 | shell: bash 37 | -------------------------------------------------------------------------------- /tests/test_dot_dict.py: -------------------------------------------------------------------------------- 1 | import arrow 2 | import pytest 3 | from hypothesis import given 4 | from hypothesis import strategies as st 5 | 6 | from calcipy.dot_dict import ddict 7 | 8 | 9 | @pytest.mark.parametrize( 10 | ('key', 'value'), [ 11 | ('int', 1), 12 | ('number', -1.23), 13 | ('unicode', '✓'), 14 | ('is_bool', False), 15 | ('datetime', arrow.now()), 16 | ], 17 | ) 18 | def test_ddict(key, value): 19 | result = ddict(**{key: value}) 20 | 21 | assert result[key] == value 22 | assert getattr(result, key) == value 23 | assert isinstance(result, dict) 24 | assert result.get(f'--{key}--') is None 25 | 26 | 27 | _ST_ANY = st.booleans() | st.binary() | st.integers() | st.text() 28 | """Broadest set of strategies for data input testing of dot_dict.""" 29 | 30 | 31 | @given( 32 | key=st.text(), 33 | value=(_ST_ANY | st.dictionaries(keys=_ST_ANY, values=_ST_ANY, max_size=10)), 34 | ) 35 | def test_ddict_with_hypothesis(key, value): 36 | result = ddict(**{key: value}) 37 | 38 | assert getattr(result, key) == value 39 | assert result[key] == value 40 | assert isinstance(result, dict) 41 | assert result.get(f'--{key}--') is None 42 | -------------------------------------------------------------------------------- /src/calcipy/experiments/bump_programmatically.py: -------------------------------------------------------------------------------- 1 | """Experiment with bumping the git tag using `griffe`.""" 2 | 3 | import griffe 4 | import semver 5 | from corallium.log import LOGGER 6 | from griffe import BuiltinModuleError 7 | 8 | 9 | def bump_tag(*, pkg_name: str, tag: str, tag_prefix: str) -> str: 10 | """Return either minor or patch change based on `griffe`. 11 | 12 | Note: major versions must be bumped manually 13 | 14 | """ 15 | previous = griffe.load_git(pkg_name, ref=tag) 16 | current = griffe.load(pkg_name) 17 | 18 | breakages = [*griffe.find_breaking_changes(previous, current)] 19 | for breakage in breakages: 20 | try: 21 | LOGGER.text(breakage._explain_oneline()) # noqa: SLF001 22 | except BuiltinModuleError: # noqa: PERF203 23 | LOGGER.warning(str(breakage)) 24 | except Exception: 25 | LOGGER.exception(str(breakage)) 26 | 27 | try: 28 | ver = semver.Version.parse(tag.replace(tag_prefix, '')) 29 | except ValueError: 30 | LOGGER.exception('Failed to parse tag', tag=tag) 31 | return '' 32 | new_ver = ver.bump_minor() if any(breakages) else ver.bump_patch() 33 | return f'{tag_prefix}{new_ver}' 34 | -------------------------------------------------------------------------------- /.pre-commit-hooks.yaml: -------------------------------------------------------------------------------- 1 | # --- 2 | # # Docs: https://pre-commit.com/#new-hooks 3 | # # Can be tested with: 4 | # # prek try-repo . --verbose --all-files --hook-stage push 5 | # # prek try-repo . tags --verbose --all-files --hook-stage push 6 | # # prek try-repo . lint-fix --verbose --all-files 7 | # - id: copier-forbidden-files 8 | # name: Copier Forbidden Files 9 | # entry: | 10 | # Found copier update rejection files; review them and remove them 11 | # (https://copier.readthedocs.io/en/stable/updating/) 12 | # language: fail 13 | # files: \.rej$ 14 | # - id: lint-fix 15 | # additional_dependencies: [".[lint]"] 16 | # description: "Run ruff and apply fixes" 17 | # entry: calcipy-lint lint.fix 18 | # language: python 19 | # minimum_pre_commit_version: 2.9.2 20 | # name: Lint-Fix 21 | # require_serial: true 22 | # types: [python] 23 | # - id: tags 24 | # additional_dependencies: [".[tags]"] 25 | # description: "Create a `CODE_TAG_SUMMARY.md` with a table for TODO- and FIXME-style code comments" 26 | # entry: calcipy-tags tags 27 | # language: python 28 | # minimum_pre_commit_version: 2.9.2 29 | # name: Tags 30 | # pass_filenames: false 31 | # require_serial: true 32 | # stages: [pre-push] 33 | -------------------------------------------------------------------------------- /tests/test_can_skip.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | import pytest 4 | 5 | from calcipy.can_skip import can_skip 6 | 7 | 8 | @pytest.mark.parametrize( 9 | ('create_order', 'prerequisites', 'targets', 'expected'), 10 | [ 11 | (['uv.lock', 'cache.json'], ['uv.lock'], ['cache.json'], True), 12 | (['cache.json', 'uv.lock'], ['uv.lock'], ['cache.json'], False), 13 | (['uv.lock', 'pyproject.toml', 'cache.json'], ['pyproject.toml', 'uv.lock'], ['cache.json'], True), 14 | (['uv.lock', 'cache.json', 'pyproject.toml'], ['pyproject.toml', 'uv.lock'], ['cache.json'], False), 15 | (['uv.lock', 'summary.txt', 'cache.json'], ['uv.lock'], ['cache.json', 'summary.txt'], True), 16 | (['summary.txt', 'uv.lock', 'cache.json'], ['uv.lock'], ['cache.json', 'summary.txt'], False), 17 | ], 18 | ) 19 | def test_skip(fix_test_cache, create_order, prerequisites, targets, expected): 20 | for sub_pth in create_order: 21 | (fix_test_cache / sub_pth).write_text('') 22 | time.sleep(0.25) # Reduces flakiness on Windows 23 | 24 | result = can_skip( 25 | prerequisites=[fix_test_cache / sub_pth for sub_pth in prerequisites], 26 | targets=[fix_test_cache / sub_pth for sub_pth in targets], 27 | ) 28 | 29 | assert result is expected 30 | -------------------------------------------------------------------------------- /.github/workflows/update_docs.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Update Docs 3 | 4 | "on": 5 | push: 6 | branches: [main] 7 | paths: 8 | - .github/workflows/update_docs.yml 9 | - docs/** 10 | - mkdocs.yml 11 | workflow_dispatch: null # For manual runs 12 | 13 | # Docs: https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#permissions 14 | permissions: 15 | # Repository contents, commits, branches, downloads, releases, and merges. 16 | contents: write 17 | 18 | jobs: 19 | docs: 20 | runs-on: ubuntu-latest 21 | strategy: 22 | matrix: 23 | python-version: ["3.9"] 24 | steps: 25 | - uses: actions/checkout@v4 26 | with: 27 | fetch-depth: 0 # For git-revision-date-localized-plugin 28 | - uses: ./.github/actions/setup 29 | with: 30 | python-version: ${{ matrix.python-version }} 31 | 32 | # https://github.com/mkdocs/mkdocs/discussions/2369#discussioncomment-625475 33 | - name: Configure git user 34 | run: |- 35 | git config user.name 'github-actions[bot]' 36 | git config user.email 'github-actions[bot]@users.noreply.github.com' 37 | - name: Build and deploy documentation 38 | run: uv run calcipy-docs cl.write doc.build doc.deploy 39 | -------------------------------------------------------------------------------- /src/calcipy/can_skip.py: -------------------------------------------------------------------------------- 1 | """Support can-skip logic from Make.""" 2 | 3 | from pathlib import Path 4 | 5 | from beartype.typing import List 6 | from corallium.log import LOGGER 7 | 8 | 9 | def can_skip(*, prerequisites: List[Path], targets: List[Path]) -> bool: 10 | """Return true if the prerequisite files are have newer `mtime` than targets. 11 | 12 | Example use with Invoke, but can be used anywhere: 13 | 14 | ```py 15 | @task() 16 | def test(ctx: Context) -> None: 17 | if can_skip(prerequisites=[*Path('src').rglob('*.py')], targets=[Path('.coverage.xml')]): 18 | return # Exit early 19 | 20 | ... # Task code 21 | ``` 22 | 23 | """ 24 | if not (ts_prerequisites := [pth.stat().st_mtime for pth in prerequisites]): 25 | raise ValueError('Required files do not exist', prerequisites) 26 | 27 | ts_targets = [pth.stat().st_mtime for pth in targets] 28 | if ts_targets and min(ts_targets) > max(ts_prerequisites): 29 | LOGGER.warning('Skipping because targets are newer', targets=targets) 30 | return True 31 | return False 32 | 33 | 34 | def dont_skip(*, prerequisites: List[Path], targets: List[Path]) -> bool: 35 | """Returns False. To use for testing with mock.""" 36 | LOGGER.debug('Mocking can_skip', prerequisites=prerequisites, targets=targets) 37 | return False 38 | -------------------------------------------------------------------------------- /src/calcipy/tasks/defaults.py: -------------------------------------------------------------------------------- 1 | """Calcipy-Invoke Defaults.""" 2 | 3 | import json 4 | from contextlib import suppress 5 | from pathlib import Path 6 | 7 | from invoke.context import Context 8 | 9 | from calcipy.collection import Collection 10 | 11 | DEFAULTS = { 12 | 'tags': { 13 | 'filename': 'CODE_TAG_SUMMARY.md', 14 | 'ignore_patterns': '', 15 | }, 16 | 'test': { 17 | 'min_cover': '0', 18 | 'out_dir': 'releases/tests', 19 | }, 20 | 'type': { 21 | 'out_dir': 'releases/tests/mypy_html', 22 | }, 23 | } 24 | 25 | 26 | def from_ctx(ctx: Context, group: str, key: str) -> str: 27 | """Safely extract the value from the context or the defaults. 28 | 29 | Instead of `ctx.tests.out_dir` use `from_ctx(ctx, 'test', 'out_dir')` 30 | 31 | """ 32 | with suppress(KeyError): 33 | return str(ctx.config[group][key]) 34 | return str(DEFAULTS[group][key]) 35 | 36 | 37 | def new_collection() -> Collection: 38 | """Initialize a collection with the combination of merged and project-specific defaults.""" 39 | ns = Collection('') 40 | 41 | # Merge default and user configuration 42 | ns.configure(DEFAULTS) 43 | config_path = Path('.calcipy.json') 44 | if config_path.is_file(): 45 | ns.configure(json.loads(config_path.read_text(encoding='utf-8'))) 46 | 47 | return ns 48 | -------------------------------------------------------------------------------- /src/calcipy/tasks/most_tasks.py: -------------------------------------------------------------------------------- 1 | """Create a namespace with all tasks that can be imported.""" 2 | 3 | from contextlib import suppress 4 | 5 | from calcipy.collection import Collection 6 | 7 | from .defaults import new_collection 8 | 9 | # "ns" will be recognized by Collection.from_module(all_tasks) 10 | # https://docs.pyinvoke.org/en/stable/api/collection.html#invoke.collection.Collection.from_module 11 | ns = new_collection() 12 | with suppress(RuntimeError): 13 | from . import cl 14 | 15 | ns.add_collection(Collection.from_module(cl)) 16 | with suppress(RuntimeError): 17 | from . import doc 18 | 19 | ns.add_collection(Collection.from_module(doc)) 20 | with suppress(RuntimeError): 21 | from . import lint 22 | 23 | ns.add_collection(Collection.from_module(lint)) 24 | with suppress(RuntimeError): 25 | from . import nox 26 | 27 | ns.add_collection(Collection.from_module(nox)) 28 | with suppress(RuntimeError): 29 | from . import pack 30 | 31 | ns.add_collection(Collection.from_module(pack)) 32 | with suppress(RuntimeError): 33 | from . import tags 34 | 35 | ns.add_collection(Collection.from_module(tags)) 36 | with suppress(RuntimeError): 37 | from . import test 38 | 39 | ns.add_collection(Collection.from_module(test)) 40 | with suppress(RuntimeError): 41 | from . import types 42 | 43 | ns.add_collection(Collection.from_module(types)) 44 | 45 | __all__ = ('ns',) 46 | -------------------------------------------------------------------------------- /plan/future-tooling-optimization.md: -------------------------------------------------------------------------------- 1 | # Future Mise/UV/NOX Utilization 2 | 3 | ## Overview 4 | 5 | With UV conversion and tooling refactor complete, optimize the use of mise, UV, and NOX for development workflows. 6 | 7 | ## Current Status 8 | 9 | - Mise manages Python versions and virtual environments 10 | - UV handles dependency management and locking 11 | - NOX provides isolated testing environments 12 | - Some integration exists but could be improved 13 | 14 | ## Proposed Mini-Project 15 | 16 | Enhance tooling integration: 17 | 18 | 1. **Mise Task Expansion**: Add more project tasks to `mise.toml` (build, deploy, release automation) 19 | 1. **UV Workflow Optimization**: Implement UV's advanced features (workspace management, overrides) 20 | 1. **NOX UV Integration**: Use UV within NOX sessions for faster environment setup 21 | 1. **Cross-Tool Automation**: Create scripts that coordinate mise/uv/nox for complex workflows 22 | 1. **Performance Monitoring**: Add benchmarks comparing old vs new tooling performance 23 | 1. **Documentation**: Create guides for optimal mise/uv/nox usage patterns 24 | 25 | ## Benefits 26 | 27 | - Faster development cycles with optimized tooling 28 | - Better developer experience with automated workflows 29 | - Improved CI/CD performance 30 | - Future-proof tooling stack aligned with Python ecosystem trends 31 | 32 | ## Estimated Effort 33 | 34 | Medium (1-2 weeks): Configuration, scripting, testing, documentation 35 | -------------------------------------------------------------------------------- /tests/experiments/test_sync_package_dependencies.py: -------------------------------------------------------------------------------- 1 | """Tests for sync_package_dependencies.""" 2 | 3 | 4 | from calcipy.experiments.sync_package_dependencies import ( 5 | _collect_pyproject_versions, 6 | _extract_base_version, 7 | _replace_pyproject_versions, 8 | ) 9 | 10 | 11 | def test_extract_base_version(): 12 | """Test _extract_base_version function.""" 13 | assert _extract_base_version('>=1.0.0,<2.0.0') == '1.0.0' 14 | assert _extract_base_version('1.0.0') == '1.0.0' 15 | assert _extract_base_version('^1.0.0') == '1.0.0' 16 | 17 | 18 | def test_collect_pyproject_versions(): 19 | """Test _collect_pyproject_versions function.""" 20 | pyproject_text = """ 21 | [tool.poetry.dependencies] 22 | python = "^3.9" 23 | requests = ">=2.0.0,<3.0.0" 24 | flask = "2.0.0" 25 | """ 26 | versions = _collect_pyproject_versions(pyproject_text) 27 | assert versions == {'requests': '2.0.0', 'flask': '2.0.0'} 28 | 29 | 30 | def test_replace_pyproject_versions(): 31 | """Test _replace_pyproject_versions function.""" 32 | pyproject_text = """ 33 | [tool.poetry.dependencies] 34 | requests = ">=2.0.0,<3.0.0" 35 | flask = "2.0.0" 36 | """ 37 | lock_versions = {'requests': '2.1.0', 'flask': '2.1.0'} 38 | pyproject_versions = {'requests': '2.0.0', 'flask': '2.0.0'} 39 | new_text = _replace_pyproject_versions(lock_versions, pyproject_versions, pyproject_text) 40 | assert '>=2.1.0,<3.0.0' in new_text 41 | assert 'flask = "2.1.0"' in new_text 42 | -------------------------------------------------------------------------------- /plan/tooling-refactor.md: -------------------------------------------------------------------------------- 1 | # Tooling Refactor Based on Ideas 2 | 3 | ## Overview 4 | 5 | The `ideas.txt` file contains proposals to refactor the tooling architecture, moving components to templates and simplifying calcipy's scope. 6 | 7 | ## Current Status 8 | 9 | Ideas from `ideas.txt`: 10 | 11 | - Move noxfile import to calcipy-template instead of calcipy 12 | - Move most CLI functionality to `mise.toml` (distributed with copier-template) 13 | - Keep only first-party tooling in calcipy (wrappers around flake8, etc.) 14 | 15 | ## Proposed Mini-Project 16 | 17 | Implement the architectural changes: 18 | 19 | 1. **NOX Template Migration**: Move noxfile logic to calcipy-template, make calcipy's noxfile a minimal wrapper 20 | 1. **CLI to Mise**: Migrate CLI commands from calcipy scripts to mise.toml tasks 21 | 1. **Scope Reduction**: Identify and move third-party tool wrappers to templates, keep only calcipy-specific functionality 22 | 1. **Template Updates**: Update copier-answers and template files to support new structure 23 | 1. **Documentation**: Update docs to reflect new tooling boundaries 24 | 25 | ## Benefits 26 | 27 | - Clearer separation of concerns between calcipy (library) and calcipy-template (project setup) 28 | - Better maintainability with mise handling project-level tasks 29 | - Reduced complexity in calcipy core 30 | - Easier adoption and customization for users 31 | 32 | ## Estimated Effort 33 | 34 | High (2-3 weeks): Architecture changes, template updates, testing, migration guides 35 | -------------------------------------------------------------------------------- /plan/code-tags-cleanup.md: -------------------------------------------------------------------------------- 1 | # Code Tags Cleanup 2 | 3 | ## Overview 4 | 5 | The codebase contains several TODO and FIXME tags that need to be addressed. The project has tooling to collect and track these via `CODE_TAG_SUMMARY.md`. 6 | 7 | ## Current Status 8 | 9 | Active TODOs (6 total): 10 | 11 | - `pyproject.toml:1`: Sync with copier-uv template 12 | - `calcipy/collection.py:50`: How to capture output? 13 | - `calcipy/experiments/sync_package_dependencies.py:52`: Handle ">=3.0.0,\<4" version constraints 14 | - `calcipy/noxfile/_noxfile.py:55`: Migrate to uv 15 | - `calcipy/tasks/pack.py:77`: Add unit test for pack functionality 16 | - `calcipy/tasks/pack.py:110`: Add unit test for pack functionality 17 | 18 | PLANNED items (4 total) in docstrings. 19 | 20 | ## Proposed Mini-Project 21 | 22 | Systematically address each TODO: 23 | 24 | 1. **Copier-UV Sync**: Research and implement changes from pawamoy/copier-uv 25 | 1. **Output Capture**: Implement proper output capturing in collection.py 26 | 1. **Version Constraints**: Add support for complex version ranges in sync_package_dependencies.py 27 | 1. **NOX UV Migration**: Complete UV integration in noxfile 28 | 1. **Unit Tests**: Add comprehensive tests for pack.py functions 29 | 1. **PLANNED Items**: Convert PLANNED docstring items to implemented features or remove if obsolete 30 | 31 | ## Benefits 32 | 33 | - Cleaner codebase with resolved technical debt 34 | - Improved functionality (output capture, version handling) 35 | - Better test coverage 36 | - Alignment with project goals 37 | 38 | ## Estimated Effort 39 | 40 | Medium-High (1-2 weeks): Research, implementation, testing for each TODO 41 | -------------------------------------------------------------------------------- /docs/docs/CODE_TAG_SUMMARY.md: -------------------------------------------------------------------------------- 1 | # Collected Code Tags 2 | 3 | | Type | Comment | Last Edit | Source File | 4 | |---------|----------------------------------------------------------------------|------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------| 5 | | PLANNED | finish updating docstrings for Returns | 2024-10-08 | [pyproject.toml:166](https://github.com/KyleKing/calcipy/blame/9cf3c6d2d9820cec475d35bdb7c53fc83627a4b2/pyproject.toml#L161) | 6 | | FIXME | port back to corallium (temporarily extended to support uv and mise) | 2025-11-02 | [src/calcipy/_corallium/file_helpers.py:60](https://github.com/KyleKing/calcipy/blame/0fb7b31846520a8d4f24f876ef0e87a294087d01/src/calcipy/_corallium/file_helpers.py#L58) | 7 | | FIXME | port back to corallium (temporarily extended to support uv) | 2025-11-02 | [src/calcipy/_corallium/file_helpers.py:85](https://github.com/KyleKing/calcipy/blame/0fb7b31846520a8d4f24f876ef0e87a294087d01/src/calcipy/_corallium/file_helpers.py#L83) | 8 | | TODO | Performantly support either ./src/<>/ and ./<>/ | 2025-11-02 | [src/calcipy/tasks/lint.py:33](https://github.com/KyleKing/calcipy/blame/0fb7b31846520a8d4f24f876ef0e87a294087d01/src/calcipy/tasks/lint.py#L33) | 9 | 10 | Found code tags for FIXME (2), TODO (1), PLANNED (1) 11 | 12 | 13 | -------------------------------------------------------------------------------- /.github/workflows/ci_pipeline.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: CI Pipeline 3 | 4 | "on": 5 | push: 6 | branches: [main] 7 | pull_request: 8 | paths: 9 | - .github/workflows/ci_pipeline.yml 10 | - calcipy/** 11 | - tests/** 12 | - uv.lock 13 | - pyproject.toml 14 | 15 | jobs: 16 | lint: 17 | runs-on: ubuntu-latest 18 | strategy: 19 | matrix: 20 | python-version: ["3.10"] 21 | steps: 22 | - uses: actions/checkout@v4 23 | - uses: ./.github/actions/setup 24 | with: 25 | python-version: ${{ matrix.python-version }} 26 | 27 | - name: Run static linters 28 | run: uv run calcipy-lint lint.check 29 | 30 | test: 31 | runs-on: ${{ matrix.os }} 32 | strategy: 33 | matrix: 34 | os: [macos-latest, windows-latest] 35 | python-version: ["3.10"] 36 | steps: 37 | - uses: actions/checkout@v4 38 | - uses: ./.github/actions/setup 39 | with: 40 | python-version: ${{ matrix.python-version }} 41 | 42 | - name: Run test 43 | run: uv run calcipy-test test.pytest 44 | 45 | typecheck: 46 | runs-on: ubuntu-latest 47 | strategy: 48 | matrix: 49 | python-version: ["3.10"] 50 | steps: 51 | - uses: actions/checkout@v4 52 | - uses: ./.github/actions/setup 53 | with: 54 | python-version: ${{ matrix.python-version }} 55 | 56 | - name: Run typecheck 57 | run: uv run calcipy-types types.mypy 58 | 59 | # https://github.com/jakebailey/pyright-action/blob/ea37d1c67b7fc90ab9ab4571114e203b313152a2/README.md#use-with-a-virtualenv 60 | - name: Add Virtual Environment to Path for pyright 61 | run: echo "$PWD/.venv/bin" >> $GITHUB_PATH 62 | - uses: jakebailey/pyright-action@v2 63 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | --- 2 | site_name: calcipy 3 | site_author: Kyle King 4 | site_description: calcipy project documentation 5 | site_url: https://calcipy.kyleking.me 6 | repo_name: kyleking/calcipy 7 | repo_url: https://github.com/kyleking/calcipy 8 | edit_uri: edit/main/docs 9 | docs_dir: docs 10 | site_dir: releases/site 11 | 12 | theme: 13 | name: material 14 | palette: 15 | - scheme: default 16 | accent: green 17 | icon: 18 | repo: fontawesome/brands/github 19 | features: 20 | - toc.autohide 21 | 22 | watch: 23 | - src/calcipy 24 | 25 | plugins: 26 | - gen-files: 27 | scripts: 28 | - docs/gen_ref_nav.py 29 | - mkdocstrings: 30 | handlers: 31 | python: 32 | options: 33 | docstring_section_style: spacy 34 | line_length: 120 35 | separate_signature: true 36 | show_category_heading: true 37 | - search 38 | 39 | markdown_extensions: 40 | - abbr 41 | - admonition 42 | - attr_list 43 | - codehilite: 44 | linenums: true 45 | - def_list 46 | - extra 47 | - fenced_code 48 | - footnotes 49 | - pymdownx.emoji: 50 | emoji_index: !!python/name:material.extensions.emoji.twemoji 51 | emoji_generator: !!python/name:material.extensions.emoji.to_svg 52 | - pymdownx.details 53 | - pymdownx.highlight: 54 | linenums: true 55 | linenums_style: pymdownx-inline 56 | - pymdownx.superfences 57 | - pymdownx.tabbed 58 | - pymdownx.tasklist: 59 | custom_checkbox: true 60 | clickable_checkbox: true 61 | - smarty 62 | - tables 63 | - toc: 64 | permalink: ⚓︎ 65 | toc_depth: 5 66 | 67 | extra_css: 68 | - _styles/mkdocstrings.css 69 | 70 | extra_javascript: 71 | - https://cdnjs.cloudflare.com/ajax/libs/tablesort/5.2.1/tablesort.min.js 72 | - _javascript/tables.js 73 | -------------------------------------------------------------------------------- /docs/gen_ref_nav.py: -------------------------------------------------------------------------------- 1 | """Generate the code reference pages. 2 | 3 | Adapted without navigation from: 4 | https://github.com/pawamoy/copier-pdm/blob/adff9b64887d0b4c9ec0b42de1698b34858a511e/project/scripts/gen_ref_nav.py 5 | 6 | """ 7 | 8 | from pathlib import Path 9 | 10 | import mkdocs_gen_files 11 | from corallium.tomllib import tomllib 12 | 13 | 14 | def has_public_code(line: str) -> bool: 15 | """Determine if a given line contains code that will be documented. 16 | 17 | Returns: 18 | bool: True if line appears to be a public function, class, or method 19 | 20 | """ 21 | for key in ('def', 'async def', 'class'): 22 | starts = line.startswith(f'{key} ') 23 | if starts and not line.startswith(f'{key} _'): 24 | return True 25 | if starts: 26 | break 27 | return False 28 | 29 | 30 | _config = tomllib.loads(Path('pyproject.toml').read_text(encoding='utf-8')) 31 | _pkg_name = _config['project']['name'] 32 | src = Path(_pkg_name) 33 | for path in sorted(src.rglob('*.py')): 34 | for line in path.read_text().split('\n'): 35 | if has_public_code(line): 36 | break 37 | else: 38 | continue # Do not include the file in generated documentation 39 | 40 | module_path = path.with_suffix('') 41 | doc_path = path.with_suffix('.md') 42 | full_doc_path = Path('reference', doc_path) 43 | 44 | parts = tuple(module_path.parts) 45 | if parts[-1] == '__init__': 46 | parts = parts[:-1] 47 | doc_path = doc_path.with_name('index.md') 48 | full_doc_path = full_doc_path.with_name('index.md') 49 | elif parts[-1].startswith('_'): 50 | continue 51 | 52 | with mkdocs_gen_files.open(full_doc_path, 'w') as fd: 53 | ident = '.'.join(parts) 54 | fd.write(f'::: {ident}') 55 | 56 | mkdocs_gen_files.set_edit_path(full_doc_path, path) 57 | -------------------------------------------------------------------------------- /tests/tasks/test_test.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import call 2 | 3 | import pytest 4 | 5 | from calcipy.tasks.executable_utils import python_dir 6 | from calcipy.tasks.test import check, coverage, watch 7 | from calcipy.tasks.test import pytest as task_pytest 8 | 9 | _COV = '--cov=calcipy --cov-branch --cov-report=term-missing --durations=25 --durations-min="0.1"' 10 | _MARKERS = 'mark1 and not mark 2' 11 | _FAILFIRST = '--failed-first --new-first --exitfirst -vv --no-cov' 12 | 13 | 14 | @pytest.mark.parametrize( 15 | ('task', 'kwargs', 'commands'), 16 | [ 17 | (task_pytest, {}, [f'{python_dir() / "pytest"} ./tests {_COV}']), 18 | (task_pytest, {'keyword': 'test'}, [f'{python_dir() / "pytest"} ./tests {_COV} -k "test"']), 19 | (task_pytest, {'marker': _MARKERS}, [f'{python_dir() / "pytest"} ./tests {_COV} -m "{_MARKERS}"']), 20 | (watch, {'marker': _MARKERS}, [f'{python_dir() / "ptw"} . --now ./tests {_FAILFIRST} -m "{_MARKERS}"']), 21 | (coverage, {'out_dir': '.cover'}, [ 22 | f'{python_dir() / "coverage"} run --branch --source=calcipy --module pytest ./tests', 23 | call(f'{python_dir() / "coverage"} report --show-missing'), 24 | call(f'{python_dir() / "coverage"} html --directory=.cover'), 25 | call(f'{python_dir() / "coverage"} json'), 26 | ]), 27 | ], 28 | ids=[ 29 | 'Default test', 30 | 'Default test with keyword', 31 | 'Default test with marker', 32 | 'watch', 33 | 'coverage', 34 | ], 35 | ) 36 | def test_test(ctx, task, kwargs, commands): 37 | task(ctx, **kwargs) 38 | 39 | ctx.run.assert_has_calls([ 40 | call(cmd) if isinstance(cmd, str) else cmd 41 | for cmd in commands 42 | ]) 43 | 44 | 45 | def test_test_check(ctx): 46 | with pytest.raises(RuntimeError, match=r'Duplicate test names.+test_intentional_duplicate.+'): 47 | check(ctx) 48 | -------------------------------------------------------------------------------- /tests/tasks/test_pack.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import call, patch 2 | 3 | import pytest 4 | 5 | from calcipy import can_skip 6 | from calcipy.tasks.pack import bump_tag, lock, publish, sync_pyproject_versions 7 | 8 | PUBLISH_ENV = {'UV_PUBLISH_USERNAME': 'pypi_user', 'UV_PUBLISH_PASSWORD': 'pypi_password'} 9 | """Set in `tests/__init__.py`.""" 10 | 11 | 12 | @pytest.mark.parametrize( 13 | ('task', 'kwargs', 'commands'), 14 | [ 15 | (lock, {}, [call('uv lock')]), 16 | (publish, {}, ['uv build --no-sources', call('uv publish', env=PUBLISH_ENV)]), 17 | ], 18 | ) 19 | def test_pack(ctx, task, kwargs, commands, monkeypatch): 20 | monkeypatch.setattr(can_skip, 'can_skip', can_skip.dont_skip) 21 | with patch('calcipy.tasks.pack.keyring'): 22 | task(ctx, **kwargs) 23 | 24 | ctx.run.assert_has_calls([call(cmd) if isinstance(cmd, str) else cmd for cmd in commands]) 25 | 26 | 27 | def test_bump_tag(ctx, monkeypatch): 28 | """Test bump_tag function.""" 29 | mock_pyproject = {'project': {'name': 'test-package'}} 30 | mock_bump = patch('calcipy.experiments.bump_programmatically.bump_tag', return_value='1.2.3') 31 | mock_read_pyproject = patch('calcipy.tasks.pack.file_helpers.read_pyproject', return_value=mock_pyproject) 32 | 33 | with mock_read_pyproject, mock_bump as bump_mock: 34 | bump_tag(ctx, tag='v1.2.2', tag_prefix='v') 35 | 36 | bump_mock.assert_called_once_with(pkg_name='test-package', tag='v1.2.2', tag_prefix='v') 37 | 38 | 39 | def test_sync_pyproject_versions(ctx): 40 | """Test sync_pyproject_versions function.""" 41 | mock_replace = patch('calcipy.experiments.sync_package_dependencies.replace_versions') 42 | mock_get_lock = patch('calcipy.tasks.pack.get_lock', return_value='path/to/lock') 43 | 44 | with mock_replace as replace_mock, mock_get_lock as get_lock_mock: 45 | sync_pyproject_versions(ctx) 46 | 47 | get_lock_mock.assert_called_once() 48 | replace_mock.assert_called_once_with(path_lock='path/to/lock') 49 | -------------------------------------------------------------------------------- /src/calcipy/invoke_helpers.py: -------------------------------------------------------------------------------- 1 | """Invoke Helpers.""" 2 | 3 | import platform 4 | from contextlib import suppress 5 | from functools import lru_cache 6 | from os import environ 7 | from pathlib import Path 8 | 9 | from beartype.typing import Any, Optional 10 | from corallium.file_helpers import COPIER_ANSWERS, read_yaml_file 11 | from invoke.context import Context 12 | from invoke.runners import Result 13 | 14 | # ---------------------------------------------------------------------------------------------------------------------- 15 | # General Invoke 16 | 17 | 18 | @lru_cache(maxsize=1) 19 | def use_pty() -> bool: 20 | """Return False on Windows and some CI environments.""" 21 | if platform.system() == 'Windows': 22 | return False 23 | return not environ.get('GITHUB_ACTION') 24 | 25 | 26 | def run(ctx: Context, *run_args: Any, **run_kwargs: Any) -> Optional[Result]: 27 | """Return wrapped `invoke.run` to run within the `working_dir`.""" 28 | working_dir = '.' 29 | with suppress(AttributeError): 30 | working_dir = ctx.config.gto.working_dir 31 | 32 | with ctx.cd(working_dir): 33 | return ctx.run(*run_args, **run_kwargs) 34 | 35 | 36 | # ---------------------------------------------------------------------------------------------------------------------- 37 | # Invoke Task Helpers 38 | 39 | 40 | @lru_cache(maxsize=1) 41 | def get_project_path() -> Path: 42 | """Returns the `cwd`.""" 43 | return Path.cwd() 44 | 45 | 46 | def get_doc_subdir(path_project: Optional[Path] = None) -> Path: 47 | """Retrieve the documentation directory from the copier answer file. 48 | 49 | Args: 50 | path_project: Path to the project directory with contains `.copier-answers.yml` 51 | 52 | Returns: 53 | Path: to the source documentation directory 54 | 55 | """ 56 | path_copier = (path_project or get_project_path()) / COPIER_ANSWERS 57 | doc_dir = read_yaml_file(path_copier).get('doc_dir', 'docs') 58 | return path_copier.parent / doc_dir / 'docs' 59 | -------------------------------------------------------------------------------- /tests/tasks/test_tags.py: -------------------------------------------------------------------------------- 1 | # mypy: disable_error_code=type-arg 2 | 3 | from pathlib import Path 4 | 5 | import pytest 6 | from beartype.typing import Callable, Dict 7 | 8 | from calcipy.tasks.tags import collect_code_tags 9 | from tests.configuration import APP_DIR, TEST_DATA_DIR 10 | 11 | 12 | def _merge_path_kwargs(kwargs: Dict) -> Path: 13 | return Path(f"{kwargs['doc_sub_dir']}/{kwargs['filename']}") 14 | 15 | 16 | def _check_no_write(kwargs: Dict) -> None: 17 | path_tag_summary = _merge_path_kwargs(kwargs) 18 | assert not path_tag_summary.is_file() 19 | 20 | 21 | def _check_output(kwargs: Dict) -> None: 22 | path_tag_summary = _merge_path_kwargs(kwargs) 23 | assert path_tag_summary.is_file() 24 | content = path_tag_summary.read_text() 25 | path_tag_summary.unlink() 26 | assert '# Collected Code Tags' in content 27 | 28 | 29 | @pytest.mark.parametrize( 30 | ('task', 'kwargs', 'validator'), 31 | [ 32 | ( 33 | collect_code_tags, 34 | { 35 | 'base_dir': APP_DIR.as_posix(), 36 | 'doc_sub_dir': TEST_DATA_DIR.as_posix(), 37 | 'filename': 'test_tags.md.rej', 38 | 'tag_order': 'FIXME,TODO', 39 | 'regex': '', 40 | 'ignore_patterns': '*.py,*.yaml,docs/docs/*.md,*.toml', 41 | }, 42 | _check_no_write, 43 | ), 44 | ( 45 | collect_code_tags, 46 | { 47 | 'base_dir': APP_DIR.as_posix(), 48 | 'doc_sub_dir': TEST_DATA_DIR.as_posix(), 49 | 'filename': 'test_tags.md.rej', 50 | }, 51 | _check_output, 52 | ), 53 | ], 54 | ids=[ 55 | 'Check that no code tags were matched and no file was created', 56 | 'Check that code tags were matched and a file was created', 57 | ], 58 | ) 59 | def test_tags(ctx, task, kwargs: Dict, validator: Callable[[Dict], None]): 60 | task(ctx, **kwargs) 61 | 62 | validator(kwargs) 63 | -------------------------------------------------------------------------------- /src/calcipy/experiments/check_duplicate_test_names.py: -------------------------------------------------------------------------------- 1 | """Experiment with checking for duplicate test names.""" 2 | 3 | import ast 4 | from pathlib import Path 5 | 6 | from beartype.typing import List, Union 7 | from corallium.log import LOGGER 8 | 9 | 10 | def _show_info(function: Union[ast.FunctionDef, ast.AsyncFunctionDef]) -> None: 11 | """Print info about the function.""" 12 | LOGGER.info('> name', name=function.name) 13 | if function.args.args: 14 | LOGGER.info('\t args', args=function.args.args) 15 | 16 | 17 | def run(test_path: Path) -> List[str]: # noqa: C901 18 | """Check for duplicates in the test suite. 19 | 20 | Inspired by: https://stackoverflow.com/a/67840804/3219667 21 | 22 | """ 23 | summary = set() 24 | duplicates = [] 25 | 26 | for path_test in test_path.rglob('test_*.py'): 27 | LOGGER.info(path_test.as_posix()) 28 | parsed_ast = ast.parse(path_test.read_text()) 29 | 30 | for node in parsed_ast.body: 31 | if isinstance(node, ast.FunctionDef): 32 | if node.name in summary and node.name.startswith('test_'): 33 | duplicates.append(node.name) 34 | summary.add(node.name) 35 | _show_info(node) 36 | elif isinstance(node, ast.ClassDef): 37 | LOGGER.info('Class name', name=node.name) 38 | for method in node.body: 39 | if isinstance(method, ast.FunctionDef): 40 | _show_info(method) 41 | 42 | for node in ast.walk(parsed_ast): # type: ignore[assignment] 43 | if ( 44 | isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) 45 | and node.name not in summary 46 | ): 47 | LOGGER.info('Found new function(s) through walking') 48 | _show_info(node) 49 | summary.add(node.name) 50 | 51 | if duplicates: 52 | LOGGER.error('Found Duplicates', duplicates=duplicates) 53 | return duplicates 54 | 55 | 56 | if __name__ == '__main__': # pragma: no cover 57 | run(Path('tests')) 58 | -------------------------------------------------------------------------------- /src/calcipy/tasks/tags.py: -------------------------------------------------------------------------------- 1 | """Code Tag Collector CLI.""" 2 | 3 | from pathlib import Path 4 | 5 | from beartype.typing import Optional 6 | from invoke.context import Context 7 | 8 | from calcipy.cli import task 9 | from calcipy.code_tag_collector import write_code_tag_file 10 | from calcipy.file_search import find_project_files 11 | from calcipy.invoke_helpers import get_doc_subdir 12 | 13 | from .defaults import from_ctx 14 | 15 | 16 | @task( 17 | default=True, 18 | help={ 19 | 'base_dir': 'Working Directory', 20 | 'doc_sub_dir': 'Subdirectory for output of the code tag summary file', 21 | 'filename': 'Code Tag Summary Filename', 22 | 'tag_order': 'Ordered list of code tags to locate (Comma-separated)', 23 | 'regex': 'Custom Code Tag Regex. Must contain "{tag}"', 24 | 'ignore_patterns': 'Glob patterns to ignore files and directories when searching (Comma-separated)', 25 | }, 26 | ) 27 | def collect_code_tags( # noqa: PLR0917 28 | ctx: Context, 29 | base_dir: str = '.', 30 | doc_sub_dir: str = '', 31 | filename: Optional[str] = None, 32 | tag_order: str = '', 33 | regex: str = '', 34 | ignore_patterns: str = '', 35 | ) -> None: 36 | """Create a `CODE_TAG_SUMMARY.md` with a table for TODO- and FIXME-style code comments.""" 37 | pth_base_dir = Path(base_dir).resolve() 38 | pth_docs = pth_base_dir / doc_sub_dir if doc_sub_dir else get_doc_subdir() 39 | if filename and '/' in filename: 40 | raise RuntimeError('Unexpected slash in filename. You should consider setting `--doc-sub-dir` instead') 41 | path_tag_summary = pth_docs / (filename or from_ctx(ctx, 'tags', 'filename')) 42 | patterns = (ignore_patterns or from_ctx(ctx, 'tags', 'ignore_patterns')).split(',') 43 | paths_source = find_project_files(pth_base_dir, ignore_patterns=[pattern for pattern in patterns if pattern]) 44 | 45 | write_code_tag_file( 46 | path_tag_summary=path_tag_summary, 47 | paths_source=paths_source, 48 | base_dir=pth_base_dir, 49 | regex=regex, 50 | tags=tag_order, 51 | header='# Collected Code Tags', 52 | ) 53 | -------------------------------------------------------------------------------- /src/calcipy/markdown_table.py: -------------------------------------------------------------------------------- 1 | """Markdown table formatting.""" 2 | 3 | from __future__ import annotations 4 | 5 | from collections.abc import Iterable 6 | from typing import Any 7 | 8 | 9 | def format_table( 10 | headers: list[str], 11 | records: list[dict[str, Any]], 12 | delimiters: list[str] | None = None, 13 | ) -> str: 14 | """Returns a formatted Github Markdown table. 15 | 16 | Args: 17 | headers: ordered keys to use as column title 18 | records: list of key:row-value dictionaries 19 | delimiters: optional list to allow for alignment 20 | 21 | """ 22 | table = [[str(_r[col]) for col in headers] for _r in records] 23 | widths = [max(len(row[col_idx].strip()) for row in [headers, *table]) for col_idx in range(len(headers))] 24 | 25 | def pad(values: list[str]) -> list[str]: 26 | return [val.strip().ljust(widths[col_idx]) for col_idx, val in enumerate(values)] 27 | 28 | def join(row: Iterable[str], spacer: str = ' ') -> str: 29 | return f'|{spacer}' + f'{spacer}|{spacer}'.join(row) + f'{spacer}|' 30 | 31 | def expand_delimiters(delim: str, width: int) -> str: 32 | expanded = '-' * (width + 2) 33 | if delim.startswith(':'): 34 | expanded = ':' + expanded[1:] 35 | if delim.endswith(':'): 36 | expanded = expanded[:-1] + ':' 37 | return expanded 38 | 39 | if delimiters: 40 | errors = [] 41 | if len(delimiters) != len(headers): 42 | errors.append(f'Incorrect number of delimiters provided ({len(delimiters)}). Expected: ({len(headers)})') 43 | allowed_delimiters = {'-', ':-', '-:', ':-:'} 44 | if not all(delim in allowed_delimiters for delim in delimiters): 45 | errors.append(f'Delimiters must one of ({len(allowed_delimiters)}). Received: ({len(delimiters)})') 46 | if errors: 47 | raise ValueError(' and '.join(errors)) 48 | 49 | lines = [ 50 | join(pad(headers)), 51 | join(map(expand_delimiters, delimiters or ['-'] * len(headers), widths), ''), 52 | *[join(pad(row)) for row in table], 53 | ] 54 | return '\n'.join(lines) 55 | -------------------------------------------------------------------------------- /src/calcipy/tasks/cl.py: -------------------------------------------------------------------------------- 1 | """Changelog CLI.""" 2 | 3 | from beartype.typing import Literal, Optional 4 | from invoke.context import Context 5 | 6 | from calcipy.cli import task 7 | from calcipy.invoke_helpers import get_doc_subdir, get_project_path, run 8 | 9 | from .executable_utils import GH_MESSAGE, check_installed, python_dir 10 | 11 | SuffixT = Optional[Literal['alpha', 'beta', 'rc']] 12 | """Prerelease Suffix Type.""" 13 | 14 | 15 | @task() 16 | def write(ctx: Context) -> None: 17 | """Write a Changelog file with the raw Git history. 18 | 19 | Resources: 20 | 21 | - https://keepachangelog.com/en/1.0.0/ 22 | - https://www.conventionalcommits.org/en/v1.0.0/ 23 | - https://writingfordevelopers.substack.com/p/how-to-write-a-commit-message 24 | - https://chris.beams.io/posts/git-commit/ 25 | - https://semver.org/ 26 | - https://calver.org/ 27 | 28 | Raises: 29 | FileNotFoundError: On missing changelog 30 | 31 | """ 32 | run(ctx, f'{python_dir() / "cz"} changelog') # with commitizen 33 | path_cl = get_project_path() / 'CHANGELOG.md' 34 | if not path_cl.is_file(): 35 | msg = f'Could not locate the changelog at: {path_cl}' 36 | raise FileNotFoundError(msg) 37 | path_cl.replace(get_doc_subdir() / path_cl.name) 38 | 39 | 40 | def bumpz(ctx: Context, *, suffix: SuffixT = None) -> None: 41 | """Bumps project version based on commits & settings in pyproject.toml.""" 42 | check_installed(ctx, executable='gh', message=GH_MESSAGE) 43 | 44 | opt_cz_args = f' --prerelease={suffix}' if suffix else '' 45 | run(ctx, f'{python_dir() / "cz"} bump{opt_cz_args} --annotated-tag --no-verify --gpg-sign') 46 | 47 | run(ctx, 'git push origin --tags --no-verify') 48 | 49 | get_last_tag = 'git tag --list --sort=-creatordate | head -n 1' 50 | opt_gh_args = ' --prerelease' if suffix else '' 51 | run(ctx, f'gh release create --generate-notes $({get_last_tag}){opt_gh_args}') 52 | 53 | 54 | @task( 55 | pre=[write], 56 | help={ 57 | 'suffix': 'Specify prerelease suffix for version bump (alpha, beta, rc)', 58 | }, 59 | ) 60 | def bump(ctx: Context, *, suffix: SuffixT = None) -> None: 61 | """Bumps project version based on commits & settings in pyproject.toml.""" 62 | bumpz(ctx, suffix=suffix) 63 | -------------------------------------------------------------------------------- /tests/md_writer/__snapshots__/test_writer.ambr: -------------------------------------------------------------------------------- 1 | # serializer version: 1 2 | # name: test_format_cov_table 3 | ''' 4 | | File | Statements | Missing | Excluded | Coverage | 5 | |-----------------------------------|-----------:|--------:|---------:|---------:| 6 | | `calcipy/doit_tasks/base.py` | 22 | 2 | 3 | 90.9% | 7 | | `calcipy/doit_tasks/code_tags.py` | 75 | 44 | 0 | 41.3% | 8 | | **Totals** | 97 | 46 | 3 | 52.6% | 9 | 10 | Generated on: 2021-06-03 11 | ''' 12 | # --- 13 | # name: test_write_template_formatted_md_sections 14 | ''' 15 | 16 | 17 | # Title 18 | 19 | > Test markdown formatting from [recipes](https://github.com/KyleKing/recipes) 20 | 21 | 22 | Personal rating: *Not yet rated* 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | | File | Statements | Missing | Excluded | Coverage | 31 | |-----------------------------------|-----------:|--------:|---------:|---------:| 32 | | `calcipy/doit_tasks/base.py` | 22 | 2 | 3 | 90.9% | 33 | | `calcipy/doit_tasks/code_tags.py` | 75 | 44 | 0 | 41.3% | 34 | | **Totals** | 97 | 46 | 3 | 52.6% | 35 | 36 | Generated on: 2021-06-03 37 | 38 | 39 | 40 | ```py 41 | """Final test alphabetically (zz) to catch general integration cases.""" 42 | 43 | from pathlib import Path 44 | 45 | from corallium.tomllib import tomllib 46 | 47 | from calcipy import __version__ 48 | 49 | 50 | def test_version(): 51 | """Check that PyProject and package __version__ are equivalent.""" 52 | data = Path('pyproject.toml').read_text(encoding='utf-8') 53 | 54 | result = tomllib.loads(data)['project']['version'] 55 | 56 | assert result == __version__ 57 | ``` 58 | 59 | 60 | ''' 61 | # --- 62 | -------------------------------------------------------------------------------- /src/calcipy/tasks/executable_utils.py: -------------------------------------------------------------------------------- 1 | """Utilities for working in calcipy's python environment.""" 2 | 3 | import sys 4 | from contextlib import suppress 5 | from functools import lru_cache 6 | from pathlib import Path 7 | 8 | from beartype.typing import Optional 9 | from invoke.context import Context 10 | from invoke.runners import Result 11 | 12 | from calcipy.invoke_helpers import run 13 | 14 | 15 | @lru_cache(maxsize=1) 16 | def resolve_python() -> Path: 17 | """Return the user's Python path based on `sys`.""" 18 | python_path = Path(sys.executable).absolute() 19 | with suppress(ValueError): 20 | return python_path.relative_to(Path.cwd()) 21 | return python_path 22 | 23 | 24 | @lru_cache(maxsize=1) 25 | def python_dir() -> Path: 26 | """Return an executable path from the currently active Python directory.""" 27 | return resolve_python().parent 28 | 29 | 30 | @lru_cache(maxsize=1) 31 | def python_m() -> str: 32 | """Return the active python path and `-m` flag.""" 33 | return f'{resolve_python()} -m' 34 | 35 | 36 | GH_MESSAGE = """ 37 | `gh` was not found and must be installed separately (such as 'brew install gh' on Mac). 38 | See the online documentation for your system: https://cli.github.com/ 39 | """ 40 | PRE_COMMIT_MESSAGE = """ 41 | `prek` was not found and must be installed separately (such as 'brew install prek' on Mac). 42 | See the online documentation for your system: https://prek.com/#install 43 | """ 44 | PYRIGHT_MESSAGE = """ 45 | `pyright` was not found and must be installed separately (such as 'brew install pyright' on Mac). 46 | See the online documentation for your system: https://microsoft.github.io/pyright/#/installation 47 | """ 48 | 49 | _EXECUTABLE_CACHE: dict[str, Optional[Result]] = {} 50 | """Runtime cache of executables.""" 51 | 52 | 53 | def check_installed(ctx: Context, executable: str, message: str) -> None: 54 | """If the required executable isn't present, raise a clear user error. 55 | 56 | Raises: 57 | RuntimeError: if not missing 58 | 59 | """ 60 | if executable in _EXECUTABLE_CACHE: 61 | res = _EXECUTABLE_CACHE[executable] 62 | else: 63 | res = run(ctx, f'which {executable}', warn=True, hide=True) 64 | _EXECUTABLE_CACHE[executable] = res 65 | 66 | if not res or res.exited == 1: 67 | raise RuntimeError(message) 68 | -------------------------------------------------------------------------------- /src/calcipy/scripts.py: -------------------------------------------------------------------------------- 1 | """Start the command line program.""" 2 | 3 | from types import ModuleType 4 | 5 | from beartype.typing import List 6 | from corallium.log import LOGGER 7 | 8 | from . import __pkg_name__, __version__ 9 | from .cli import start_program 10 | from .collection import Collection 11 | 12 | 13 | def start() -> None: # pragma: no cover 14 | """Run the customized Invoke Program.""" 15 | try: 16 | from .tasks import all_tasks # noqa: PLC0415 17 | 18 | start_program(__pkg_name__, __version__, all_tasks) 19 | except (ImportError, RuntimeError) as error: 20 | from .tasks import most_tasks # noqa: PLC0415 21 | 22 | LOGGER.error(str(error)) # Must be first 23 | print() # noqa: T201 24 | start_program(__pkg_name__, __version__, most_tasks) 25 | 26 | 27 | def _start_subset(modules: List[ModuleType]) -> None: # pragma: no cover 28 | """Run the specified subset.""" 29 | from .tasks.defaults import new_collection # noqa: PLC0415 30 | 31 | ns = new_collection() 32 | for module in modules: 33 | ns.add_collection(Collection.from_module(module)) 34 | 35 | start_program(__pkg_name__, __version__, collection=ns) 36 | 37 | 38 | def start_docs() -> None: # pragma: no cover 39 | """Run CLI with only the cl and doc namespaces.""" 40 | from .tasks import cl, doc # noqa: PLC0415 41 | 42 | _start_subset([cl, doc]) 43 | 44 | 45 | def start_lint() -> None: # pragma: no cover 46 | """Run CLI with only the lint namespace.""" 47 | from .tasks import lint # noqa: PLC0415 48 | 49 | _start_subset([lint]) 50 | 51 | 52 | def start_pack() -> None: # pragma: no cover 53 | """Run CLI with only the pack namespace.""" 54 | from .tasks import pack # noqa: PLC0415 55 | 56 | _start_subset([pack]) 57 | 58 | 59 | def start_tags() -> None: # pragma: no cover 60 | """Run CLI with only the tags namespace.""" 61 | from .tasks import tags # noqa: PLC0415 62 | 63 | _start_subset([tags]) 64 | 65 | 66 | def start_test() -> None: # pragma: no cover 67 | """Run CLI with only the test namespace.""" 68 | from .tasks import test # noqa: PLC0415 69 | 70 | _start_subset([test]) 71 | 72 | 73 | def start_types() -> None: # pragma: no cover 74 | """Run CLI with only the types namespace.""" 75 | from .tasks import types # noqa: PLC0415 76 | 77 | _start_subset([types]) 78 | -------------------------------------------------------------------------------- /src/calcipy/_runtime_type_check_setup.py: -------------------------------------------------------------------------------- 1 | """Conditionally configure runtime typechecking.""" 2 | 3 | from contextlib import suppress 4 | from datetime import datetime, timezone 5 | from enum import Enum 6 | from os import getenv 7 | from warnings import filterwarnings 8 | 9 | from typing_extensions import Self 10 | 11 | NAME = 'calcipy'.upper() 12 | """Package name to allow more targeted usage.""" 13 | 14 | 15 | class _RuntimeTypeCheckingModes(Enum): 16 | """Supported global runtime type checking modes.""" 17 | 18 | ERROR = 'ERROR' 19 | WARNING = 'WARNING' 20 | OFF = None 21 | 22 | @classmethod 23 | def from_environment(cls) -> Self: # pragma: no cover 24 | """Return the configured mode. 25 | 26 | Raises: 27 | ValueError: if environment variable is configured incorrectly 28 | 29 | """ 30 | rtc_mode = getenv('RUNTIME_TYPE_CHECKING_MODE') or getenv(f'RUNTIME_TYPE_CHECKING_MODE_{NAME}') or None 31 | try: 32 | return cls(rtc_mode) 33 | except ValueError: 34 | modes = [_e.value for _e in cls] 35 | msg = f"'RUNTIME_TYPE_CHECKING_MODE={rtc_mode}' is not from {modes}" 36 | raise ValueError(msg) from None 37 | 38 | 39 | def configure_runtime_type_checking_mode() -> None: # pragma: no cover 40 | """Optionally configure runtime type checking mode globally.""" 41 | rtc_mode = _RuntimeTypeCheckingModes.from_environment() 42 | 43 | if rtc_mode is not _RuntimeTypeCheckingModes.OFF: 44 | with suppress(ImportError, ModuleNotFoundError): 45 | # Requires beartype >=0.15.0 and Python >= 3.8 46 | from beartype import BeartypeConf # noqa: PLC0415 47 | from beartype.claw import beartype_this_package # noqa: PLC0415 48 | from beartype.roar import BeartypeClawDecorWarning # noqa: PLC0415 49 | 50 | beartype_this_package( 51 | conf=BeartypeConf( 52 | warning_cls_on_decorator_exception=( 53 | None if rtc_mode is _RuntimeTypeCheckingModes.ERROR else BeartypeClawDecorWarning 54 | ), 55 | ), 56 | ) 57 | 58 | 59 | _PEP585_DATE = 2025 60 | if datetime.now(tz=timezone.utc).year <= _PEP585_DATE: # pragma: no cover 61 | with suppress(ImportError, ModuleNotFoundError): 62 | from beartype.roar import BeartypeDecorHintPep585DeprecationWarning 63 | 64 | filterwarnings( 65 | 'ignore', 66 | category=BeartypeDecorHintPep585DeprecationWarning, 67 | ) 68 | -------------------------------------------------------------------------------- /src/calcipy/tasks/doc.py: -------------------------------------------------------------------------------- 1 | """Document CLI.""" 2 | 3 | import webbrowser 4 | from contextlib import suppress 5 | from pathlib import Path 6 | 7 | from corallium.file_helpers import ( 8 | MKDOCS_CONFIG, 9 | open_in_browser, 10 | read_yaml_file, 11 | ) 12 | from invoke.context import Context 13 | from invoke.exceptions import UnexpectedExit 14 | 15 | from calcipy.cli import task 16 | from calcipy.invoke_helpers import get_project_path, run 17 | from calcipy.md_writer import write_template_formatted_md_sections 18 | 19 | from .executable_utils import python_dir 20 | 21 | 22 | def get_out_dir() -> Path: 23 | """Returns the mkdocs-specified site directory.""" 24 | mkdocs_config = read_yaml_file(get_project_path() / MKDOCS_CONFIG) 25 | return Path(mkdocs_config.get('site_dir', 'releases/site')) 26 | 27 | 28 | @task() 29 | def build(ctx: Context) -> None: 30 | """Build documentation with mkdocs.""" 31 | write_template_formatted_md_sections() 32 | run(ctx, f'{python_dir() / "mkdocs"} build --site-dir {get_out_dir()}') 33 | 34 | 35 | def _is_mkdocs_local() -> bool: 36 | """Check if mkdocs is configured for local output. 37 | 38 | See notes on local-link configuration here: https://github.com/timothycrosley/portray/issues/65 39 | 40 | Additional information on using local search here: https://github.com/wilhelmer/mkdocs-localsearch 41 | 42 | Returns: 43 | bool: True if configured for local file output rather than hosted 44 | 45 | """ 46 | mkdocs_config = read_yaml_file(get_project_path() / MKDOCS_CONFIG) 47 | return mkdocs_config.get('use_directory_urls') is False 48 | 49 | 50 | @task() 51 | def watch(ctx: Context) -> None: 52 | """Serve local documentation for local editing.""" 53 | if _is_mkdocs_local(): # pragma: no cover 54 | path_doc_index = get_out_dir() / 'index.html' 55 | open_in_browser(path_doc_index) 56 | else: # pragma: no cover 57 | webbrowser.open('http://localhost:8000') 58 | run(ctx, f'{python_dir() / "mkdocs"} serve --dirtyreload') 59 | 60 | 61 | @task() 62 | def deploy(ctx: Context) -> None: 63 | """Deploy docs to the Github `gh-pages` branch.""" 64 | if _is_mkdocs_local(): # pragma: no cover 65 | raise NotImplementedError('Not yet configured to deploy documentation without "use_directory_urls"') 66 | 67 | with suppress(UnexpectedExit): 68 | run(ctx, 'prek uninstall') # To prevent prek failures when mkdocs calls push 69 | run(ctx, f'{python_dir() / "mkdocs"} gh-deploy --force') 70 | with suppress(UnexpectedExit): 71 | run(ctx, 'prek install') # Restore prek 72 | -------------------------------------------------------------------------------- /src/calcipy/tasks/lint.py: -------------------------------------------------------------------------------- 1 | """Lint CLI.""" 2 | 3 | from contextlib import suppress 4 | 5 | from beartype.typing import Optional 6 | from invoke.context import Context 7 | 8 | from calcipy._corallium.file_helpers import read_package_name 9 | from calcipy.cli import task 10 | from calcipy.invoke_helpers import run 11 | 12 | from .executable_utils import PRE_COMMIT_MESSAGE, check_installed, python_dir, python_m 13 | 14 | # ============================================================================== 15 | # Linting 16 | 17 | 18 | def _inner_task( 19 | ctx: Context, 20 | *, 21 | command: str, 22 | cli_args: str = '', 23 | run_as_module: bool = True, 24 | target: Optional[str] = None, 25 | ) -> None: 26 | """Shared task logic.""" 27 | file_args = [] 28 | with suppress(AttributeError): 29 | file_args = ctx.config.gto.file_args 30 | if file_args: 31 | target = ' '.join([f'"{_a}"' for _a in file_args]) 32 | elif target is None: 33 | # TODO: Performantly support either ./src/<>/ and ./<>/ 34 | target = f'"./src/{read_package_name()}" ./tests' 35 | 36 | cmd = f'{python_m()} {command}' if run_as_module else f'{python_dir() / command}' 37 | run(ctx, f'{cmd} {target} {cli_args}'.strip()) 38 | 39 | 40 | @task(default=True) 41 | def check(ctx: Context) -> None: 42 | """Run ruff as check-only.""" 43 | _inner_task(ctx, command='ruff check') 44 | 45 | 46 | @task( 47 | help={ 48 | 'unsafe': 'if provided, attempt even fixes considered unsafe', 49 | }, 50 | ) 51 | def fix(ctx: Context, *, unsafe: bool = False) -> None: 52 | """Run ruff and apply fixes.""" 53 | cli_args = '--fix' 54 | if unsafe: 55 | cli_args += ' --unsafe-fixes' 56 | _inner_task(ctx, command='ruff check', cli_args=cli_args) 57 | 58 | 59 | @task() 60 | def watch(ctx: Context) -> None: 61 | """Run ruff as check-only.""" 62 | _inner_task(ctx, command='ruff check', cli_args='--watch') 63 | 64 | 65 | # ============================================================================== 66 | # prek 67 | 68 | ALL_PRE_COMMIT_HOOK_STAGES = [ 69 | 'commit', 70 | 'merge-commit', 71 | 'push', 72 | 'prepare-commit-msg', 73 | 'commit-msg', 74 | 'post-checkout', 75 | 'post-commit', 76 | 'post-merge', 77 | 'post-rewrite', 78 | 'manual', 79 | ] 80 | 81 | 82 | @task( 83 | help={ 84 | 'no_update': 'Skip updating the prek hooks', 85 | }, 86 | ) 87 | def pre_commit(ctx: Context, *, no_update: bool = False) -> None: 88 | """Run prek.""" 89 | check_installed(ctx, executable='prek', message=PRE_COMMIT_MESSAGE) 90 | 91 | run(ctx, 'prek install') 92 | if not no_update: 93 | run(ctx, 'prek autoupdate') 94 | 95 | stages_cli = ' '.join(f'--hook-stage {stg}' for stg in ALL_PRE_COMMIT_HOOK_STAGES) 96 | run(ctx, f'prek run --all-files {stages_cli}') 97 | -------------------------------------------------------------------------------- /.github/ABOUT.md: -------------------------------------------------------------------------------- 1 | # `.github` README 2 | 3 | ## Links 4 | 5 | The GitHub Workflows and Action were influenced by these excellent examples: 6 | 7 | - [uv Github Integration](https://docs.astral.sh/uv/guides/integration/github) 8 | - [Re-engineering the SciPy Pipelines](https://labs.quansight.org/blog/2021/10/re-engineering-cicd-pipelines-for-scipy/) and [Example](https://github.com/scipy/scipy/blob/c4829bddb859ffe5716a88f6abd5e0d2dc1d9045/.github/workflows/linux_meson.yml) 9 | - SciPy also has good examples of building Docker image with layer caching, [docker.yml](https://github.com/scipy/scipy/blob/c4829bddb859ffe5716a88f6abd5e0d2dc1d9045/.github/workflows/docker.yml) and [gitpod.yml](https://github.com/scipy/scipy/blob/c4829bddb859ffe5716a88f6abd5e0d2dc1d9045/.github/workflows/gitpod.yml) 10 | - [PostHog Guide on GHA](https://posthog.com/blog/automating-a-software-company-with-github-actions). Includes information on Cypress, working with Amazon ECS, version bumping, etc. 11 | - ["Awesome" GHA](https://github.com/sdras/awesome-actions) 12 | - ["services" Can create PG or other services in workflows!](https://github.com/Nike-Inc/knockoff-factory/blob/1567a46e5eaa3fe1bdf989ef5253f9ee0dbd69b3/.github/workflows/python-test.yaml) 13 | - ["artifact" optionally upload the report.zip from successful builds](https://github.com/marketplace/actions/upload-a-build-artifact) 14 | - [General Tips](https://www.datree.io/resources/github-actions-best-practices). Keep workflows short and fast (view [usage here](https://github.com/settings/billing)), cache, [user GitHub's secret management](https://help.github.com/en/actions/automating-your-workflow-with-github-actions/creating-and-using-encrypted-secrets), and environment variables can be [scoped to an individual step](https://docs.github.com/en/actions/learn-github-actions/environment-variables) 15 | - There are many ways to run a workflow beyond only commit events, such as [workflow_dispatch, schedule, release, comment, review, etc.](https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows). Once `workflow_dispatch` is set, [workflows can be run from the CLI](https://docs.github.com/en/actions/managing-workflow-runs/manually-running-a-workflow) 16 | - [Inspiration for caching](https://github.com/MrThearMan/savestate/blob/fb299e220ef366727857b1df0631300a027840fc/.github/workflows/main.yml) 17 | - [mdformat pipeline](https://github.com/executablebooks/mdformat/blob/4752321bb444b51f120d8a6933583129a6ecaabb/.github/workflows/tests.yaml) 18 | - [decopatch has a cool use of dynamic matrices from nox](https://github.com/smarie/python-decopatch/blob/e7f5e7e3794a81af9254b2d30d1f43b7a9874399/.github/workflows/base.yml#L30-L44) 19 | 20 | ## CLI Notes 21 | 22 | ```bash 23 | # Inspect a workflow interactively 24 | gh workflow view 25 | # See recent history 26 | gh run list --workflow update_docs.yml 27 | gh run list --workflow ci_pipeline.yml 28 | # Additional arguments for triggering workflows: https://cli.github.com/manual/gh_workflow_run 29 | gh workflow run .yml --ref --field = 30 | ``` 31 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | # Common commands: 3 | # prek install -f 4 | # prek autoupdate 5 | # prek run --all-files mdformat 6 | # prek run --all-files --hook-stage commit 7 | # prek run --all-files --hook-stage push 8 | # prek run --last-commit 9 | # uv run calcipy lint.prek --no-update 10 | # 11 | # See https://pre-commit.com for more information 12 | # and https://pre-commit.com/hooks.html for more hooks 13 | # And: https://prek.j178.dev/builtin 14 | 15 | # Only commit is installed by default: https://pre-commit.com/#pre-commit-install 16 | # Pending rename of pre-push from: https://github.com/pre-commit/pre-commit/issues/2732 17 | default_install_hook_types: ["commit-msg", "pre-commit", "pre-push"] 18 | 19 | repos: 20 | - repo: https://github.com/pre-commit/pre-commit-hooks 21 | rev: v5.0.0 22 | hooks: 23 | - id: check-added-large-files 24 | - id: check-executables-have-shebangs 25 | - id: check-json 26 | - id: check-merge-conflict 27 | - id: check-symlinks 28 | - id: check-toml 29 | - id: check-vcs-permalinks 30 | - id: check-yaml 31 | args: [--unsafe] 32 | - id: debug-statements 33 | - id: destroyed-symlinks 34 | - id: detect-private-key 35 | - id: double-quote-string-fixer 36 | - id: end-of-file-fixer 37 | exclude: \.copier-answers\.yml|__snapshots__/.*\.ambr 38 | - id: fix-byte-order-marker 39 | - id: forbid-new-submodules 40 | - id: mixed-line-ending 41 | args: [--fix=auto] 42 | - id: trailing-whitespace 43 | exclude: __snapshots__/.*\.ambr 44 | - repo: https://github.com/commitizen-tools/commitizen 45 | rev: v4.1.0 46 | hooks: 47 | - id: commitizen 48 | - repo: https://github.com/executablebooks/mdformat 49 | rev: 0.7.19 50 | hooks: 51 | - id: mdformat 52 | additional_dependencies: 53 | - "mdformat-mkdocs[recommended]>=3.0.0" 54 | - "mdformat-gfm-alerts>=1.0.1" 55 | args: [--wrap=no] 56 | exclude: _.+\.md|CHANGELOG\.md|CODE_TAG_SUMMARY\.md 57 | stages: ["pre-commit"] 58 | - repo: https://github.com/shellcheck-py/shellcheck-py 59 | rev: v0.10.0.1 60 | hooks: 61 | - id: shellcheck 62 | args: [--severity=warning] 63 | stages: ["pre-commit"] 64 | - repo: https://github.com/pre-commit/mirrors-prettier 65 | rev: "v4.0.0-alpha.8" 66 | hooks: 67 | - id: prettier 68 | additional_dependencies: 69 | # Note: this version must be the same as the hook revision 70 | - "prettier@4.0.0-alpha.8" 71 | - "prettier-plugin-sh" 72 | exclude: \.copier-answers\.yml|tests/.*/cassettes/.*\.yaml|__snapshots__/.*\.json 73 | types_or: [html, javascript, json, shell, yaml] 74 | stages: ["pre-commit"] 75 | - repo: https://github.com/pappasam/toml-sort 76 | rev: v0.24.2 77 | hooks: 78 | - id: toml-sort-fix 79 | exclude: uv\.lock 80 | stages: ["pre-commit"] 81 | # - repo: https://github.com/KyleKing/calcipy 82 | # rev: 5.0.0 83 | # hooks: 84 | # - id: copier-forbidden-files 85 | # - id: lint-fix 86 | # stages: ["pre-commit"] 87 | # - id: tags 88 | # stages: ["pre-push"] 89 | -------------------------------------------------------------------------------- /src/calcipy/tasks/all_tasks.py: -------------------------------------------------------------------------------- 1 | """Tasks can be imported piecemeal or imported in their entirety from here.""" 2 | 3 | from beartype.typing import Any, List, Union 4 | from corallium.log import LOGGER 5 | from invoke.context import Context 6 | from invoke.tasks import Call 7 | 8 | from calcipy.cli import task 9 | from calcipy.collection import DeferredTask, _build_task 10 | 11 | from . import cl, doc, lint, nox, pack, tags, test, types 12 | from .most_tasks import ns 13 | 14 | 15 | @task( 16 | help={ 17 | 'message': 'String message to display', 18 | }, 19 | show_task_info=False, 20 | ) 21 | def summary(_ctx: Context, *, message: str) -> None: 22 | """Summary Task.""" 23 | LOGGER.text(message, is_header=True) 24 | 25 | 26 | @task( 27 | help={ 28 | 'index': 'Current index (0-indexed)', 29 | 'total': 'Total steps', 30 | }, 31 | show_task_info=False, 32 | ) 33 | def progress(_ctx: Context, *, index: int, total: int) -> None: 34 | """Progress Task.""" 35 | LOGGER.text('Progress', is_header=True, index=index + 1, total=total) 36 | 37 | 38 | TaskList = List[Union[Call, DeferredTask]] 39 | """List of wrapped or normal task functions.""" 40 | 41 | 42 | def with_progress(items: Any, offset: int = 0) -> TaskList: 43 | """Inject intermediary 'progress' tasks. 44 | 45 | Args: 46 | items: list of tasks 47 | offset: Optional offset to shift counters 48 | 49 | """ 50 | task_items = [_build_task(_t) for _t in items] 51 | message = 'Running tasks: ' + ', '.join([str(_t.__name__) for _t in task_items]) 52 | tasks: TaskList = [summary.with_kwargs(message=message)] # pyright: ignore[reportFunctionMemberAccess] 53 | 54 | total = len(task_items) + offset 55 | for idx, item in enumerate(task_items): 56 | tasks += [ 57 | progress.with_kwargs(index=idx + offset, total=total), # pyright: ignore[reportFunctionMemberAccess] 58 | item, 59 | ] 60 | return tasks 61 | 62 | 63 | _MAIN_TASKS = [ 64 | lint.fix, 65 | types.mypy, 66 | types.pyright, 67 | test.coverage, 68 | cl.write, 69 | doc.build, 70 | ] 71 | _OTHER_TASKS = [ 72 | lint.pre_commit.with_kwargs(no_update=True), # pyright: ignore[reportFunctionMemberAccess] 73 | nox.noxfile.with_kwargs(session='tests'), # pyright: ignore[reportFunctionMemberAccess] 74 | pack.lock, 75 | tags.collect_code_tags, 76 | test.check, # Expected to fail for calcipy 77 | ] 78 | 79 | 80 | @task(post=with_progress(_MAIN_TASKS)) 81 | def main(_ctx: Context) -> None: 82 | """Run main task pipeline.""" 83 | 84 | 85 | @task(post=with_progress(_OTHER_TASKS)) 86 | def other(_ctx: Context) -> None: 87 | """Run tasks that are otherwise not exercised in main.""" 88 | 89 | 90 | @task( 91 | help=cl.bump.help, # pyright: ignore[reportFunctionMemberAccess] 92 | post=with_progress( 93 | [ 94 | pack.lock, 95 | doc.build, 96 | doc.deploy, 97 | pack.publish, 98 | ], 99 | offset=1, 100 | ), 101 | ) 102 | def release(ctx: Context, *, suffix: cl.SuffixT = None) -> None: 103 | """Run release pipeline.""" 104 | cl.bumpz(ctx, suffix=suffix) 105 | 106 | 107 | ns.add_task(main) 108 | ns.add_task(other) 109 | ns.add_task(release) 110 | 111 | __all__ = ('ns',) 112 | -------------------------------------------------------------------------------- /tests/code_tag_collector/test_collector.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | import pytest 4 | 5 | from calcipy.code_tag_collector import write_code_tag_file 6 | from calcipy.code_tag_collector._collector import ( 7 | CODE_TAG_RE, 8 | _CodeTag, 9 | _format_report, 10 | _search_lines, 11 | _Tags, 12 | github_blame_url, 13 | ) 14 | from tests.configuration import TEST_DATA_DIR 15 | 16 | TEST_PROJECT = TEST_DATA_DIR / 'test_project' 17 | 18 | 19 | @pytest.mark.parametrize( 20 | ('clone_uri', 'expected'), 21 | [ 22 | ('https://github.com/KyleKing/calcipy.git', 'https://github.com/KyleKing/calcipy'), 23 | ('git@github.com:KyleKing/calcipy.git', 'https://github.com/KyleKing/calcipy'), 24 | ('unknown/repo.svn', ''), 25 | ('', ''), 26 | ], 27 | ) 28 | def test_github_blame_url(clone_uri: str, expected: str): 29 | assert github_blame_url(clone_uri) == expected 30 | 31 | 32 | def test__search_lines(snapshot): 33 | lines = [ 34 | '# DEBUG: Show dodo.py in the documentation', 35 | 'print("FIXME: Show README.md in the documentation (may need to update paths?)")', 36 | '# FYI: Replace src_examples_dir and make more generic to specify code to include in documentation', 37 | '# HACK: Show table of contents in __init__.py file', 38 | '# NOTE: Show table of contents in __init__.py file', 39 | '# PLANNED: Show table of contents in __init__.py file', 40 | '# REVIEW: Show table of contents in __init__.py file', 41 | '# TBD: Show table of contents in __init__.py file', 42 | '# TODO: Show table of contents in __init__.py file', 43 | '# HACK - Support unconventional dashed code tags', 44 | 'class Code: # TODO: Complete', 45 | ' //TODO: Not matched', 46 | ' ... # Both FIXME: and FYI: in the same line, but only match the first', 47 | '# FIXME: ' + 'For a long line is ignored ...' * 14, 48 | ] 49 | tag_order = ['FIXME', 'FYI', 'HACK', 'REVIEW'] 50 | matcher = CODE_TAG_RE.format(tag='|'.join(tag_order)) 51 | 52 | comments = _search_lines(lines, re.compile(matcher)) 53 | 54 | assert comments == snapshot 55 | 56 | 57 | def test__format_report(fake_process, snapshot): 58 | fake_process.pass_command([fake_process.any()]) # Allow "git blame" and other commands to run unregistered 59 | fake_process.keep_last_process(keep=True) 60 | lines = ['# DEBUG: Example 1', '# TODO: Example 2'] 61 | comments = [ 62 | _CodeTag(lineno=lineno, **dict(zip(('tag', 'text'), line.split('# ')[1].split(': ')))) 63 | for lineno, line in enumerate(lines) 64 | ] 65 | tagged_collection = [_Tags(path_source=TEST_DATA_DIR / 'test_project', code_tags=comments)] 66 | tag_order = ['TODO'] 67 | 68 | output = _format_report(TEST_DATA_DIR.parent, tagged_collection, tag_order=tag_order) 69 | 70 | assert output == snapshot 71 | 72 | 73 | def test_write_code_tag_file_when_no_matches(fix_test_cache): 74 | path_tag_summary = fix_test_cache / 'code_tags.md' 75 | path_tag_summary.write_text('Should be removed.') 76 | tmp_code_file = fix_test_cache / 'tmp.code' 77 | tmp_code_file.write_text('No FIXMES or TODOS here') 78 | 79 | write_code_tag_file( 80 | path_tag_summary=path_tag_summary, paths_source=[tmp_code_file], base_dir=fix_test_cache, 81 | ) 82 | 83 | assert not path_tag_summary.is_file() 84 | 85 | 86 | # calcipy_skip_tags 87 | -------------------------------------------------------------------------------- /src/calcipy/noxfile/_noxfile.py: -------------------------------------------------------------------------------- 1 | # ruff: noqa: ERA001 2 | """nox-uv configuration file. 3 | 4 | [Useful snippets from docs](https://nox.thea.codes/en/stable/usage.html) 5 | 6 | ```sh 7 | source .venv/bin/activate 8 | 9 | nox -l 10 | nox --list-sessions 11 | 12 | nox -s build_check-3.8 build_dist-3.8 tests-3.8 13 | nox --session tests-3.11 14 | 15 | nox --python 3.8 16 | 17 | nox -k "not build_check and not build_dist" 18 | ``` 19 | 20 | Useful nox snippets 21 | 22 | ```python3 23 | # Example conditionally skipping a session 24 | if not session.interactive: 25 | session.skip('Cannot run detect-secrets audit in non-interactive shell') 26 | 27 | # Install pinned version 28 | session.install('detect-secrets==1.0.3') 29 | 30 | # Example capturing STDOUT into a file (could do the same for stderr) 31 | path_stdout = Path('.stdout.txt').resolve() 32 | with open(path_stdout, 'w') as out: 33 | session.run(*shlex.split('echo Hello World!'), stdout=out) 34 | ``` 35 | 36 | """ 37 | 38 | import shlex 39 | from functools import lru_cache 40 | 41 | from beartype.typing import Any, Dict, List, Union 42 | from nox import Session as NoxSession 43 | from nox import session as nox_session 44 | 45 | from calcipy._corallium.file_helpers import get_tool_versions, read_package_name, read_pyproject 46 | 47 | 48 | @lru_cache(maxsize=1) 49 | def _get_pythons() -> List[str]: 50 | """Return python versions from supported configuration files.""" 51 | return [*{str(ver) for ver in get_tool_versions()['python']}] 52 | 53 | 54 | def _installable_ci_dependencies(pyproject_data: Union[Dict[str, Any], None] = None) -> List[str]: 55 | """List of CI dependencies from pyproject.toml dependency-groups. 56 | 57 | Args: 58 | pyproject_data: Optional pyproject data for testing 59 | 60 | Returns: 61 | List[str]: `['hypothesis[cli] >=6.112.4', 'pytest-asyncio >=0.24.0']` 62 | 63 | """ 64 | pyproject = read_pyproject() if pyproject_data is None else pyproject_data 65 | return pyproject.get('dependency-groups', {}).get('ci', []) 66 | 67 | 68 | def _install_local(session: NoxSession) -> None: # pragma: no cover 69 | """Ensure local CI-dependencies and calcipy extras are installed. 70 | 71 | Previously required to support poetry, but not re-tested with uv yet. 72 | See: https://github.com/cjolowicz/nox-poetry/issues/230#issuecomment-855445920 73 | 74 | """ 75 | if read_package_name() == 'calcipy': 76 | session.run_install( 77 | 'uv', 78 | 'sync', 79 | '--all-extras', 80 | env={'UV_PROJECT_ENVIRONMENT': session.virtualenv.location}, 81 | ) 82 | else: 83 | extras = ['test'] 84 | session.run_install( 85 | 'uv', 86 | 'sync', 87 | *(f'--extra={extra}' for extra in extras), 88 | env={'UV_PROJECT_ENVIRONMENT': session.virtualenv.location}, 89 | ) 90 | 91 | if dev_deps := _installable_ci_dependencies(): 92 | session.install(*dev_deps) 93 | 94 | 95 | @nox_session(venv_backend='uv', python=_get_pythons(), reuse_venv=True) 96 | def tests(session: NoxSession) -> None: # pragma: no cover 97 | """Run doit test task for specified python versions.""" 98 | _install_local(session) 99 | session.run(*shlex.split('pytest ./tests'), stdout=True, env={'RUNTIME_TYPE_CHECKING_MODE': 'WARNING'}) 100 | -------------------------------------------------------------------------------- /src/calcipy/file_search.py: -------------------------------------------------------------------------------- 1 | """Find Files.""" 2 | 3 | from collections import defaultdict 4 | from pathlib import Path 5 | 6 | from beartype.typing import Dict, List, Optional 7 | from corallium.log import LOGGER 8 | from corallium.shell import capture_shell 9 | 10 | 11 | def _zsplit(stdout: str) -> List[str]: 12 | """Split output from git when used with `-z`.""" 13 | return [item for item in stdout.split('\0') if item] 14 | 15 | 16 | def _get_all_files(*, cwd: Path) -> List[str]: 17 | """Get all files using git. Modified `pre_commit.git.get_all_files` to accept `cwd`. 18 | 19 | https://github.com/pre-commit/pre-commit/blob/488b1999f36cac62b6b0d9bc8eae99418ae5c226/pre_commit/git.py#L153 20 | 21 | Args: 22 | cwd: current working directory to pass to `subprocess.Popen` 23 | 24 | Returns: 25 | List[str]: list of all file paths relative to the `cwd` 26 | 27 | """ 28 | return _zsplit(capture_shell('git ls-files -z', cwd=cwd)) 29 | 30 | 31 | def _filter_files(rel_filepaths: List[str], ignore_patterns: List[str]) -> List[str]: 32 | """Filter a list of string file paths with specified ignore patterns in glob syntax. 33 | 34 | Args: 35 | rel_filepaths: list of string file paths 36 | ignore_patterns: glob ignore patterns 37 | 38 | Returns: 39 | List[str]: list of all non-ignored file path names 40 | 41 | """ 42 | if ignore_patterns: 43 | matches = [] 44 | for _fp in rel_filepaths: 45 | pth = Path(_fp).resolve() 46 | if not any(pth.match(pat) for pat in ignore_patterns): 47 | matches.append(_fp) 48 | return matches 49 | return rel_filepaths 50 | 51 | 52 | def find_project_files(path_project: Path, ignore_patterns: List[str]) -> List[Path]: 53 | """Find project files in git version control. 54 | 55 | > Note: uses the relative project directory and verifies that each file exists 56 | 57 | Args: 58 | path_project: Path to the project directory 59 | ignore_patterns: glob ignore patterns 60 | 61 | Returns: 62 | Dict[str, List[Path]]: where keys are the suffix (without leading dot) and values the list of paths 63 | 64 | """ 65 | file_paths = [] 66 | rel_filepaths = _get_all_files(cwd=path_project) 67 | filtered_rel_files = _filter_files(rel_filepaths=rel_filepaths, ignore_patterns=ignore_patterns) 68 | for rel_file in filtered_rel_files: 69 | path_file = path_project / rel_file 70 | if path_file.is_file(): 71 | file_paths.append(path_file) 72 | else: # pragma: no cover 73 | LOGGER.warning('Could not find the specified file', path_file=path_file) 74 | return file_paths 75 | 76 | 77 | def find_project_files_by_suffix( 78 | path_project: Path, 79 | *, 80 | ignore_patterns: Optional[List[str]] = None, 81 | ) -> Dict[str, List[Path]]: 82 | """Find project files in git version control. 83 | 84 | > Note: uses the relative project directory and verifies that each file exists 85 | 86 | Args: 87 | path_project: Path to the project directory 88 | ignore_patterns: glob ignore patterns 89 | 90 | Returns: 91 | Dict[str, List[Path]]: where keys are the suffix (without leading dot) and values the list of paths 92 | 93 | """ 94 | file_lookup: Dict[str, List[Path]] = defaultdict(list) 95 | for path_file in find_project_files(path_project, ignore_patterns or []): 96 | file_lookup[path_file.suffix.lstrip('.')].append(path_file) 97 | return dict(file_lookup) 98 | -------------------------------------------------------------------------------- /src/calcipy/_corallium/file_helpers.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from contextlib import suppress 4 | from functools import lru_cache 5 | from pathlib import Path 6 | 7 | from corallium.file_helpers import find_in_parents, read_pyproject 8 | from corallium.tomllib import tomllib 9 | 10 | __all__ = ['get_tool_versions', 'read_package_name', 'read_pyproject'] 11 | 12 | 13 | def _parse_mise_lock(lock_path: Path) -> dict[str, list[str]]: 14 | """Parse mise.lock file and extract locked tool versions. 15 | 16 | The mise.lock file contains resolved versions for tools, including 17 | 'latest' versions that have been pinned to specific releases. 18 | 19 | """ 20 | content = lock_path.read_bytes() 21 | data = tomllib.loads(content.decode('utf-8')) 22 | 23 | versions: dict[str, list[str]] = {} 24 | 25 | # Parse [tools] section from lockfile 26 | if 'tools' in data: 27 | for tool, tool_data in data['tools'].items(): 28 | if isinstance(tool_data, dict) and 'version' in tool_data: 29 | version = tool_data['version'] 30 | if version: 31 | versions.setdefault(tool, []).append(version) 32 | 33 | return versions 34 | 35 | 36 | def _parse_mise_toml(mise_path: Path) -> dict[str, list[str]]: 37 | """Parse mise.toml file and extract tool versions from [tools] section. 38 | 39 | Supports two format variations: 40 | - Single version string: python = "3.11" 41 | - Multiple versions array: python = ["3.10", "3.11"] 42 | 43 | """ 44 | content = mise_path.read_bytes() 45 | data = tomllib.loads(content.decode('utf-8')) 46 | 47 | versions: dict[str, list[str]] = {} 48 | 49 | # Parse [tools] section only 50 | if 'tools' in data: 51 | for tool, version in data['tools'].items(): 52 | if isinstance(version, str): 53 | versions.setdefault(tool, []).append(version) 54 | elif isinstance(version, list): 55 | versions.setdefault(tool, []).extend(version) 56 | 57 | return versions 58 | 59 | 60 | # FIXME: port back to corallium (temporarily extended to support uv and mise) 61 | def get_tool_versions(cwd: Path | None = None) -> dict[str, list[str]]: 62 | """Return versions from `mise.lock`, `mise.toml`, or `.tool-versions` file. 63 | 64 | Priority order: 65 | 1. mise.lock (contains resolved versions, including 'latest') 66 | 2. mise.toml (contains specified versions) 67 | 3. .tool-versions (legacy asdf format) 68 | 69 | """ 70 | # Try mise.lock first (highest priority - contains resolved versions) 71 | with suppress(FileNotFoundError): 72 | lock_path = find_in_parents(name='mise.lock', cwd=cwd) 73 | return _parse_mise_lock(lock_path) 74 | 75 | # Try mise.toml second 76 | with suppress(FileNotFoundError): 77 | mise_path = find_in_parents(name='mise.toml', cwd=cwd) 78 | return _parse_mise_toml(mise_path) 79 | 80 | # Fall back to .tool-versions (lowest priority) 81 | tv_path = find_in_parents(name='.tool-versions', cwd=cwd) 82 | return {line.split(' ')[0]: line.split(' ')[1:] for line in tv_path.read_text().splitlines()} 83 | 84 | 85 | # FIXME: port back to corallium (temporarily extended to support uv) 86 | @lru_cache(maxsize=5) 87 | def read_package_name(cwd: Path | None = None) -> str: 88 | """Return the package name.""" 89 | pyproject = read_pyproject(cwd=cwd) 90 | with suppress(KeyError): 91 | return str(pyproject['project']['name']) # For uv 92 | return str(pyproject['tool']['poetry']['name']) 93 | -------------------------------------------------------------------------------- /tests/md_writer/test_writer.py: -------------------------------------------------------------------------------- 1 | import json 2 | import shutil 3 | from functools import partial 4 | from pathlib import Path 5 | 6 | import pytest 7 | from beartype.typing import List 8 | 9 | from calcipy.md_writer._writer import ( 10 | _format_cov_table, 11 | _handle_coverage, 12 | _handle_source_file, 13 | _parse_var_comment, 14 | write_template_formatted_md_sections, 15 | ) 16 | from tests.configuration import TEST_DATA_DIR 17 | 18 | SAMPLE_README_PATH = TEST_DATA_DIR / 'sample_doc_files' / 'README.md' 19 | 20 | _COVERAGE_SAMPLE_DATA = { 21 | 'meta': {'timestamp': '2021-06-03T19:37:11.980123'}, 22 | 'files': { 23 | 'calcipy/doit_tasks/base.py': { 24 | 'summary': { 25 | 'covered_lines': 20, 26 | 'num_statements': 22, 27 | 'percent_covered': 90.9090909090909, 28 | 'missing_lines': 2, 29 | 'excluded_lines': 3, 30 | }, 31 | }, 32 | 'calcipy/doit_tasks/code_tags.py': { 33 | 'summary': { 34 | 'covered_lines': 31, 35 | 'num_statements': 75, 36 | 'percent_covered': 41.333333333333336, 37 | 'missing_lines': 44, 38 | 'excluded_lines': 0, 39 | }, 40 | }, 41 | }, 42 | 'totals': { 43 | 'covered_lines': 51, 44 | 'num_statements': 97, 45 | 'percent_covered': 52.57732, 46 | 'missing_lines': 46, 47 | 'excluded_lines': 3, 48 | }, 49 | } 50 | """Sample coverage data generated with `python -m coverage json`.""" 51 | 52 | 53 | def test_format_cov_table(snapshot): 54 | result = _format_cov_table(_COVERAGE_SAMPLE_DATA) 55 | 56 | assert '\n'.join(result) == snapshot 57 | 58 | 59 | def test_write_template_formatted_md_sections(fix_test_cache, snapshot): 60 | path_new_readme = fix_test_cache / SAMPLE_README_PATH.name 61 | shutil.copyfile(SAMPLE_README_PATH, path_new_readme) 62 | path_cover = fix_test_cache / 'coverage.json' 63 | path_cover.write_text(json.dumps(_COVERAGE_SAMPLE_DATA)) 64 | placeholder = '\n' 65 | was_placeholder = placeholder in path_new_readme.read_text() 66 | 67 | write_template_formatted_md_sections( 68 | handler_lookup={ 69 | 'SOURCE_FILE_TEST': _handle_source_file, 70 | 'COVERAGE_TEST': partial(_handle_coverage, path_coverage=path_cover), 71 | }, 72 | paths_md=[path_new_readme], 73 | ) 74 | 75 | text = path_new_readme.read_text() 76 | assert was_placeholder 77 | assert placeholder not in text 78 | assert text == snapshot 79 | 80 | 81 | @pytest.mark.parametrize( 82 | ('line', 'match'), 83 | [ 84 | ('', {'rating': '1'}), 85 | ('', {'path_image': 'imgs/image_filename.png'}), 86 | ('', {'tricky_var_3': '-11e-21'}), 87 | ], 88 | ) 89 | def test_parse_var_comment(line, match): 90 | result = _parse_var_comment(line) 91 | 92 | assert result == match 93 | 94 | 95 | def _star_parser(line: str, path_md: Path) -> List[str]: 96 | rating = int(_parse_var_comment(line)['rating']) 97 | return [f'RATING={rating}'] 98 | 99 | 100 | def test_write_template_formatted_md_sections_custom(fix_test_cache): 101 | path_new_readme = fix_test_cache / SAMPLE_README_PATH.name 102 | shutil.copyfile(SAMPLE_README_PATH, path_new_readme) 103 | 104 | write_template_formatted_md_sections(handler_lookup={'rating': _star_parser}, paths_md=[path_new_readme]) 105 | 106 | text = path_new_readme.read_text() 107 | assert '\n 112 | 113 | """ 114 | in text 115 | ) 116 | -------------------------------------------------------------------------------- /src/calcipy/tasks/test.py: -------------------------------------------------------------------------------- 1 | """Test CLI.""" 2 | 3 | from pathlib import Path 4 | 5 | from beartype.typing import Optional 6 | from corallium.file_helpers import open_in_browser 7 | from invoke.context import Context 8 | 9 | from calcipy._corallium.file_helpers import read_package_name 10 | from calcipy.cli import task 11 | from calcipy.experiments import check_duplicate_test_names 12 | from calcipy.invoke_helpers import run 13 | 14 | from .defaults import from_ctx 15 | from .executable_utils import python_dir 16 | 17 | 18 | def _inner_task( 19 | ctx: Context, 20 | *, 21 | command: str = 'pytest', 22 | cli_args: str = '', 23 | keyword: str = '', 24 | marker: str = '', 25 | min_cover: int = 0, 26 | ) -> None: 27 | """Shared task logic.""" 28 | if keyword: 29 | cli_args += f' -k "{keyword}"' 30 | if marker: 31 | cli_args += f' -m "{marker}"' 32 | if fail_under := min_cover or int(from_ctx(ctx, 'test', 'min_cover')): 33 | cli_args += f' --cov-fail-under={fail_under}' 34 | run(ctx, f'{python_dir() / command} ./tests{cli_args}') 35 | 36 | 37 | @task() 38 | def check(_ctx: Context) -> None: 39 | """Run pytest checks, such as identifying. 40 | 41 | Raises: 42 | RuntimeError: if duplicate tests 43 | 44 | """ 45 | if duplciates := check_duplicate_test_names.run(Path('tests')): 46 | raise RuntimeError(f'Duplicate test names found ({duplciates}). See above for details.') # noqa: EM102 47 | 48 | 49 | KM_HELP = { 50 | # See: https://docs.pytest.org/en/latest/usage.html#specifying-tests-selecting-tests 51 | 'keyword': 'Only run tests that match the string pattern', 52 | 'marker': 'Only run tests matching given mark expression', 53 | } 54 | 55 | 56 | @task( 57 | default=True, 58 | help={ 59 | 'min_cover': 'Fail if coverage less than threshold', 60 | **KM_HELP, 61 | }, 62 | ) 63 | def pytest(ctx: Context, *, keyword: str = '', marker: str = '', min_cover: int = 0) -> None: 64 | """Run pytest with default arguments. 65 | 66 | Additional arguments can be set in the environment variable 'PYTEST_ADDOPTS' 67 | 68 | """ 69 | pkg_name = read_package_name() 70 | durations = '--durations=25 --durations-min="0.1"' 71 | _inner_task( 72 | ctx, 73 | cli_args=f' --cov={pkg_name} --cov-branch --cov-report=term-missing {durations}', 74 | keyword=keyword, 75 | marker=marker, 76 | min_cover=min_cover, 77 | ) 78 | 79 | 80 | @task(help=KM_HELP) 81 | def watch(ctx: Context, *, keyword: str = '', marker: str = '') -> None: 82 | """Run pytest with polling and optimized to stop on first error.""" 83 | _inner_task( 84 | ctx, 85 | cli_args=' --failed-first --new-first --exitfirst -vv --no-cov', 86 | keyword=keyword, 87 | marker=marker, 88 | command='ptw . --now', 89 | ) 90 | 91 | 92 | @task( 93 | help={ 94 | 'min_cover': 'Fail if coverage less than threshold', 95 | 'out_dir': 'Optional path to coverage directory. Typically ".cover" or "releases/tests"', 96 | 'view': 'If True, open the created files', 97 | }, 98 | ) 99 | def coverage(ctx: Context, *, min_cover: int = 0, out_dir: Optional[str] = None, view: bool = False) -> None: 100 | """Generate useful coverage outputs after running pytest. 101 | 102 | Creates `coverage.json` used in `doc.build` 103 | 104 | """ 105 | pkg_name = read_package_name() 106 | _inner_task( 107 | ctx, 108 | cli_args='', 109 | min_cover=min_cover, 110 | command=f'coverage run --branch --source={pkg_name} --module pytest', 111 | ) 112 | 113 | cov_dir = Path(out_dir or from_ctx(ctx, 'test', 'out_dir')) 114 | cov_dir.mkdir(exist_ok=True, parents=True) 115 | print() # noqa: T201 116 | for cli_args in ( 117 | 'report --show-missing', # Write to STDOUT 118 | f'html --directory={cov_dir}', # Write to HTML 119 | 'json', # Create coverage.json file for "_handle_coverage" 120 | ): 121 | run(ctx, f'{python_dir() / "coverage"} {cli_args}') 122 | 123 | if view: # pragma: no cover 124 | open_in_browser(cov_dir / 'index.html') 125 | -------------------------------------------------------------------------------- /src/calcipy/experiments/sync_package_dependencies.py: -------------------------------------------------------------------------------- 1 | """Experiment with setting pyproject versions to latest lock file versions. 2 | 3 | # Note: Currently only supports poetry format 4 | 5 | """ 6 | 7 | import re 8 | from pathlib import Path 9 | 10 | from corallium.log import LOGGER 11 | from corallium.tomllib import tomllib 12 | 13 | 14 | def _extract_base_version(version_spec: str) -> str: 15 | """Extract the base version from a version specification.""" 16 | # Find version numbers in the spec 17 | version_match = re.search(r'(\d+(?:\.\d+)*(?:\.\d+)*)', version_spec) 18 | return version_match.group(1) if version_match else version_spec 19 | 20 | 21 | def _collect_pyproject_versions(pyproject_text: str) -> dict[str, str]: 22 | """Return pyproject versions without version specification for possible replacement. 23 | 24 | Documentation: https://python-poetry.org/docs/dependency-specification 25 | 26 | """ 27 | pyproject = tomllib.loads(pyproject_text) 28 | 29 | pyproject_versions: dict[str, str] = {} 30 | # for section in 31 | pyproject_groups = pyproject['tool']['poetry'].get('group', {}) 32 | groups = [group.get('dependencies', []) for group in pyproject_groups.values()] 33 | for deps in [pyproject['tool']['poetry']['dependencies'], *groups]: 34 | for name, value in deps.items(): 35 | if name == 'python': 36 | continue 37 | version = value if isinstance(value, str) else value.get('version') 38 | if not version: 39 | continue 40 | pyproject_versions[name] = _extract_base_version(version) 41 | return pyproject_versions 42 | 43 | 44 | def _replace_pyproject_versions( 45 | lock_versions: dict[str, str], 46 | pyproject_versions: dict[str, str], 47 | pyproject_text: str, 48 | ) -> str: 49 | """Return pyproject text with replaced versions.""" 50 | new_lines: list[str] = [] 51 | active_section = '' 52 | for line in pyproject_text.split('\n'): 53 | if line.startswith('['): 54 | active_section = line 55 | elif '=' in line and 'dependencies' in active_section: 56 | name = line.split('=')[0].strip() 57 | if (lock_version := lock_versions.get(name)) and (pyproject_version := pyproject_versions.get(name)): 58 | versions = {'name': name, 'new_version': lock_version, 'old_version': pyproject_version} 59 | if pyproject_version != lock_version: 60 | if pyproject_version in line: 61 | new_lines.append(line.replace(pyproject_version, lock_version, 1)) 62 | LOGGER.text('Upgrade minimum package version', **versions) # type: ignore[arg-type] 63 | continue 64 | LOGGER.warning( 65 | 'Could not set new version. Please do so manually and submit a bug report', 66 | line=line, 67 | **versions, 68 | ) 69 | elif lock_version and not pyproject_versions.get(name): 70 | LOGGER.text('WARNING: consider manually updating the version', new_version=lock_version) 71 | 72 | new_lines.append(line) 73 | return '\n'.join(new_lines) 74 | 75 | 76 | def replace_versions(path_lock: Path) -> None: 77 | """Read packages from poetry.lock and update the versions in pyproject.toml. 78 | 79 | Args: 80 | path_lock: Path to the poetry.lock file 81 | 82 | Raises: 83 | NotImplementedError: if a lock file other that the poetry lock file is used 84 | 85 | """ 86 | if path_lock.name != 'poetry.lock': 87 | msg = f'Expected a path to a "poetry.lock" file. Instead, received: "{path_lock.name}"' 88 | raise NotImplementedError(msg) 89 | 90 | lock = tomllib.loads(path_lock.read_text(encoding='utf-8', errors='ignore')) 91 | lock_versions = {dependency['name']: dependency['version'] for dependency in lock['package']} 92 | 93 | path_pyproject = path_lock.parent / 'pyproject.toml' 94 | pyproject_text = path_pyproject.read_text(encoding='utf-8') 95 | pyproject_versions = _collect_pyproject_versions(pyproject_text) 96 | 97 | path_pyproject.write_text(_replace_pyproject_versions(lock_versions, pyproject_versions, pyproject_text)) 98 | -------------------------------------------------------------------------------- /src/calcipy/tasks/pack.py: -------------------------------------------------------------------------------- 1 | """Packaging CLI.""" 2 | 3 | from os import getenv 4 | from pathlib import Path 5 | 6 | import keyring 7 | from corallium import file_helpers # Required for mocking read_pyproject 8 | from corallium.file_helpers import PROJECT_TOML, delete_dir, get_lock 9 | from corallium.log import LOGGER 10 | from invoke.context import Context 11 | 12 | from calcipy import can_skip # Required for mocking can_skip.can_skip 13 | from calcipy.cli import task 14 | from calcipy.invoke_helpers import run 15 | 16 | 17 | @task() 18 | def lock(ctx: Context) -> None: 19 | """Update package manager lock file.""" 20 | if can_skip.can_skip(prerequisites=[PROJECT_TOML], targets=[get_lock()]): 21 | return # Exit early 22 | 23 | run(ctx, 'uv lock') 24 | 25 | 26 | def _configure_uv_env_credentials(*, index_name: str, interactive: bool) -> dict[str, str]: 27 | username = getenv('UV_PUBLISH_USERNAME') 28 | password = getenv('UV_PUBLISH_PASSWORD') 29 | if username and password: 30 | return { 31 | 'UV_PUBLISH_USERNAME': username, 32 | 'UV_PUBLISH_PASSWORD': password, 33 | } 34 | 35 | def _get_token() -> str: 36 | """Return token stored in keyring.""" 37 | kwargs = {'service_name': 'calcipy', 'username': f'uv-{index_name}-token'} 38 | if token := keyring.get_password(**kwargs): 39 | return token 40 | if interactive and (new_token := input('PyPi Publish Token: ')): # pragma: no cover 41 | keyring.set_password(**kwargs, password=new_token) 42 | return new_token 43 | raise RuntimeError("No Token for PyPi in 'UV_PUBLISH_TOKEN' or keyring") 44 | 45 | token = getenv('UV_PUBLISH_TOKEN') 46 | return {'UV_PUBLISH_TOKEN': token or _get_token()} 47 | 48 | 49 | @task( 50 | help={ 51 | 'to_test_pypi': 'Publish to the TestPyPi repository', 52 | 'no_interactive': 'Do not prompt for credentials when not found', 53 | }, 54 | ) 55 | def publish(ctx: Context, *, to_test_pypi: bool = False, no_interactive: bool = False) -> None: 56 | """Build the distributed format(s) and publish. 57 | 58 | Alternatively, configure Github Actions to use 'Trusted Publisher' 59 | https://docs.pypi.org/trusted-publishers/adding-a-publisher 60 | 61 | """ 62 | delete_dir(Path('dist')) 63 | run(ctx, 'uv build --no-sources') 64 | 65 | keyring.set_password('system', 'username', 'password') 66 | keyring.get_password('system', 'username') 67 | 68 | cmd = 'uv publish' 69 | index_name = 'PyPi' 70 | if to_test_pypi: 71 | cmd += ' --publish-url https://test.pypi.org/legacy/' 72 | index_name = 'Test PyPi' 73 | env = _configure_uv_env_credentials(index_name=index_name, interactive=not no_interactive) 74 | run(ctx, cmd, env=env) 75 | 76 | 77 | @task( 78 | help={ 79 | 'tag': 'Last tag, can be provided with `--tag="$(git tag -l "v*" | sort | tail -n 1)"`', 80 | 'tag_prefix': 'Optional tag prefix, such as "v"', 81 | 'pkg_name': 'Optional package name. If not provided, will read the uv pyproject.toml file', 82 | }, 83 | ) 84 | def bump_tag(ctx: Context, *, tag: str, tag_prefix: str = '', pkg_name: str = '') -> None: # noqa: ARG001 85 | """Experiment with bumping the git tag using `griffe` (experimental). 86 | 87 | Example for `calcipy`: 88 | 89 | ```sh 90 | ./run pack.bump-tag --tag="$(git tag -l "*" | sort | head -n 5 | tail -n 1)" --tag-prefix="" 91 | ``` 92 | 93 | """ 94 | if not tag: 95 | raise ValueError('tag must not be empty') 96 | if not pkg_name: 97 | pkg_name = file_helpers.read_pyproject()['project']['name'] 98 | 99 | from calcipy.experiments import bump_programmatically # noqa: PLC0415 100 | 101 | new_version = bump_programmatically.bump_tag( 102 | pkg_name=pkg_name, 103 | tag=tag, 104 | tag_prefix=tag_prefix, 105 | ) 106 | LOGGER.text(new_version) 107 | 108 | 109 | @task(post=[lock]) 110 | def sync_pyproject_versions(ctx: Context) -> None: # noqa: ARG001 111 | """Experiment with setting the pyproject.toml dependencies to the version from uv.lock (experimental). 112 | 113 | Uses the current working directory and should be run after `uv update`. 114 | 115 | """ 116 | from calcipy.experiments import sync_package_dependencies # noqa: PLC0415 117 | 118 | sync_package_dependencies.replace_versions(path_lock=get_lock()) 119 | -------------------------------------------------------------------------------- /tests/test_file_helpers.py: -------------------------------------------------------------------------------- 1 | """Tests for calcipy._corallium.file_helpers module.""" 2 | 3 | 4 | import pytest 5 | 6 | from calcipy._corallium.file_helpers import _parse_mise_lock, _parse_mise_toml, get_tool_versions 7 | 8 | 9 | def test__parse_mise_toml_with_tools_array(tmp_path): 10 | """Test parsing mise.toml with array of Python versions in [tools] section.""" 11 | mise_toml = tmp_path / 'mise.toml' 12 | mise_toml.write_text(""" 13 | [tools] 14 | python = ["3.12.5", "3.9.13"] 15 | node = ["20.0.0"] 16 | """) 17 | 18 | result = _parse_mise_toml(mise_toml) 19 | 20 | assert result == { 21 | 'python': ['3.12.5', '3.9.13'], 22 | 'node': ['20.0.0'], 23 | } 24 | 25 | 26 | def test__parse_mise_toml_with_tools_string(tmp_path): 27 | """Test parsing mise.toml with single string Python version in [tools] section.""" 28 | mise_toml = tmp_path / 'mise.toml' 29 | mise_toml.write_text(""" 30 | [tools] 31 | python = "3.11.0" 32 | """) 33 | 34 | result = _parse_mise_toml(mise_toml) 35 | 36 | assert result == {'python': ['3.11.0']} 37 | 38 | 39 | def test__parse_mise_lock_with_single_tool(tmp_path): 40 | """Test parsing mise.lock with single Python version.""" 41 | mise_lock = tmp_path / 'mise.lock' 42 | mise_lock.write_text(""" 43 | [tools.python] 44 | version = "3.12.5" 45 | backend = "core:python" 46 | """) 47 | 48 | result = _parse_mise_lock(mise_lock) 49 | 50 | assert result == {'python': ['3.12.5']} 51 | 52 | 53 | def test__parse_mise_lock_with_multiple_tools(tmp_path): 54 | """Test parsing mise.lock with multiple tools.""" 55 | mise_lock = tmp_path / 'mise.lock' 56 | mise_lock.write_text(""" 57 | [tools.python] 58 | version = "3.11.0" 59 | backend = "core:python" 60 | 61 | [tools.node] 62 | version = "20.0.0" 63 | backend = "core:node" 64 | """) 65 | 66 | result = _parse_mise_lock(mise_lock) 67 | 68 | assert result == { 69 | 'python': ['3.11.0'], 70 | 'node': ['20.0.0'], 71 | } 72 | 73 | 74 | def test__parse_mise_lock_empty(tmp_path): 75 | """Test parsing empty mise.lock file.""" 76 | mise_lock = tmp_path / 'mise.lock' 77 | mise_lock.write_text('') 78 | 79 | result = _parse_mise_lock(mise_lock) 80 | 81 | assert result == {} 82 | 83 | 84 | def test__parse_mise_toml_empty(tmp_path): 85 | """Test parsing empty mise.toml file.""" 86 | mise_toml = tmp_path / 'mise.toml' 87 | mise_toml.write_text('') 88 | 89 | result = _parse_mise_toml(mise_toml) 90 | 91 | assert result == {} 92 | 93 | 94 | def test_get_tool_versions_with_mise_toml(tmp_path, monkeypatch): 95 | """Test get_tool_versions reads mise.toml when present.""" 96 | mise_toml = tmp_path / 'mise.toml' 97 | mise_toml.write_text(""" 98 | [tools] 99 | python = ["3.12.5", "3.9.13"] 100 | """) 101 | 102 | monkeypatch.chdir(tmp_path) 103 | 104 | result = get_tool_versions(cwd=tmp_path) 105 | 106 | assert result == {'python': ['3.12.5', '3.9.13']} 107 | 108 | 109 | def test_get_tool_versions_with_tool_versions(tmp_path, monkeypatch): 110 | """Test get_tool_versions falls back to .tool-versions when mise.toml doesn't exist.""" 111 | tool_versions = tmp_path / '.tool-versions' 112 | tool_versions.write_text('python 3.11.0 3.10.0\nnode 18.0.0\n') 113 | 114 | monkeypatch.chdir(tmp_path) 115 | 116 | result = get_tool_versions(cwd=tmp_path) 117 | 118 | assert result == { 119 | 'python': ['3.11.0', '3.10.0'], 120 | 'node': ['18.0.0'], 121 | } 122 | 123 | 124 | def test_get_tool_versions_prefers_mise_lock(tmp_path, monkeypatch): 125 | """Test get_tool_versions prefers mise.lock over mise.toml and .tool-versions.""" 126 | # Create all three files 127 | mise_lock = tmp_path / 'mise.lock' 128 | mise_lock.write_text(""" 129 | [tools.python] 130 | version = "3.13.0" 131 | backend = "core:python" 132 | """) 133 | 134 | mise_toml = tmp_path / 'mise.toml' 135 | mise_toml.write_text(""" 136 | [tools] 137 | python = ["3.12.5"] 138 | """) 139 | 140 | tool_versions = tmp_path / '.tool-versions' 141 | tool_versions.write_text('python 3.11.0\n') 142 | 143 | monkeypatch.chdir(tmp_path) 144 | 145 | result = get_tool_versions(cwd=tmp_path) 146 | 147 | # Should use mise.lock (highest priority) 148 | assert result == {'python': ['3.13.0']} 149 | 150 | 151 | def test_get_tool_versions_prefers_mise_toml_over_tool_versions(tmp_path, monkeypatch): 152 | """Test get_tool_versions prefers mise.toml over .tool-versions when no mise.lock exists.""" 153 | mise_toml = tmp_path / 'mise.toml' 154 | mise_toml.write_text(""" 155 | [tools] 156 | python = ["3.12.5"] 157 | """) 158 | 159 | tool_versions = tmp_path / '.tool-versions' 160 | tool_versions.write_text('python 3.11.0\n') 161 | 162 | monkeypatch.chdir(tmp_path) 163 | 164 | result = get_tool_versions(cwd=tmp_path) 165 | 166 | # Should use mise.toml, not .tool-versions 167 | assert result == {'python': ['3.12.5']} 168 | 169 | 170 | def test_get_tool_versions_file_not_found(tmp_path, monkeypatch): 171 | """Test get_tool_versions raises error when neither file exists.""" 172 | monkeypatch.chdir(tmp_path) 173 | 174 | with pytest.raises(FileNotFoundError): 175 | get_tool_versions(cwd=tmp_path) 176 | -------------------------------------------------------------------------------- /docs/docs/DEVELOPER_GUIDE.md: -------------------------------------------------------------------------------- 1 | # Developer Notes 2 | 3 | ## Local Development 4 | 5 | ```sh 6 | git clone https://github.com/kyleking/calcipy.git 7 | cd calcipy 8 | uv sync --all-extras 9 | 10 | # See the available tasks 11 | uv run calcipy 12 | # Or use a local 'run' file (so that 'calcipy' can be extended) 13 | ./run 14 | 15 | # Run the default task list (lint, auto-format, test coverage, etc.) 16 | ./run main 17 | 18 | # Make code changes and run specific tasks as needed: 19 | ./run lint.fix test 20 | 21 | # Install globally 22 | uv tool install ".[ddict,doc,experimental,lint,nox,tags,test,types]" --force --editable 23 | ``` 24 | 25 | ### Maintenance 26 | 27 | Dependency upgrades can be accomplished with: 28 | 29 | ```sh 30 | uv lock --upgrade 31 | uv sync --all-extras 32 | ``` 33 | 34 | ## Publishing 35 | 36 | For testing, create an account on [TestPyPi](https://test.pypi.org/legacy). Either set 'UV_PUBLISH_TOKEN' or input the generated token when prompted by the command. 37 | 38 | ```sh 39 | ./run main pack.publish --to-test-pypi 40 | ``` 41 | 42 | To publish to the real PyPi 43 | 44 | ```sh 45 | ./run release 46 | 47 | # Or for a pre-release 48 | ./run release --suffix=rc 49 | ``` 50 | 51 | ## Current Status 52 | 53 | 54 | | File | Statements | Missing | Excluded | Coverage | 55 | |---------------------------------------------------------|-----------:|--------:|---------:|---------:| 56 | | `src/calcipy/__init__.py` | 4 | 0 | 0 | 100.0% | 57 | | `src/calcipy/_corallium/__init__.py` | 2 | 0 | 0 | 100.0% | 58 | | `src/calcipy/_corallium/file_helpers.py` | 44 | 0 | 0 | 95.0% | 59 | | `src/calcipy/_runtime_type_check_setup.py` | 13 | 0 | 37 | 100.0% | 60 | | `src/calcipy/can_skip.py` | 14 | 1 | 0 | 88.9% | 61 | | `src/calcipy/cli.py` | 34 | 1 | 77 | 92.1% | 62 | | `src/calcipy/code_tag_collector/__init__.py` | 5 | 2 | 0 | 60.0% | 63 | | `src/calcipy/code_tag_collector/_collector.py` | 130 | 2 | 0 | 97.0% | 64 | | `src/calcipy/collection.py` | 45 | 3 | 65 | 88.2% | 65 | | `src/calcipy/dot_dict/__init__.py` | 5 | 2 | 0 | 60.0% | 66 | | `src/calcipy/dot_dict/_dot_dict.py` | 6 | 0 | 0 | 100.0% | 67 | | `src/calcipy/experiments/__init__.py` | 0 | 0 | 0 | 100.0% | 68 | | `src/calcipy/experiments/bump_programmatically.py` | 22 | 16 | 0 | 25.0% | 69 | | `src/calcipy/experiments/check_duplicate_test_names.py` | 33 | 0 | 2 | 98.2% | 70 | | `src/calcipy/experiments/sync_package_dependencies.py` | 51 | 12 | 0 | 73.3% | 71 | | `src/calcipy/file_search.py` | 32 | 0 | 2 | 100.0% | 72 | | `src/calcipy/invoke_helpers.py` | 27 | 4 | 0 | 79.3% | 73 | | `src/calcipy/markdown_table.py` | 28 | 4 | 0 | 80.0% | 74 | | `src/calcipy/md_writer/__init__.py` | 5 | 2 | 0 | 60.0% | 75 | | `src/calcipy/md_writer/_writer.py` | 93 | 6 | 0 | 89.7% | 76 | | `src/calcipy/noxfile/__init__.py` | 5 | 2 | 0 | 60.0% | 77 | | `src/calcipy/noxfile/_noxfile.py` | 12 | 0 | 30 | 100.0% | 78 | | `src/calcipy/scripts.py` | 6 | 0 | 51 | 100.0% | 79 | | `src/calcipy/tasks/__init__.py` | 0 | 0 | 0 | 100.0% | 80 | | `src/calcipy/tasks/all_tasks.py` | 37 | 0 | 0 | 100.0% | 81 | | `src/calcipy/tasks/cl.py` | 26 | 5 | 0 | 75.0% | 82 | | `src/calcipy/tasks/defaults.py` | 17 | 0 | 0 | 94.7% | 83 | | `src/calcipy/tasks/doc.py` | 29 | 0 | 8 | 100.0% | 84 | | `src/calcipy/tasks/executable_utils.py` | 32 | 0 | 0 | 97.2% | 85 | | `src/calcipy/tasks/lint.py` | 38 | 2 | 0 | 87.0% | 86 | | `src/calcipy/tasks/most_tasks.py` | 29 | 0 | 0 | 100.0% | 87 | | `src/calcipy/tasks/nox.py` | 8 | 0 | 0 | 100.0% | 88 | | `src/calcipy/tasks/pack.py` | 53 | 10 | 3 | 76.2% | 89 | | `src/calcipy/tasks/tags.py` | 18 | 1 | 0 | 90.0% | 90 | | `src/calcipy/tasks/test.py` | 40 | 1 | 2 | 93.8% | 91 | | `src/calcipy/tasks/types.py` | 11 | 0 | 0 | 100.0% | 92 | | **Totals** | 954 | 76 | 277 | 89.0% | 93 | 94 | Generated on: 2025-11-02 95 | 96 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.toptal.com/developers/gitignore/api/python 2 | # Edit at https://www.toptal.com/developers/gitignore?templates=python 3 | 4 | ### Python ### 5 | # Byte-compiled / optimized / DLL files 6 | __pycache__/ 7 | *.py[cod] 8 | *$py.class 9 | 10 | # C extensions 11 | *.so 12 | 13 | # Distribution / packaging 14 | .Python 15 | build/ 16 | develop-eggs/ 17 | dist/ 18 | downloads/ 19 | eggs/ 20 | .eggs/ 21 | lib/ 22 | lib64/ 23 | parts/ 24 | sdist/ 25 | var/ 26 | wheels/ 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 | cover/ 57 | 58 | # Translations 59 | *.mo 60 | *.pot 61 | 62 | # Django stuff: 63 | *.log 64 | local_settings.py 65 | db.sqlite3 66 | db.sqlite3-journal 67 | 68 | # Flask stuff: 69 | instance/ 70 | .webassets-cache 71 | 72 | # Scrapy stuff: 73 | .scrapy 74 | 75 | # Sphinx documentation 76 | docs/_build/ 77 | 78 | # PyBuilder 79 | .pybuilder/ 80 | target/ 81 | 82 | # Jupyter Notebook 83 | .ipynb_checkpoints 84 | 85 | # IPython 86 | profile_default/ 87 | ipython_config.py 88 | 89 | # pyenv 90 | # For a library or package, you might want to ignore these files since the code is 91 | # intended to run in multiple environments; otherwise, check them in: 92 | # .python-version 93 | 94 | # pipenv 95 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 96 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 97 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 98 | # install all needed dependencies. 99 | #Pipfile.lock 100 | 101 | # poetry 102 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 103 | # This is especially recommended for binary packages to ensure reproducibility, and is more 104 | # commonly ignored for libraries. 105 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 106 | #poetry.lock 107 | 108 | # pdm 109 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 110 | #pdm.lock 111 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 112 | # in version control. 113 | # https://pdm.fming.dev/#use-with-ide 114 | .pdm.toml 115 | 116 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 117 | __pypackages__/ 118 | 119 | # Celery stuff 120 | celerybeat-schedule 121 | celerybeat.pid 122 | 123 | # SageMath parsed files 124 | *.sage.py 125 | 126 | # Environments 127 | .env 128 | .venv 129 | env/ 130 | venv/ 131 | ENV/ 132 | env.bak/ 133 | venv.bak/ 134 | 135 | # Spyder project settings 136 | .spyderproject 137 | .spyproject 138 | 139 | # Rope project settings 140 | .ropeproject 141 | 142 | # mkdocs documentation 143 | /site 144 | 145 | # mypy 146 | .mypy_cache/ 147 | .dmypy.json 148 | dmypy.json 149 | 150 | # Pyre type checker 151 | .pyre/ 152 | 153 | # pytype static type analyzer 154 | .pytype/ 155 | 156 | # Cython debug symbols 157 | cython_debug/ 158 | 159 | # PyCharm 160 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 161 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 162 | # and can be added to the global gitignore or merged into this file. For a more nuclear 163 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 164 | #.idea/ 165 | 166 | ### Python Patch ### 167 | # Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration 168 | poetry.toml 169 | 170 | # ruff 171 | .ruff_cache/ 172 | 173 | # End of https://www.toptal.com/developers/gitignore/api/python 174 | 175 | # ---------------------------------------------------------------------------------------------------------------------- 176 | # Common Rules 177 | 178 | # General 179 | .vscode/* 180 | *.csv 181 | *.doc* 182 | *.jsonl 183 | *.lnk 184 | *.log 185 | *.pdf 186 | *.png 187 | *.jp*g 188 | *.spe 189 | *.svg 190 | *.xl* 191 | ~*.* 192 | ~$* 193 | 194 | # SQLite DB files (Note: use *.dvc to check these into git) 195 | *.db 196 | *.sqlite 197 | *.sqlite-journal 198 | 199 | # Other Python 200 | *.whl 201 | *.tar.gz 202 | setup.py 203 | 204 | # Coverage (for Pytest) 205 | .cover/* 206 | coverage* 207 | cov_html/ 208 | coverage.json 209 | 210 | # Other Custom Pytest 211 | /tests/_tmp_cache/** 212 | 213 | # Static Type Checkers 214 | .pytype/* 215 | 216 | # Ignore testmon cache files 217 | .testmondata 218 | 219 | # Built Outputs 220 | /releases/ 221 | 222 | # Ignore auto-created files by documentation tasks 223 | /docs/modules/*.md 224 | /docs/modules/**/*.md 225 | 226 | # Allow List 227 | !/docs/*.* 228 | !supporting/** 229 | !tests/data/** 230 | 231 | # Ensure that system files don't appear in the Allow List 232 | .DS_Store 233 | 234 | # Ensure *.pyc files are still ignored even if in tests/data directory 235 | *.pyc 236 | 237 | # ---------------------------------------------------------------------------------------------------------------------- 238 | # Custom Rules 239 | -------------------------------------------------------------------------------- /src/calcipy/cli.py: -------------------------------------------------------------------------------- 1 | """Extend Invoke for Calcipy.""" 2 | 3 | import sys 4 | from base64 import b64encode 5 | from functools import wraps 6 | from pathlib import Path 7 | from types import ModuleType 8 | 9 | from beartype.typing import Any, Callable, Dict, List, Optional, Union 10 | from invoke.collection import Collection as InvokeCollection # noqa: TID251 11 | from invoke.config import Config, merge_dicts 12 | from invoke.program import Program 13 | 14 | from .collection import TASK_ARGS_ATTR, TASK_KWARGS_ATTR, Collection, GlobalTaskOptions 15 | from .invoke_helpers import use_pty 16 | 17 | 18 | class _CalcipyProgram(Program): 19 | """Customized version of Invoke's `Program`.""" 20 | 21 | def print_help(self) -> None: # pragma: no cover 22 | """Extend print_help with calcipy-specific global configuration. 23 | 24 | https://github.com/pyinvoke/invoke/blob/0bcee75e4a26ad33b13831719c00340ca12af2f0/invoke/program.py#L657-L667 25 | 26 | """ 27 | super().print_help() 28 | print('Global Task Options:') # noqa: T201 29 | print() # noqa: T201 30 | self.print_columns( 31 | [ 32 | ('*file_args', 'List of Paths available globally to all tasks. Will resolve paths with working_dir'), 33 | ('--keep-going', 'Continue running tasks even on failure'), 34 | ('--working_dir=STRING', 'Set the cwd for the program. Example: "../run --working-dir .. lint test"'), 35 | ('-v,-vv,-vvv', 'Globally configure logger verbosity (-vvv for most verbose)'), 36 | ], 37 | ) 38 | print() # noqa: T201 39 | 40 | 41 | class CalcipyConfig(Config): 42 | """Opinionated Config with better defaults.""" 43 | 44 | @staticmethod 45 | def global_defaults() -> Dict: # type: ignore[type-arg] # pragma: no cover 46 | """Override the global defaults.""" 47 | invoke_defaults = Config.global_defaults() 48 | calcipy_defaults = { 49 | 'run': { 50 | 'echo': True, 51 | 'echo_format': '\033[2;3;37mRunning: {command}\033[0m', 52 | 'pty': use_pty(), 53 | }, 54 | } 55 | return merge_dicts(invoke_defaults, calcipy_defaults) 56 | 57 | 58 | def start_program( 59 | pkg_name: str, 60 | pkg_version: str, 61 | module: Optional[ModuleType] = None, 62 | collection: Optional[Union[Collection, InvokeCollection]] = None, 63 | ) -> None: # pragma: no cover 64 | """Run the customized Invoke Program. 65 | 66 | FYI: recommendation is to extend the `core_args` method, but this won't parse positional arguments: 67 | https://docs.pyinvoke.org/en/stable/concepts/library.html#modifying-core-parser-arguments 68 | 69 | """ 70 | # Manipulate 'sys.argv' to hide arguments that invoke can't parse 71 | lgto = GlobalTaskOptions() 72 | sys_argv: List[str] = sys.argv[:1] 73 | last_argv = '' 74 | for argv in sys.argv[1:]: 75 | if not last_argv.startswith('-') and Path(argv).is_file(): 76 | lgto.file_args.append(Path(argv)) 77 | # Check for CLI flags 78 | elif argv in {'-v', '-vv', '-vvv', '--verbose'}: 79 | lgto.verbose = argv.count('v') 80 | elif argv == '--keep-going': 81 | lgto.keep_going = True 82 | # Check for CLI arguments with values 83 | elif last_argv == '--working-dir': 84 | lgto.working_dir = Path(argv).resolve() 85 | elif argv != '--working-dir': 86 | sys_argv.append(argv) 87 | last_argv = argv 88 | lgto.file_args = [_f if _f.is_absolute() else Path.cwd() / _f for _f in lgto.file_args] 89 | sys.argv = sys_argv 90 | 91 | class _CalcipyConfig(CalcipyConfig): 92 | gto: GlobalTaskOptions = lgto 93 | 94 | if module and collection: 95 | raise ValueError('Only one of collection or module can be specified') 96 | 97 | _CalcipyProgram( 98 | name=pkg_name, 99 | version=pkg_version, 100 | # binary=?, 101 | # binary_names=?, 102 | namespace=Collection.from_module(module) if module else collection, 103 | config_class=_CalcipyConfig, 104 | ).run() 105 | 106 | 107 | def task(*dec_args: Any, **dec_kwargs: Any) -> Callable: # type: ignore[type-arg] 108 | """Mark wrapped callable object as a valid Invoke task.""" 109 | 110 | def wrapper(func: Any) -> Callable: # type: ignore[type-arg] 111 | # Attach arguments for Task 112 | setattr(func, TASK_ARGS_ATTR, dec_args) 113 | setattr(func, TASK_KWARGS_ATTR, dec_kwargs) 114 | # Attach public attributes from invoke that are expected 115 | func.help = dec_kwargs.pop('help', {}) 116 | 117 | def _with_kwargs(**extra_kwargs: Any) -> Callable: # type: ignore[type-arg] # nosem 118 | """Support building partial tasks.""" 119 | if extra_kwargs: 120 | # Set a unique name when 'extra_kwargs' was provided 121 | # https://github.com/pyinvoke/invoke/blob/07b836f2663bb073a7bcef3d6c454e1dc6b867ae/invoke/tasks.py#L81-L104 122 | encoded = b64encode(str(extra_kwargs).encode()) 123 | func.__name__ = f"{func.__name__}_{encoded.decode().rstrip('=')}" 124 | 125 | @wraps(func) # nosem 126 | def _with_kwargs_inner(*args: Any, **kwargs: Any) -> Any: 127 | return func(*args, **kwargs, **extra_kwargs) 128 | 129 | return _with_kwargs_inner 130 | 131 | func.with_kwargs = _with_kwargs 132 | 133 | @wraps(func) # nosem 134 | def _inner(*args: Any, **kwargs: Any) -> Any: 135 | return func(*args, **kwargs) 136 | 137 | return _inner 138 | 139 | # Handle the case when the decorator is called without arguments 140 | if len(dec_args) == 1 and callable(dec_args[0]) and not hasattr(dec_args[0], TASK_ARGS_ATTR): 141 | return wrapper(dec_args[0]) 142 | 143 | return wrapper 144 | -------------------------------------------------------------------------------- /src/calcipy/collection.py: -------------------------------------------------------------------------------- 1 | """Extend Invoke for Calcipy.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | import os 7 | from contextlib import redirect_stderr, redirect_stdout 8 | from dataclasses import dataclass, field 9 | from functools import wraps 10 | from io import StringIO 11 | from pathlib import Path 12 | from types import ModuleType 13 | 14 | from beartype.typing import Any, Callable, Dict, List, Optional, Tuple, Union 15 | from corallium.log import LOGGER, configure_logger 16 | from invoke.collection import Collection as InvokeCollection # noqa: TID251 17 | from invoke.context import Context 18 | from invoke.tasks import Task 19 | 20 | TASK_ARGS_ATTR = 'dev_args' 21 | TASK_KWARGS_ATTR = 'dev_kwargs' 22 | 23 | DeferredTask = Union[Callable, Task] # type: ignore[type-arg] 24 | 25 | LOG_LOOKUP = {3: logging.NOTSET, 2: logging.DEBUG, 1: logging.INFO, 0: logging.WARNING} 26 | 27 | 28 | @dataclass 29 | class GlobalTaskOptions: 30 | """Global Task Options.""" 31 | 32 | working_dir: Path = field(default_factory=Path.cwd) 33 | """Working directory for the program to use globally.""" 34 | 35 | file_args: List[Path] = field(default_factory=list) 36 | """List of Paths to modify.""" 37 | 38 | verbose: int = field(default=0) 39 | """Verbosity level.""" 40 | 41 | keep_going: bool = False 42 | """Continue task execution regardless of failure.""" 43 | 44 | capture_output: bool = False 45 | """Capture stdout and stderr output from tasks.""" 46 | 47 | def __post_init__(self) -> None: 48 | """Validate dataclass.""" 49 | options_verbose = [*LOG_LOOKUP.keys()] 50 | if self.verbose not in options_verbose: 51 | error = f'verbose must be one of: {options_verbose}' 52 | raise ValueError(error) 53 | 54 | 55 | def _configure_task_logger(ctx: Context) -> None: # pragma: no cover 56 | """Configure the logger based on task context.""" 57 | verbose = ctx.config.gto.verbose 58 | raw_log_level = LOG_LOOKUP.get(verbose) 59 | log_level = logging.ERROR if raw_log_level is None else raw_log_level 60 | configure_logger(log_level=log_level) 61 | 62 | 63 | def _run_task(func: Any, ctx: Context, *args: Any, show_task_info: bool, **kwargs: Any) -> Any: # pragma: no cover 64 | """Run the task function with optional logging.""" 65 | if show_task_info: 66 | summary = func.__doc__.split('\n')[0] 67 | LOGGER.text(f'Running {func.__name__}', is_header=True, summary=summary) 68 | LOGGER.text_debug('With task arguments', args=args, kwargs=kwargs) 69 | 70 | if ctx.config.gto.capture_output: 71 | stdout_capture = StringIO() 72 | stderr_capture = StringIO() 73 | with redirect_stdout(stdout_capture), redirect_stderr(stderr_capture): 74 | result = func(ctx, *args, **kwargs) 75 | captured_stdout = stdout_capture.getvalue() 76 | captured_stderr = stderr_capture.getvalue() 77 | if show_task_info: 78 | if captured_stdout: 79 | LOGGER.text_debug('Captured stdout', output=captured_stdout) 80 | if captured_stderr: 81 | LOGGER.text_debug('Captured stderr', output=captured_stderr) 82 | else: 83 | result = func(ctx, *args, **kwargs) 84 | 85 | if show_task_info: 86 | LOGGER.text('') 87 | LOGGER.text_debug(f'Completed {func.__name__}', result=result) 88 | 89 | return result 90 | 91 | 92 | def _wrapped_task(ctx: Context, *args: Any, func: Any, show_task_info: bool, **kwargs: Any) -> Any: # pragma: no cover 93 | """Wrap task with extended logic.""" 94 | try: 95 | ctx.config.gto # noqa: B018 96 | except AttributeError: 97 | ctx.config.gto = GlobalTaskOptions() 98 | 99 | # Begin utilizing Global Task Options 100 | os.chdir(ctx.config.gto.working_dir) 101 | _configure_task_logger(ctx) 102 | 103 | try: 104 | return _run_task(func, ctx, *args, show_task_info=show_task_info, **kwargs) 105 | except Exception: 106 | if not ctx.config.gto.keep_going: 107 | raise 108 | LOGGER.exception('Task Failed', func=str(func), args=args, kwargs=kwargs) 109 | return None 110 | 111 | 112 | def _build_task(task: DeferredTask) -> Task: # type: ignore[type-arg] # pragma: no cover 113 | """Defer creation of the Task.""" 114 | if hasattr(task, TASK_ARGS_ATTR) or hasattr(task, TASK_KWARGS_ATTR): 115 | 116 | @wraps(task) 117 | def inner(*args: Any, **kwargs: Any) -> Any: 118 | return _wrapped_task(*args, func=task, show_task_info=show_task_info, **kwargs) 119 | 120 | kwargs = getattr(task, TASK_KWARGS_ATTR) 121 | show_task_info = kwargs.pop('show_task_info', None) or False 122 | pre = [_build_task(pre) for pre in kwargs.pop('pre', None) or []] 123 | post = [_build_task(post) for post in kwargs.pop('post', None) or []] 124 | return Task(inner, *getattr(task, TASK_ARGS_ATTR), pre=pre, post=post, **kwargs) # type: ignore[misc,arg-type] 125 | return task # type: ignore[return-value] 126 | 127 | 128 | class Collection(InvokeCollection): 129 | """Calcipy Task Collection.""" 130 | 131 | @classmethod 132 | def from_module( 133 | cls, 134 | module: ModuleType, 135 | name: Optional[str] = None, 136 | config: Optional[Dict[str, Any]] = None, 137 | loaded_from: Optional[str] = None, 138 | auto_dash_names: Optional[bool] = None, 139 | ) -> InvokeCollection: 140 | """Extend search for a namespace, Task, or deferred task.""" 141 | collection = super().from_module( 142 | module=module, 143 | name=name, 144 | config=config, 145 | loaded_from=loaded_from, 146 | auto_dash_names=auto_dash_names, 147 | ) 148 | 149 | # If tasks were not loaded from a namespace or otherwise found 150 | if not collection.task_names: 151 | # Look for any decorated, but deferred "Tasks" 152 | for task in (fxn for fxn in vars(module).values() if hasattr(fxn, TASK_ARGS_ATTR)): 153 | collection.add_task(task) 154 | 155 | return collection 156 | 157 | def add_task( 158 | self, 159 | task: DeferredTask, 160 | name: Optional[str] = None, 161 | aliases: Optional[Tuple[str, ...]] = None, 162 | default: Optional[bool] = None, 163 | ) -> None: 164 | """Extend for deferred tasks.""" 165 | super().add_task(task=_build_task(task), name=name, aliases=aliases, default=default) 166 | -------------------------------------------------------------------------------- /docs/calcipy-square.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | calcipy 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | CALCIPY 18 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /docs/calcipy-banner.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | calcipy-banner 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | CALCIPY 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /docs/calcipy-banner-wide.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | calcipy-banner-wide 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | CALCIPY 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # calcipy 2 | 3 | ![./calcipy-banner-wide.svg](https://raw.githubusercontent.com/KyleKing/calcipy/main/docs/calcipy-banner-wide.svg) 4 | 5 | `calcipy` is a Python package that implements best practices such as code style (linting, auto-fixes), documentation, CI/CD, and logging. Like the calcium carbonate in hard coral, packages can be built on the `calcipy` foundation. 6 | 7 | `calcipy` has some configurability, but is tailored for my particular use cases. If you want the same sort of functionality, there are a number of alternatives to consider: 8 | 9 | - [pyscaffold](https://github.com/pyscaffold/pyscaffold) is a much more mature project that aims for the same goals, but with a slightly different approach and tech stack (tox vs. nox, cookiecutter vs. copier, etc.) 10 | - [tidypy](https://github.com/jayclassless/tidypy#features), [pylama](https://github.com/klen/pylama), and [codecheck](https://pypi.org/project/codecheck/) offer similar functionality of bundling and running static checkers, but makes far fewer assumptions 11 | - [pytoil](https://github.com/FollowTheProcess/pytoil) is a general CLI tool for developer automation 12 | - And many more such as [pyta](https://github.com/pyta-uoft/pyta), [prospector](https://github.com/PyCQA/prospector), [wemake-python-styleguide](https://github.com/wemake-services/wemake-python-styleguide) / [cjolowicz/cookiecutter-hypermodern-python](https://github.com/cjolowicz/cookiecutter-hypermodern-python), [formate](https://github.com/python-formate/formate), [johnthagen/python-blueprint](https://github.com/johnthagen/python-blueprint), [oxsecurity/megalinter](https://github.com/oxsecurity/megalinter), [trialandsuccess/su6](https://github.com/trialandsuccess/su6), [precious](https://github.com/houseabsolute/precious), etc. 13 | 14 | ## Installation 15 | 16 | Calcipy needs a few static files managed using copier and a template project: [kyleking/calcipy_template](https://github.com/KyleKing/calcipy_template/) 17 | 18 | You can quickly use the template to create a new project or add calcipy to an existing one: 19 | 20 | ```sh 21 | # Below examples assume you have Astral uv installed (which provides uvx) 22 | # If you have your shell configured, `uv tool install copier` allows usage of `copier ...` instead of `uvx copier ...` 23 | 24 | # To create a new project 25 | uvx copier copy gh:KyleKing/calcipy_template new_project 26 | cd new_project 27 | 28 | # Or convert/update an existing one 29 | cd my_project 30 | uvx copier copy gh:KyleKing/calcipy_template . 31 | uvx copier update 32 | ``` 33 | 34 | ### Calcipy CLI 35 | 36 | Additionally, `calcipy` can be run as a CLI application without adding the package as a dependency. 37 | 38 | Quick Start: 39 | 40 | ```sh 41 | # For the CLI, only install a few of the extras which can be used from a few different CLI commands 42 | uv tool install 'calcipy[lint,tags]' 43 | 44 | # Use 'tags' to create a CODE_TAG_SUMMARY of the specified directory 45 | calcipy-tags tags --help 46 | calcipy-tags tags --base-dir=~/path/to/my_project 47 | ``` 48 | 49 | Note: the CLI output below is compressed for readability, but you can try running each of these commands locally to see the most up-to-date documentation and the full set of options. The "Usage", "Core options", and "Global Task Options" are the same for each subsequent command, so they are excluded for brevity. 50 | 51 | ```txt 52 | > calcipy-lint 53 | Usage: calcipy-lint [--core-opts] [--subcommand-opts] ... 54 | 55 | Core options: 56 | 57 | --complete Print tab-completion candidates for given parse remainder. 58 | --hide=STRING Set default value of run()'s 'hide' kwarg. 59 | --print-completion-script=STRING Print the tab-completion script for your preferred shell (bash|zsh|fish). 60 | --prompt-for-sudo-password Prompt user at start of session for the sudo.password config value. 61 | --write-pyc Enable creation of .pyc files. 62 | -d, --debug Enable debug output. 63 | -D INT, --list-depth=INT When listing tasks, only show the first INT levels. 64 | -e, --echo Echo executed commands before running. 65 | -f STRING, --config=STRING Runtime configuration file to use. 66 | -F STRING, --list-format=STRING Change the display format used when listing tasks. Should be one of: flat (default), nested, json. 67 | -h [STRING], --help[=STRING] Show core or per-task help and exit. 68 | -l [STRING], --list[=STRING] List available tasks, optionally limited to a namespace. 69 | -p, --pty Use a pty when executing shell commands. 70 | -R, --dry Echo commands instead of running. 71 | -T INT, --command-timeout=INT Specify a global command execution timeout, in seconds. 72 | -V, --version Show version and exit. 73 | -w, --warn-only Warn, instead of failing, when shell commands fail. 74 | 75 | Subcommands: 76 | 77 | lint.check (lint) Run ruff as check-only. 78 | lint.fix Run ruff and apply fixes. 79 | lint.prek Run prek. 80 | lint.watch Run ruff as check-only. 81 | 82 | Global Task Options: 83 | 84 | *file_args List of Paths available globally to all tasks. Will resolve paths with working_dir 85 | --keep-going Continue running tasks even on failure 86 | --working_dir=STRING Set the cwd for the program. Example: "../run --working-dir .. lint test" 87 | -v,-vv,-vvv Globally configure logger verbosity (-vvv for most verbose) 88 | 89 | > calcipy-pack 90 | 91 | Subcommands: 92 | 93 | pack.bump-tag Experiment with bumping the git tag using `griffe` (experimental). 94 | pack.lock Update package manager lock file. 95 | pack.publish Build the distributed format(s) and publish. 96 | pack.sync-pyproject-versions Experiment with setting the pyproject.toml dependencies to the version from uv.lock (experimental). 97 | 98 | > calcipy-tags 99 | 100 | Subcommands: 101 | 102 | tags.collect-code-tags (tags) Create a `CODE_TAG_SUMMARY.md` with a table for TODO- and FIXME-style code comments. 103 | 104 | > calcipy-types 105 | 106 | Subcommands: 107 | 108 | types.mypy Run mypy. 109 | types.pyright Run pyright using the config in `pyproject.toml`. 110 | ``` 111 | 112 | ### Calcipy Pre-Commit 113 | 114 | `calcipy` can also be used as a `pre-commit` task by adding the below snippet to your `pre-commit` file: 115 | 116 | ```yaml 117 | repos: 118 | - repo: https://github.com/KyleKing/calcipy 119 | rev: main 120 | hooks: 121 | - id: tags 122 | - id: lint-fix 123 | - id: types 124 | ``` 125 | 126 | Tip: running pre-commit with prek is recommended for performance: https://pypi.org/project/prek 127 | 128 | ## Project Status 129 | 130 | See the `Open Issues` and/or the [CODE_TAG_SUMMARY]. For release history, see the [CHANGELOG]. 131 | 132 | ## Contributing 133 | 134 | We welcome pull requests! For your pull request to be accepted smoothly, we suggest that you first open a GitHub issue to discuss your idea. For resources on getting started with the code base, see the below documentation: 135 | 136 | - [DEVELOPER_GUIDE] 137 | - [STYLE_GUIDE] 138 | 139 | ## Code of Conduct 140 | 141 | We follow the [Contributor Covenant Code of Conduct][contributor-covenant]. 142 | 143 | ### Open Source Status 144 | 145 | We try to reasonably meet most aspects of the "OpenSSF scorecard" from [Open Source Insights](https://deps.dev/pypi/calcipy) 146 | 147 | ## Responsible Disclosure 148 | 149 | If you have any security issue to report, please contact the project maintainers privately. You can reach us at [dev.act.kyle@gmail.com](mailto:dev.act.kyle@gmail.com). 150 | 151 | ## License 152 | 153 | [LICENSE] 154 | 155 | [changelog]: https://calcipy.kyleking.me/docs/CHANGELOG 156 | [code_tag_summary]: https://calcipy.kyleking.me/docs/CODE_TAG_SUMMARY 157 | [contributor-covenant]: https://www.contributor-covenant.org 158 | [developer_guide]: https://calcipy.kyleking.me/docs/DEVELOPER_GUIDE 159 | [license]: https://github.com/kyleking/calcipy/blob/main/LICENSE 160 | [style_guide]: https://calcipy.kyleking.me/docs/STYLE_GUIDE 161 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | build-backend = "uv_build" 3 | requires = ["uv_build>=0.9.7"] 4 | 5 | [dependency-groups] 6 | maintain = [ 7 | "build>=1.2", 8 | "git-changelog>=2.5", 9 | "twine>=5.1", 10 | "yore>=0.3.3", 11 | ] 12 | ci = [ 13 | "duty>=1.6", 14 | "hypothesis[cli] >=6.112.4", # Use CLI with: "uv run hypothesis write calcipy.dot_dict.ddict" 15 | "mypy >=1.11.2", 16 | "pytest >=8.3.3", 17 | "pytest-asyncio >=0.24.0", 18 | "pytest-cov >=5.0.0", 19 | "pytest-randomly >=3.15.0", 20 | "pytest-subprocess >=1.5.2", 21 | "pytest-watcher >=0.4.3", 22 | "pytest-xdist>=3.6", 23 | "ruff >=0.8.2", 24 | "syrupy >=4.7.2", 25 | "types-markdown>=3.6", 26 | "types-pyyaml >=6.0.12.20240917", 27 | "types-setuptools >=75.1.0.20240917", 28 | ] 29 | docs = [ 30 | "commitizen >=3.29.1", 31 | "markdown-callouts>=0.4", 32 | "markdown-exec>=1.8", 33 | "mkdocs >=1.6.1", 34 | "mkdocs-coverage>=1.0", 35 | "mkdocs-gen-files >=0.5.0", 36 | "mkdocs-git-revision-date-localized-plugin>=1.2", 37 | "mkdocs-llmstxt>=0.2", 38 | "mkdocs-material >=9.5.39", 39 | "mkdocs-minify-plugin>=0.8", 40 | "mkdocs-section-index>=0.3", 41 | "mkdocstrings[python] >=0.26.1", 42 | "pymdown-extensions >=10.11.2", 43 | # YORE: EOL 3.10: Remove line. 44 | "tomli>=2.0; python_version < '3.11'", 45 | ] 46 | 47 | [project] 48 | classifiers = [ 49 | "Development Status :: 4 - Beta", 50 | "Framework :: Pytest", 51 | "Intended Audience :: Developers", 52 | "License :: OSI Approved :: MIT License", 53 | "Operating System :: OS Independent", 54 | "Programming Language :: Python :: 3.10", 55 | "Programming Language :: Python :: 3.11", 56 | "Programming Language :: Python :: 3.12", 57 | "Programming Language :: Python :: 3.9", 58 | "Topic :: Software Development :: Libraries", 59 | "Topic :: Utilities", 60 | ] # https://pypi.org/classifiers/ 61 | dependencies = [ 62 | "beartype >=0.19.0", 63 | "corallium>=2.1.1", 64 | "invoke >=2.2.0", 65 | "keyring>=25.5.0", 66 | "pydantic >=2.9.2", 67 | ] 68 | description = "Python package to simplify development" 69 | documentation = "https://calcipy.kyleking.me" 70 | keywords = ["calcipy_template"] 71 | license = "MIT" 72 | maintainers = [] 73 | name = "calcipy" 74 | readme = "docs/README.md" 75 | repository = "https://github.com/kyleking/calcipy" 76 | requires-python = ">=3.9.13" 77 | version = "5.0.0" 78 | 79 | [project.optional-dependencies] 80 | ddict = [ 81 | "python-box >=7.2.0", 82 | ] 83 | doc = [ 84 | "commitizen >=3.29.1", 85 | "mkdocs >=1.6.1", 86 | "mkdocs-gen-files >=0.5.0", 87 | "mkdocs-material >=9.5.39", 88 | "mkdocstrings[python] >=0.26.1", 89 | "pymdown-extensions >=10.11.2", 90 | ] 91 | experimental = [ 92 | "griffe >=1.3.2", 93 | "semver >=3.0.2", 94 | ] 95 | lint = [ 96 | "ruff >=0.8.2", 97 | ] 98 | nox = [ 99 | "nox >=2024.10.9", # "virtualenv >=20.26.6", # Prevents 'scripts' KeyError with Python 3.12 100 | ] 101 | tags = [ 102 | "arrow >=1.3.0", 103 | "pyyaml >=6.0.2", # Required for get_doc_subdir 104 | ] 105 | test = [ 106 | "pytest >=8.3.3", 107 | "pytest-cov >=5.0.0", 108 | "pytest-randomly >=3.15.0", 109 | "pytest-watcher >=0.4.3", 110 | ] 111 | types = [ 112 | "mypy >=1.11.2", 113 | ] 114 | 115 | [project.scripts] # Docs: https://setuptools.pypa.io/en/latest/userguide/entry_point.html#console-scripts 116 | calcipy = "calcipy.scripts:start" 117 | calcipy-docs = "calcipy.scripts:start_docs" 118 | calcipy-lint = "calcipy.scripts:start_lint" 119 | calcipy-pack = "calcipy.scripts:start_pack" 120 | calcipy-tags = "calcipy.scripts:start_tags" 121 | calcipy-test = "calcipy.scripts:start_test" 122 | calcipy-types = "calcipy.scripts:start_types" 123 | 124 | [tool.commitizen] 125 | version = "5.0.0" 126 | version_files = ["pyproject.toml:^version", "src/calcipy/__init__.py:^__version"] 127 | 128 | [tool.mypy] 129 | check_untyped_defs = true 130 | disallow_any_generics = true 131 | enable_error_code = ["ignore-without-code", "possibly-undefined", "redundant-expr", "truthy-bool"] 132 | extra_checks = true 133 | files = ["src/calcipy", "tests"] 134 | no_implicit_reexport = true 135 | plugins = [ 136 | ] 137 | python_version = "3.9" 138 | show_column_numbers = true 139 | show_error_codes = true 140 | strict_equality = true 141 | warn_no_return = true 142 | warn_redundant_casts = true 143 | warn_unreachable = true 144 | warn_unused_configs = true 145 | warn_unused_ignores = true 146 | 147 | [tool.pyright] 148 | include = ["src/calcipy", "tests"] 149 | pythonVersion = "3.9" 150 | 151 | [tool.ruff] 152 | # Docs: https://github.com/charliermarsh/ruff 153 | # Tip: uv run ruff rule RUF100 154 | line-length = 120 155 | target-version = 'py39' 156 | 157 | [tool.ruff.lint] 158 | ignore = [ 159 | 'ANN002', # Missing type annotation for `*args` 160 | 'ANN003', # Missing type annotation for `**kwargs` 161 | 'ANN401', # Dynamically typed expressions (typing.Any) are disallowed in `pop_key` 162 | 'BLE001', # Do not catch blind exception: `Exception` 163 | 'CPY001', # Missing copyright notice at top of file 164 | 'D203', # "1 blank line required before class docstring" (Conflicts with D211) 165 | 'D213', # "Multi-line docstring summary should start at the second line" (Conflicts with D212) 166 | 'DOC201', # PLANNED: finish updating docstrings for Returns 167 | 'DOC501', # Raised exception `RuntimeError` missing from docstring 168 | 'EM101', # Exception must not use a string literal, assign to variable first 169 | 'FIX001', # Line contains FIXME 170 | 'FIX002', # Line contains TODO 171 | 'FIX004', # Line contains HACK 172 | 'PLR0913', # Too many arguments in function definition (6 > 5) 173 | 'TC001', # Move application import `tail_jsonl.config.Config` into a type-checking block (Conflicts with Beartype) 174 | 'TC002', # Move third-party import `rich.console.Console` into a type-checking block (Conflicts with Beartype) 175 | 'TC003', # Move standard library import `argparse` into a type-checking block (Conflicts with Beartype) 176 | 'TD001', # Invalid TODO tag: `FIXME` 177 | 'TD002', # Missing author in TODO; try: `# TODO(): ...` 178 | 'TD003', # Missing issue link on the line following this TODO 179 | 'TRY003', # Avoid specifying long messages outside the exception class 180 | ] 181 | preview = true 182 | select = ['ALL'] 183 | unfixable = [ 184 | 'ERA001', # Commented out code 185 | ] 186 | 187 | [tool.ruff.lint.flake8-quotes] 188 | inline-quotes = 'single' 189 | 190 | [tool.ruff.lint.flake8-tidy-imports.banned-api] 191 | 'invoke.collection.Collection'.msg = 'Use calcipy.collection.Collection instead.' 192 | 'invoke.tasks.task'.msg = 'Use calcipy.cli.task instead.' 193 | 'typing.Callable'.msg = 'Use "from collections.abc" instead.' 194 | 'typing.Dict'.msg = 'Use "from __future__ import annotations"' 195 | 'typing.List'.msg = 'Use "from __future__ import annotations"' 196 | 'typing.Optional'.msg = 'Use "from __future__ import annotations"' 197 | 'typing.Protocol'.msg = 'Use "from beartype.typing" instead.' 198 | 199 | [tool.ruff.lint.isort] 200 | known-first-party = ['calcipy'] 201 | 202 | [tool.ruff.lint.per-file-ignores] 203 | './src/calcipy/../*.py' = [ 204 | 'INP001', # File `/<>.py` is part of an implicit namespace package. Add an `__init__.py`. 205 | ] 206 | '__init__.py' = [ 207 | 'D104', # Missing docstring in public package 208 | ] 209 | 'scripts/*.py' = [ 210 | 'INP001', # File `scripts/*.py` is part of an implicit namespace package. Add an `__init__.py`. 211 | ] 212 | 'tests/*.py' = [ 213 | 'ANN001', # Missing type annotation for function argument 214 | 'ANN201', # Missing return type annotation for public function 215 | 'ANN202', # Missing return type annotation for private function `test_make_diffable` 216 | 'ARG001', # Unused function argument: `line` 217 | 'D100', # Missing docstring in public module 218 | 'D103', # Missing docstring in public function 219 | 'PLC2701', # Private name import `_<>` from external module 220 | 'PT004', # flake8-pytest-style: fixture does not return 221 | 'S101', # Use of `assert` detected 222 | ] 223 | 224 | [tool.ruff.lint.pydocstyle] 225 | convention = "google" 226 | 227 | [tool.tomlsort] 228 | all = true 229 | in_place = true 230 | trailing_comma_inline_array = true 231 | 232 | [tool.uv] 233 | default-groups = ["maintain", "ci", "docs"] 234 | required-version = ">=0.9.0" 235 | -------------------------------------------------------------------------------- /src/calcipy/md_writer/_writer.py: -------------------------------------------------------------------------------- 1 | """Markdown Machine.""" 2 | 3 | from __future__ import annotations 4 | 5 | import json 6 | import re 7 | from pathlib import Path 8 | 9 | from beartype.typing import Any, Callable, Dict, List, Optional 10 | from corallium.file_helpers import read_lines 11 | from corallium.log import LOGGER 12 | 13 | from calcipy.file_search import find_project_files_by_suffix 14 | from calcipy.invoke_helpers import get_project_path 15 | from calcipy.markdown_table import format_table 16 | 17 | HandlerLookupT = Dict[str, Callable[[str, Path], List[str]]] 18 | """Handler Lookup.""" 19 | 20 | 21 | class _ParseSkipError(RuntimeError): 22 | """Exception caught if the handler does not want to replace the text.""" 23 | 24 | 25 | class _ReplacementMachine: 26 | """State machine to replace content with user-specified handlers. 27 | 28 | Uses `{cts}` and `{cte}` to demarcate sections (short for 'calcipy-template-start' or '...-end') 29 | 30 | Previously built with `transitions`: https://pypi.org/project/transitions 31 | 32 | """ 33 | 34 | def __init__(self) -> None: 35 | """Initialize the state machine.""" 36 | self.state_other = 'non-template' 37 | self.state_template = 'calcipy-template-formatted' 38 | self.state = self.state_other 39 | 40 | def change_template(self) -> None: 41 | """Transition from state_other to state_template.""" 42 | if self.state == self.state_other: 43 | self.state = self.state_template 44 | 45 | def change_end(self) -> None: 46 | """Transition from state_template to state_other.""" 47 | if self.state == self.state_template: 48 | self.state = self.state_other 49 | 50 | def parse( 51 | self, 52 | lines: List[str], 53 | handler_lookup: HandlerLookupT, 54 | path_file: Path, 55 | ) -> List[str]: 56 | """Parse lines and insert new_text based on provided handler_lookup. 57 | 58 | Args: 59 | lines: list of string from source file 60 | handler_lookup: Lookup dictionary for template-formatted sections 61 | path_file: optional path to the file. Only useful for debugging 62 | 63 | Returns: 64 | List[str]: modified list of strings 65 | 66 | """ 67 | updated_lines = [] 68 | for line in lines: 69 | updated_lines.extend(self._parse_line(line, handler_lookup, path_file)) 70 | return updated_lines 71 | 72 | def _parse_line( 73 | self, 74 | line: str, 75 | handler_lookup: HandlerLookupT, 76 | path_file: Path, 77 | ) -> List[str]: 78 | """Parse lines and insert new_text based on provided handler_lookup. 79 | 80 | Args: 81 | line: single line 82 | handler_lookup: lookup dictionary for template-formatted sections 83 | path_file: optional path to the file. Only useful for debugging 84 | 85 | Returns: 86 | List[str]: modified list of strings 87 | 88 | """ 89 | lines: List[str] = [] 90 | if '{cte}' in line and self.state == self.state_template: # end 91 | self.change_end() 92 | elif '{cts}' in line: # start 93 | self.change_template() 94 | matches = [text_match for text_match in handler_lookup if text_match in line] 95 | if len(matches) == 1: 96 | [match] = matches 97 | try: 98 | lines.extend(handler_lookup[match](line, path_file)) 99 | except _ParseSkipError: 100 | lines.append(line) 101 | self.change_end() 102 | else: 103 | LOGGER.debug('Could not parse. Skipping:', line=line) 104 | lines.append(line) 105 | self.change_end() 106 | elif self.state == self.state_other: 107 | lines.append(line) 108 | # else: discard the lines in the template-section 109 | return lines 110 | 111 | 112 | _VAR_COMMENT_HTML = r'' 153 | line_end = '' 154 | return [line_start, *lines_source, line_end] 155 | 156 | 157 | def _format_cov_table(coverage_data: Dict[str, Any]) -> List[str]: 158 | """Format code coverage data table as markdown. 159 | 160 | Args: 161 | coverage_data: dictionary created by `python -m coverage json` 162 | 163 | Returns: 164 | List[str]: list of string lines to insert 165 | 166 | """ 167 | col_key_map = { 168 | 'Statements': 'num_statements', 169 | 'Missing': 'missing_lines', 170 | 'Excluded': 'excluded_lines', 171 | 'Coverage': 'percent_covered', 172 | } 173 | records = [ 174 | { 175 | 'File': f'`{Path(path_file).as_posix()}`', 176 | **{col: file_obj['summary'][key] for col, key in col_key_map.items()}, 177 | } 178 | for path_file, file_obj in coverage_data['files'].items() 179 | ] 180 | records.append( 181 | { 182 | 'File': '**Totals**', 183 | **{col: coverage_data['totals'][key] for col, key in col_key_map.items()}, 184 | }, 185 | ) 186 | records = [{**_r, 'Coverage': f"{round(_r['Coverage'], 1)}%"} for _r in records] 187 | 188 | delimiters = ['-', *(['-:'] * len(col_key_map))] 189 | lines_table = format_table(headers=['File', *col_key_map], records=records, delimiters=delimiters).split('\n') 190 | short_date = coverage_data['meta']['timestamp'].split('T')[0] 191 | lines_table.extend(['', f'Generated on: {short_date}']) 192 | return lines_table 193 | 194 | 195 | def _handle_coverage(line: str, _path_file: Path, path_coverage: Optional[Path] = None) -> List[str]: 196 | """Read the coverage.json file and write a Markdown table to the README file. 197 | 198 | Args: 199 | line: first line of the section 200 | _path_file: path to the file that contained the string (unused) 201 | path_coverage: full path to a coverage.json file or defaults to the project 202 | 203 | Returns: 204 | List[str]: list of template-formatted text 205 | 206 | Raises: 207 | _ParseSkipError: if the "coverage.json" file is not available 208 | 209 | """ 210 | path_coverage = path_coverage or get_project_path() / 'coverage.json' 211 | if not path_coverage.is_file(): 212 | msg = f'Could not locate: {path_coverage}' 213 | raise _ParseSkipError(msg) 214 | coverage_data = json.loads(path_coverage.read_text()) 215 | lines_cov = _format_cov_table(coverage_data) 216 | line_end = '' 217 | return [line, *lines_cov, line_end] 218 | 219 | 220 | def write_template_formatted_md_sections( 221 | handler_lookup: Optional[HandlerLookupT] = None, 222 | paths_md: Optional[List[Path]] = None, 223 | ) -> None: 224 | """Populate the template-formatted sections of markdown files with user-configured logic.""" 225 | lookup: HandlerLookupT = handler_lookup or { 226 | 'COVERAGE ': _handle_coverage, 227 | 'SOURCE_FILE=': _handle_source_file, 228 | } 229 | 230 | paths = paths_md or find_project_files_by_suffix(get_project_path()).get('md') or [] 231 | for path_md in paths: 232 | LOGGER.text_debug('Processing', path_md=path_md) 233 | if md_lines := _ReplacementMachine().parse(read_lines(path_md), lookup, path_md): 234 | path_md.write_text('\n'.join(md_lines) + '\n', encoding='utf-8') 235 | -------------------------------------------------------------------------------- /docs/docs/STYLE_GUIDE.md: -------------------------------------------------------------------------------- 1 | # Personal Style Guides 2 | 3 | ## Git 4 | 5 | We use [Commitizen](https://github.com/commitizen-tools/commitizen) to manage both an auto-generated [Changelog](https://keepachangelog.com/en/1.0.0/) and incrementing the release version following [semver](https://semver.org/). For both of these automated outputs to work well, please follow the [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) style, which is described in more detail below. 6 | 7 | ### Commitizen Types and Scopes 8 | 9 | > `type(scope): description` 10 | 11 | - **Types** 12 | - *fix*: A bug fix 13 | - *feat*: A new feature 14 | - *docs*: Documentation-only changes (code comments, separate docs) 15 | - *style*: Changes that do not affect the meaning of the code (white-space, formatting, missing semi-colons) 16 | - *perf*: A code change that improves performance 17 | - *refactor*: A change to production code that is not a *fix*, *feat*, or *perf* 18 | - *test*: Adding missing or correcting existing tests 19 | - *build*: Changes that affect the build system or external dependencies 20 | - *ci*: Changes to our CI configuration files and scripts 21 | - A `!` can be used to indicate a breaking change (`refactor!: drop support for Node 6`) 22 | - **SemVer Rules** 23 | - Based on commit type, the version will be auto-incremented: `fix : PATCH // feat : MINOR // BREAKING CHANGE : MAJOR` 24 | - **Scopes** 25 | - A Class, File name, Issue Number, other appropriate noun. As examples: `build(uv): bump requests to v3` or `style(#32): add missing type annotations` 26 | - **Tips** 27 | - What if a commit fits multiple types? 28 | - Go back and make multiple commits whenever possible. Part of the benefit of Conventional Commits is the focus on more organized and intentional changes 29 | - Use `git rebase -i` to fix commit names prior to merging if incorrect types/scopes are used 30 | 31 | ### Git Description Guidelines 32 | 33 | - [Commit message guidelines](https://writingfordevelopers.substack.com/p/how-to-write-a-commit-message) 34 | - Full sentence with verb (*lowercase*) and concise description. Below are modified examples for Conventional Commits 35 | - `fix(roles): bug in admin role permissions` 36 | - `feat(ui): implement new button design` 37 | - `build(pip): upgrade package to remove vulnerabilities` 38 | - `refactor: file structure to improve code readability` 39 | - `perf(cli): rewrite methods` 40 | - `feat(api): endpoints to implement new customer dashboard` 41 | - [How to write a good commit message](https://chris.beams.io/posts/git-commit/) 42 | - A diff will tell you what changed, but only the commit message can properly tell you why. 43 | - Keep in mind: [This](http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html) [has](https://www.git-scm.com/book/en/v2/Distributed-Git-Contributing-to-a-Project#_commit_guidelines) [all](https://github.com/torvalds/subsurface-for-dirk/blob/master/README.md#contributing) [been](http://who-t.blogspot.co.at/2009/12/on-commit-messages.html) [said](https://github.com/erlang/otp/wiki/writing-good-commit-messages) [before](https://github.com/spring-projects/spring-framework/blob/30bce7/CONTRIBUTING.md#format-commit-messages). 44 | - From the seven rules of a great Git commit message: 45 | - (2) [Try for 50 characters, but consider 72 the hard limit](https://chris.beams.io/posts/git-commit/#limit-50) 46 | - (7) [Use the body to explain what and why vs. how](https://chris.beams.io/posts/git-commit/#why-not-how) 47 | 48 | ### Issue Labels and Milestones 49 | 50 | Personal Guide 51 | 52 | - For Issue Labels, see \[labels.yml\]\[labels\] 53 | - Milestones 54 | - **Current Tasks**: main milestone (*name could change based on a specific project, sprint, or month*) 55 | - **Next Tasks** 56 | - **Blue Sky** 57 | 58 |
59 | Research 60 |
    61 |
  • [Sane GitHub Labels](https://medium.com/@dave_lunny/sane-github-labels-c5d2e6004b63) and see [sensible-github-labels](https://github.com/Relequestual/sensible-github-labels) for full descriptions of each
  • 62 |
      63 |
    • “it is much more helpful to see the status and type of all issues at a glance.”
    • 64 |
    • One of each:
    • 65 |
        66 |
      • Status: …
      • 67 |
        • Abandoned, Accepted, Available, Blocked, Completed, In Progress, On Hold, Pending, Review Needed, Revision Needed
        68 |
      • Type: …
      • 69 |
        • Bug, Maintenance, Question, Enhancement
        70 |
      • Priority: …
      • 71 |
        • Critical, High, Medium, Low
        72 |
      73 |
    74 |
  • [Britecharts](https://britecharts.github.io/britecharts/github-labels.html)
  • 75 |
      76 |
    • Status: …
    • 77 |
        78 |
      • On Review – Request that we are pondering if including or not
      • 79 |
      • Needs Reproducing – For bugs that need to be reproduced in order to get fixed
      • 80 |
      • Needs Design – Feature that needs a design
      • 81 |
      • Ready to Go – Issue that has been defined and is ready to get started with
      • 82 |
      • In Progress – Issue that is being worked on right now.
      • 83 |
      • Completed – Finished feature or fix
      • 84 |
      85 |
    • Type: …
    • 86 |
        87 |
      • Bug – An unexpected problem or unintended behavior
      • 88 |
      • Feature – A new feature request
      • 89 |
      • Maintenance – A regular maintenance chore or task, including refactors, build system, CI, performance improvements
      • 90 |
      • Documentation – A documentation improvement task
      • 91 |
      • Question – An issue or PR that needs more information or a user question
      • 92 |
      93 |
    • Not Included
    • 94 |
        95 |
      • Priority: They would add complexity and overhead due to the discussions, but could help with the roadmap
      • 96 |
      • Technology Labels: It can create too much overhead, as properly tag with technologies all the issues could be time consuming
      • 97 |
      98 |
    99 |
100 |
  • [Ian Bicking Blog](https://www.ianbicking.org/blog/2014/03/use-github-issues-to-organize-a-project.html)
  • 101 |
      102 |
    • Milestone Overview
    • 103 |
        104 |
      • What are we doing right now?
      • 105 |
      • What aren’t we doing right now?
      • 106 |
          107 |
        • 2a. Stuff we’ll probably do soon
        • 108 |
        • 2b. Stuff we probably won’t do soon
        • 109 |
        110 |
      • What aren’t we sure about?
      • 111 |
      112 |
    • Milestone Descriptions
    • 113 |
        114 |
      • Stuff we are doing right now: this is the “main” milestone. We give it a name (like Alpha 2 or Strawberry Rhubarb Pie) and we write down what we are trying to accomplish with the milestone. We create a new milestone when we are ready for the next iteration.
      • 115 |
      • Stuff we’ll probably do soon: this is a standing “**Next Tasks**” milestone. We never change or rename this milestone.
      • 116 |
        • We use a permanent “Next Tasks” milestone (as opposed to renaming it to “Alpha 3” or actual-next-iteration milestone) because we don’t want to presume or default to including something in the real next iteration. When we’re ready to start planning the next iteration we’ll create a new milestone, and only deliberately move things into that milestone.
        117 |
      • Stuff we probably won’t do soon: this is a standing “**Blue Sky**” milestone. We refer to these tickets and sometimes look through them, but they are easy to ignore, somewhat intentionally ignored.
      • 118 |
      • What aren’t we sure about?: issues with no milestone.
      • 119 |
      120 |
    • Label: “Needs Discussion” - (addressed in a triage meeting) - use liberally for either big or small tickets
    • 121 |
    • “It’s better to give people more power: it’s actually helpful if people can overreach because it is an opportunity to establish where the limits really are and what purpose those limits have”
    • 122 |
    123 | 124 |
    125 | 126 | ### External Links 127 | 128 | 129 | 130 | - \[Git: The Simple Guide\]\[simple_git\] 131 | - \[Commit Messages\]\[gcmsg\] and [why use the present tense](https://news.ycombinator.com/item?id=8874177) 132 | - [GitHub's Advice on GitHub](https://github.com/erlang/otp/wiki/Writing-good-commit-messages) 133 | - [Most Comprehensive Guide](https://chris.beams.io/posts/git-commit/) 134 | - [Git Pro Book (free)](https://git-scm.com/book/en/v2) 135 | - [Bash Tab-Completion Snippet](https://git-scm.com/book/en/v2/Appendix-A%3A-Git-in-Other-Environments-Git-in-Bash) 136 | 137 | ## Python 138 | 139 | 140 | 141 | - Python Style Guides 142 | - 143 | - 144 | - 145 | - 146 | - 147 | - 148 | - 149 | 150 | ## ADRs 151 | 152 | 153 | 154 | - ADR Approaches 155 | - Template (And [associated review](https://infraeng.dev/tech-spec-review/)) vs. 156 | - Y-Statements: [abbreviated shorthand](https://medium.com/olzzio/y-statements-10eb07b5a177). Add this as a one-line decision option if a full ADR isn't needed (or when referencing an existing ADR) () 157 | - https://adr.github.io 158 | - More formal implementation of [ADRs (MADR) that this is based on](https://adr.github.io/madr/). Template: 159 | - [https://github.com/ethereum/EIPs/blob/confluenceuser/EIPS/eip-5639.md](https://github.com/ethereum/EIPs/blob/confluenceuser/EIPS/eip-5639.md) 160 | - Examples 161 | - 162 | - 163 | - 164 | - [https://github.com/ethereum/EIPs/blob/confluenceuser/EIPS/eip-1010.md](https://github.com/ethereum/EIPs/blob/confluenceuser/EIPS/eip-1010.md) 165 | - [https://docs-v1.prefect.io/core/pins/pin-01-introduce-pins.html](https://docs-v1.prefect.io/core/pins/pin-01-introduce-pins.html) 166 | - [https://peps.python.org/pep-0387/](https://peps.python.org/pep-0387) 167 | - 168 | - And many others! 169 | 170 | <-- Links --> 171 | [simple_git]: http://rogerdudler.github.io/git-guide/ 172 | [gcmsg]: https://github.com/atom/atom/blob/master/CONTRIBUTING.md#styleguides 173 | [labels]: https://github.com/kyleking/.github/labels.yml 174 | -------------------------------------------------------------------------------- /docs/docs/MIGRATION.md: -------------------------------------------------------------------------------- 1 | # Migration Guide 2 | 3 | ## `v5` 4 | 5 | The breaking changes include removing `stale` and `pack.check_license` 6 | 7 | ### Speed Test 8 | 9 | After further reduction of dependencies, the CLI performance has continued to improve: 10 | 11 | ```sh 12 | > poetry run pip freeze | wc -l 13 | 79 14 | ``` 15 | 16 | ```sh 17 | > hyperfine -m 20 --warmup 5 ./run 18 | Benchmark 1: ./run 19 | Time (mean ± σ): 397.1 ms ± 12.2 ms [User: 268.4 ms, System: 57.0 ms] 20 | Range (min … max): 385.9 ms … 421.5 ms 20 runs 21 | ``` 22 | 23 | ## `v4` 24 | 25 | The total number of dependencies was reduce even further by replacing flake8, isort, and other tooling with ruff; fewer mkdocs plugins; and fewer steps in the `main` run task to speed up normal usage. 26 | 27 | The only breaking [change impacted `recipes`](https://github.com/KyleKing/recipes/commit/b3fcf8136af77ccf1bd3ee1fb4297b79dd7e86ea#diff-4bf564fcd9dbaec9e9807f16f649791c1e333f89db8160ad715d3c0c09a1a65c) when `write_autoformatted_md_sections` was renamed to `write_template_formatted_md_sections`. 28 | 29 | ### Speed Test 30 | 31 | Following up on performance checks from the `v2` migration. The performance is comparable, but you will see savings in cache size and poetry install and when running `main` (`./run main` for Calcipy, currently takes ~20s) 32 | 33 | ```sh 34 | > hyperfine -m 20 --warmup 5 ./run 35 | Benchmark 1: ./run 36 | Time (mean ± σ): 863.9 ms ± 10.0 ms [User: 550.7 ms, System: 102.3 ms] 37 | Range (min … max): 848.5 ms … 885.3 ms 20 runs 38 | > hyperfine -m 20 --warmup 5 "poetry run calcipy-tags" 39 | Benchmark 1: poetry run calcipy-tags 40 | Time (mean ± σ): 770.5 ms ± 5.7 ms [User: 470.6 ms, System: 89.5 ms] 41 | Range (min … max): 760.1 ms … 780.3 ms 20 runs 42 | ``` 43 | 44 | ## `v3` 45 | 46 | Replaced features from flake8 and plugins with corresponding checks from ruff, however both are still used in parallel. 47 | 48 | ## `v2` 49 | 50 | ### Background 51 | 52 | calcipy `v1` was a complete rewrite to switch from `doit` to `invoke`: 53 | 54 | - with `invoke`, tasks can be run from anywhere without a `dodo.py` file 55 | - tasks can be loaded lazily, which means that some performance gains are possible 56 | - since there is no shared state file, tasks can be more easily run from pre-commit or generally in parallel 57 | 58 | `doit` excelled at clearly delineated task output and run summary, but `invoke` isn't designed that way. I would like to improve the CLI output, but the benefits are worth this tradeoff. 59 | 60 | calcipy `v0` was built on [doit](https://pypi.org/project/doit/) and thus required a `dodo.py` file. I began adding `cement` to support a separate CLI for `calcipy` installed with `pipx` or `uvx`, but that required a lot of boilerplate code. With `doit`, the string command needed to be complete at task evaluation rather than runtime, so globbing files couldn't be resolved lazily. 61 | 62 | ### Migration 63 | 64 | While refactoring, the global configuration was mostly removed (`DoitGlobals`) along with a few tasks, but the main functionality is still present. Any project dependent on `calcipy` will need substantial changes. The easiest way to start migrating is to run `copier copy gh:KyleKing/calcipy_template .` for [calcipy_template](https://github.com/KyleKing/calcipy_template) 65 | 66 | ### Speed Test 67 | 68 | It turns out that switching to `invoke` appears to have only saved 100ms 69 | 70 | ```sh 71 | > hyperfine -m 20 --warmup 5 ./run 72 | Benchmark 1: ./run 73 | Time (mean ± σ): 863.9 ms ± 10.0 ms [User: 550.7 ms, System: 102.3 ms] 74 | Range (min … max): 848.5 ms … 885.3 ms 20 runs 75 | > hyperfine -m 20 --warmup 5 "poetry run calcipy-tags" 76 | Benchmark 1: poetry run calcipy-tags 77 | Time (mean ± σ): 770.5 ms ± 5.7 ms [User: 470.6 ms, System: 89.5 ms] 78 | Range (min … max): 760.1 ms … 780.3 ms 20 runs 79 | ``` 80 | 81 | ```sh 82 | > hyperfine -m 20 --warmup 5 "poetry run python -c 'print(1)'" 83 | Benchmark 1: poetry run python -c 'print(1)' 84 | Time (mean ± σ): 377.9 ms ± 3.1 ms [User: 235.0 ms, System: 61.8 ms] 85 | Range (min … max): 372.7 ms … 384.0 ms 20 runs 86 | > hyperfine -m 20 --warmup 5 ./run 87 | Benchmark 1: ./run 88 | Time (mean ± σ): 936.0 ms ± 26.9 ms [User: 1548.2 ms, System: 1687.7 ms] 89 | Range (min … max): 896.4 ms … 1009.4 ms 20 runs 90 | > hyperfine -m 20 --warmup 5 "poetry run calcipy_tags" 91 | Benchmark 1: poetry run calcipy_tags 92 | Time (mean ± σ): 618.5 ms ± 29.7 ms [User: 1536.8 ms, System: 1066.2 ms] 93 | Range (min … max): 578.2 ms … 694.9 ms 20 runs 94 | > hyperfine -m 20 --warmup 5 "poetry run doit list" 95 | Benchmark 1: poetry run doit list 96 | Time (mean ± σ): 1.002 s ± 0.015 s [User: 1.643 s, System: 1.682 s] 97 | Range (min … max): 0.974 s … 1.023 s 20 runs 98 | ``` 99 | 100 | Additionally, the major decrease in dependencies will make install and update actions much faster. With the recommended extras installed, `calcipy-v1` has 124 dependencies (with all extras, 164) vs. `calcipy-v0`'s 259. Counted with: `cat .calcipy_packaging.lock | jq 'keys' | wc -l` 101 | 102 | ### Code Comparison 103 | 104 | Accounting for code extracted to `corallium`, the overall number of lines decreased from 1772 to 1550 or only 12%, while increasing the CLI and `pre-commit` capabilities. 105 | 106 | ```sh 107 | ~/calcipy-v0 > cloc calcipy 108 | ------------------------------------------------------------------------------- 109 | Language files blank comment code 110 | ------------------------------------------------------------------------------- 111 | Python 26 942 1075 1772 112 | ------------------------------------------------------------------------------- 113 | SUM: 26 942 1075 1772 114 | ------------------------------------------------------------------------------- 115 | ~/calcipy > cloc calcipy 116 | ------------------------------------------------------------------------------- 117 | Language files blank comment code 118 | ------------------------------------------------------------------------------- 119 | Python 27 454 438 1185 120 | ------------------------------------------------------------------------------- 121 | SUM: 27 454 438 1185 122 | ------------------------------------------------------------------------------- 123 | ~/corallium > cloc corallium 124 | ------------------------------------------------------------------------------- 125 | Language files blank comment code 126 | ------------------------------------------------------------------------------- 127 | Python 7 176 149 365 128 | ------------------------------------------------------------------------------- 129 | SUM: 7 176 149 365 130 | ------------------------------------------------------------------------------- 131 | 132 | ~/calcipy > cloc tests 133 | ------------------------------------------------------------------------------- 134 | Language files blank comment code 135 | ------------------------------------------------------------------------------- 136 | YAML 2 0 0 580 137 | Python 19 176 68 578 138 | JSON 2 0 0 60 139 | Markdown 3 9 10 8 140 | Text 1 0 0 2 141 | ------------------------------------------------------------------------------- 142 | SUM: 27 185 78 1228 143 | ------------------------------------------------------------------------------- 144 | ~/calcipy-v0 > cloc tests 145 | ------------------------------------------------------------------------------- 146 | Language files blank comment code 147 | ------------------------------------------------------------------------------- 148 | JSON 30 0 0 762 149 | YAML 2 0 0 580 150 | Python 24 314 186 578 151 | Markdown 3 9 10 8 152 | ------------------------------------------------------------------------------- 153 | SUM: 59 323 196 1928 154 | ------------------------------------------------------------------------------- 155 | ~/corallium > cloc tests 156 | ------------------------------------------------------------------------------- 157 | Language files blank comment code 158 | ------------------------------------------------------------------------------- 159 | Python 6 36 15 69 160 | Markdown 1 1 0 2 161 | ------------------------------------------------------------------------------- 162 | SUM: 7 37 15 71 163 | ------------------------------------------------------------------------------- 164 | ``` 165 | 166 | ### doit output 167 | 168 | I would like to restore the `doit` task summary, but `invoke`'s architecture doesn't really make this possible. The `--continue` option was extremely useful, but that also might not be achievable. 169 | 170 | ```sh 171 | > poetry run doit run 172 | . format_recipes > [ 173 | Python: function format_recipes 174 | ] 175 | 176 | 2023-02-19 10:40:23.954 | INFO | recipes.formatter:_write_toc:287 - Creating TOC for: ./recipes/docs/breakfast 177 | 2023-02-19 10:40:23.957 | INFO | recipes.formatter:_write_toc:287 - Creating TOC for: ./recipes/docs/rice 178 | 2023-02-19 10:40:23.959 | INFO | recipes.formatter:_write_toc:287 - Creating TOC for: ./recipes/docs/meals 179 | 2023-02-19 10:40:23.964 | INFO | recipes.formatter:_write_toc:287 - Creating TOC for: ./recipes/docs/seafood 180 | 2023-02-19 10:40:23.967 | INFO | recipes.formatter:_write_toc:287 - Creating TOC for: ./recipes/docs/pizza 181 | 2023-02-19 10:40:23.969 | INFO | recipes.formatter:_write_toc:287 - Creating TOC for: ./recipes/docs/poultry 182 | 2023-02-19 10:40:23.972 | INFO | recipes.formatter:_write_toc:287 - Creating TOC for: ./recipes/docs/sushi 183 | . collect_code_tags > [ 184 | Python: function write_code_tag_file 185 | ] 186 | 187 | . cl_write > [ 188 | Cmd: poetry run cz changelog 189 | Python: function _move_cl 190 | ] 191 | 192 | . lock > [ 193 | Cmd: poetry lock --no-update 194 | ] 195 | 196 | Resolving dependencies... 197 | . nox_coverage > [ 198 | Cmd: poetry run nox --error-on-missing-interpreters --session coverage 199 | ] 200 | 201 | ... 202 | 203 | doit> Summary: 204 | doit> format_recipes was successful 205 | doit> collect_code_tags was successful 206 | doit> cl_write was successful 207 | doit> lock was successful 208 | doit> nox_coverage was successful 209 | doit> auto_format was successful 210 | doit> document was successful 211 | doit> check_for_stale_packages was successful 212 | doit> pre_commit_hooks failed (red) 213 | doit> lint_project was not run 214 | doit> static_checks was not run 215 | doit> security_checks was not run 216 | doit> check_types was not run 217 | ``` 218 | --------------------------------------------------------------------------------