diff --git a/.coveragerc b/.coveragerc index a920d99..7235976 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1,13 +1,28 @@ +# .coveragerc to control coverage.py [run] +branch = True source = src omit = tests/* +[paths] +source = + src/ + */site-packages/ + [report] +# Regexes for lines to exclude from consideration exclude_lines = + # Have to re-enable the standard pragma pragma: no cover + + # Don't complain about missing debug-only code: def __repr__ - if self.debug: + if self\.debug + + # Don't complain if tests don't hit defensive assertion code: raise AssertionError raise NotImplementedError + + # Don't complain if non-runnable code isn't run: if 0: if __name__ == .__main__.: diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index 3a661e2..ccc7db7 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -9,12 +9,7 @@ jobs: name: Build and Test runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - - name: Set up uv - uses: astral-sh/setup-uv@v5 - with: - enable-cache: true + - uses: actions/checkout@v7 - name: Set up Python 3.12 uses: actions/setup-python@v5 @@ -22,7 +17,7 @@ jobs: python-version: 3.12 - name: Install tox - run: uv tool install tox --with tox-uv + run: python -m pip install tox - name: Test run: tox -e default @@ -40,12 +35,7 @@ jobs: name: Build Documentation runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - - name: Set up uv - uses: astral-sh/setup-uv@v5 - with: - enable-cache: true + - uses: actions/checkout@v7 - name: Set up Python 3.12 uses: actions/setup-python@v5 @@ -53,7 +43,7 @@ jobs: python-version: 3.12 - name: Install tox - run: uv tool install tox --with tox-uv + run: python -m pip install tox - name: Build docs run: tox -e docs @@ -62,7 +52,7 @@ jobs: run: touch ./docs/_build/html/.nojekyll - name: Upload Pages artifact - uses: actions/upload-pages-artifact@v3 + uses: actions/upload-pages-artifact@v5 with: path: ./docs/_build/html @@ -77,7 +67,7 @@ jobs: id-token: write # IMPORTANT: mandatory for trusted publishing steps: - name: Download all the dists - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: name: python-package-distributions path: dist/ @@ -98,4 +88,4 @@ jobs: steps: - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@v4 + uses: actions/deploy-pages@v5 diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 3350dd1..25083d6 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -36,12 +36,7 @@ jobs: runs-on: ${{ matrix.platform }} name: Python ${{ matrix.python }}, ${{ matrix.platform }} steps: - - uses: actions/checkout@v4 - - - name: Set up uv - uses: astral-sh/setup-uv@v5 - with: - enable-cache: true + - uses: actions/checkout@v7 - name: Set up Python uses: actions/setup-python@v5 @@ -49,14 +44,12 @@ jobs: python-version: ${{ matrix.python }} - name: Install tox - run: uv tool install tox --with tox-uv - - - name: Install dependencies - run: uv sync --all-extras --python ${{ matrix.python }} + run: python -m pip install tox coverage - name: Run tests - run: | - tox -e default -- --cov --cov-branch --cov-report=xml + run: >- + tox + -- -rFEx --durations 10 --color yes --cov --cov-branch --cov-report=xml # pytest args - name: Check for codecov token availability id: codecov-check @@ -71,7 +64,7 @@ jobs: - name: Upload coverage reports to Codecov with GitHub Action uses: codecov/codecov-action@v7 if: ${{ steps.codecov-check.outputs.codecov == 'true' }} - env: - CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + with: + token: ${{ secrets.CODECOV_TOKEN }} slug: ${{ github.repository }} flags: ${{ matrix.platform }} - py${{ matrix.python }} diff --git a/README.md b/README.md index 4080557..a5515d8 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,12 @@ To create a brand new repository, you can use the `hatchit` CLI command: hatchit my-awesome-package --description "A robust new tool" --license MIT ``` +By default, `hatchit` builds standard `tox` environments with pip installation. If you want to use the blazing fast `uv` ecosystem instead, just add `--use-uv`: + +```bash +hatchit my-awesome-package --description "A robust new tool" --license MIT --use-uv +``` + Alternatively, since `hatchit` is a 100% compatible Copier template, you can use `copier` directly to render the project! ```bash @@ -50,13 +56,15 @@ That's it! You're ready to start writing code. ## Development Process -`hatchit` structures your project around `tox` and `uv`. To run your tests during development: +`hatchit` structures your project around `tox` for automated testing. To run your tests during development: ```bash cd my-awesome-package tox -e default ``` +If you configured the project with `--use-uv`, tox will automatically use `uv` under the hood! + To build your documentation locally and preview: ```bash tox -e docs diff --git a/add_biocframe_test.py b/add_biocframe_test.py new file mode 100644 index 0000000..6ef69f1 --- /dev/null +++ b/add_biocframe_test.py @@ -0,0 +1,11 @@ +import re +from pathlib import Path + +pyproj = Path("/home/jayaram/Projects/biocpy/biocutils/pyproject.toml") +with open(pyproj, "r") as f: + content = f.read() + +content = content.replace('"pandas>=3.0.5",\n', '"pandas>=3.0.5",\n "biocframe",\n "iranges",\n') + +with open(pyproj, "w") as f: + f.write(content) diff --git a/check_missing_testing.py b/check_missing_testing.py new file mode 100644 index 0000000..742dfae --- /dev/null +++ b/check_missing_testing.py @@ -0,0 +1,24 @@ +import os +from pathlib import Path + +projects_dir = Path("/home/jayaram/Projects") +missing_testing = [] + +for pyproj in projects_dir.rglob("pyproject.toml"): + if ".venv" in str(pyproj) or ".tox" in str(pyproj) or "trash" in str(pyproj): + continue + + with open(pyproj, 'r') as f: + content = f.read() + + # only care if we just migrated it - we can tell if it has tox.ini with `extras = testing` + tox_file = pyproj.parent / "tox.ini" + if tox_file.exists(): + with open(tox_file, 'r') as tf: + tox_content = tf.read() + if "extras = testing" in tox_content: + if "\ntesting =" not in content and "\ntesting=" not in content: + missing_testing.append(str(pyproj)) + +for p in sorted(missing_testing): + print(p) diff --git a/check_uv.py b/check_uv.py new file mode 100644 index 0000000..9c2a3ff --- /dev/null +++ b/check_uv.py @@ -0,0 +1,23 @@ +import os +import glob +from pathlib import Path + +projects_dir = Path("/home/jayaram/Projects") +uv_projects = [] + +for pyproj in projects_dir.rglob("pyproject.toml"): + if ".venv" in str(pyproj) or ".tox" in str(pyproj): + continue + + project_root = pyproj.parent + workflows_dir = project_root / ".github" / "workflows" + run_tests = workflows_dir / "run-tests.yml" + + if run_tests.exists(): + with open(run_tests, 'r') as f: + if 'setup-uv' in f.read(): + uv_projects.append(str(project_root)) + +print("Projects using uv:") +for p in sorted(uv_projects): + print(p) diff --git a/copier.yml b/copier.yml index 405369e..d47cceb 100644 --- a/copier.yml +++ b/copier.yml @@ -39,10 +39,14 @@ github_username: type: str default: "YOUR_ORG_OR_USERNAME" help: GitHub Organization or Username +use_uv: + type: bool + default: false + help: "Use uv for dependency management and tox running?" # Tasks to run after generation _tasks: - - "uv lock" + - "{% if use_uv %}uv lock{% else %}echo Skip uv lock{% endif %}" - "git init" - "git add ." # Use ignore_errors because git commit might fail if git user isn't configured, or we can just run it. diff --git a/docs/requirements.txt b/docs/requirements.txt new file mode 100644 index 0000000..d097ae7 --- /dev/null +++ b/docs/requirements.txt @@ -0,0 +1,5 @@ +sphinx>=7.0.0 +furo +myst-nb +sphinx-autodoc-typehints +linkify-it-py diff --git a/fix_biocutils.py b/fix_biocutils.py new file mode 100644 index 0000000..297c396 --- /dev/null +++ b/fix_biocutils.py @@ -0,0 +1,95 @@ +import re +from pathlib import Path + +repo = Path("/home/jayaram/Projects/biocpy/biocutils") + +# 1. Fix pyproject.toml +pyproj = repo / "pyproject.toml" +with open(pyproj, "r") as f: + content = f.read() + +# Remove the duplicate dev group +content = re.sub(r'\[project\.optional-dependencies\]\ndev = \[\n( ".*?",\n)*\]\n?', '', content) +with open(pyproj, "w") as f: + f.write(content.strip() + "\n") + + +# 2. Fix tox.ini +tox_ini = repo / "tox.ini" +tox_content = """# Tox configuration file +# Read more under https://tox.wiki/ + +[tox] +minversion = 4.0 +envlist = default + +[testenv] +description = Invoke pytest to run automated tests +extras = testing +commands = + pytest {posargs} + +[testenv:typecheck] +description = Run static type checking with mypy +deps = mypy +commands = + mypy src/ + +[testenv:lint] +description = Perform static analysis and style checks +deps = ruff +skip_install = True +commands = + ruff check {posargs:.} + ruff format --check {posargs:.} + +[testenv:{build,clean}] +description = + build: Build the package + clean: Remove old distribution files +deps = build +skip_install = True +commands = + clean: python -c 'import shutil; [shutil.rmtree(p, True) for p in ("build", "dist", "docs/_build")]' + clean: python -c 'import pathlib, shutil; [shutil.rmtree(p, True) for p in pathlib.Path("src").glob("*.egg-info")]' + build: python -m build {posargs} + +[testenv:{docs,doctests,linkcheck}] +description = + docs: Invoke sphinx-build to build the docs + doctests: Invoke sphinx-build to run doctests + linkcheck: Check for broken links in the documentation +deps = + -r {toxinidir}/docs/requirements.txt +setenv = + DOCSDIR = {toxinidir}/docs + BUILDDIR = {toxinidir}/docs/_build + docs: BUILD = html + doctests: BUILD = doctest + linkcheck: BUILD = linkcheck +commands = + sphinx-apidoc -f -o "{env:DOCSDIR}/api" src/ + sphinx-build --color -b {env:BUILD} -d "{env:BUILDDIR}/doctrees" "{env:DOCSDIR}" "{env:BUILDDIR}/{env:BUILD}" {posargs} + +[testenv:publish] +description = + Publish the package you have been developing to a package index server. +skip_install = True +deps = twine +commands = + python -m twine upload {posargs:dist/*} +""" +with open(tox_ini, "w") as f: + f.write(tox_content) + +# 3. Fix run-tests.yml +run_tests = repo / ".github" / "workflows" / "run-tests.yml" +with open(run_tests, "r") as f: + content = f.read() + +content = re.sub(r'\n\s*- name: Set up uv\n\s*uses: astral-sh/setup-uv@v5\n\s*with:\n\s*enable-cache: true\n', '', content) +content = content.replace("uv tool install tox --with tox-uv", "python -m pip install tox") +content = re.sub(r'\n\s*- name: Install dependencies\n\s*run: uv sync --all-extras --python \$\{\{ matrix\.python \}\}\n', '', content) +with open(run_tests, "w") as f: + f.write(content) + diff --git a/fix_hatchit.py b/fix_hatchit.py new file mode 100644 index 0000000..ad7b702 --- /dev/null +++ b/fix_hatchit.py @@ -0,0 +1,11 @@ +import sys +sys.path.append("/home/jayaram/.gemini/antigravity-ide/brain/9bc824b7-d246-454a-b7c0-32f2b65d7d0d/scratch") +import migrate_to_pip +from pathlib import Path + +root = Path("/home/jayaram/Projects/biocpy/hatchit") +migrate_to_pip.process_tox_ini(root / "tox.ini") +migrate_to_pip.process_pyproject_toml(root / "pyproject.toml") +migrate_to_pip.process_github_action(root / ".github" / "workflows" / "run-tests.yml") +migrate_to_pip.process_github_action(root / ".github" / "workflows" / "publish-pypi.yml") +migrate_to_pip.process_readthedocs(root / ".readthedocs.yml") diff --git a/fix_hatchit2.py b/fix_hatchit2.py new file mode 100644 index 0000000..271d4a5 --- /dev/null +++ b/fix_hatchit2.py @@ -0,0 +1,12 @@ +import sys +from pathlib import Path + +# Load functions from our existing scripts +import imp +fix_tox_ini = imp.load_source('fix_tox_ini', 'fix_tox_ini.py') +fix_tox_skip_install = imp.load_source('fix_tox_skip_install', 'fix_tox_skip_install.py') + +root = Path("/home/jayaram/Projects/biocpy/hatchit") +fix_tox_ini.fix_tox(root / "tox.ini") +fix_tox_skip_install.fix_tox(root / "tox.ini") +fix_tox_skip_install.fix_pyproject(root / "pyproject.toml") diff --git a/fix_missing_testing.py b/fix_missing_testing.py new file mode 100644 index 0000000..123ceb8 --- /dev/null +++ b/fix_missing_testing.py @@ -0,0 +1,18 @@ +from pathlib import Path + +paths = [ + Path("/home/jayaram/Projects/biocpy/biostrings/pyproject.toml"), + Path("/home/jayaram/Projects/biocpy/iranges/pyproject.toml") +] + +for p in paths: + if not p.exists(): continue + with open(p, 'r') as f: + content = f.read() + + if "testing = [" not in content: + content = content.replace("[project.optional-dependencies]", + "[project.optional-dependencies]\ntesting = [\n \"pytest>=9.1.1\",\n \"pytest-cov>=7.1.0\",\n]\n") + with open(p, 'w') as f: + f.write(content) + diff --git a/fix_pyproject_metadata.py b/fix_pyproject_metadata.py new file mode 100644 index 0000000..eda34f8 --- /dev/null +++ b/fix_pyproject_metadata.py @@ -0,0 +1,102 @@ +import os +import tomllib +from pathlib import Path +import re + +def process_pyproject(path): + with open(path, 'r') as f: + content = f.read() + + try: + data = tomllib.loads(content) + except Exception as e: + print(f"Failed to parse TOML {path}: {e}") + return + + project_data = data.get('project', {}) + org_name = path.parent.parent.name + repo_name = path.parent.name + + # Capitalization logic for GitHub URL conventions + if org_name == "biocpy": + org_url = "BiocPy" + else: + org_url = org_name + + # 1. Keywords + if "keywords" not in project_data: + # insert after requires-python + keyword_str = 'keywords = [\n "bioinformatics",\n]\n' + content = re.sub(r'(requires-python = ".*?"\n)', r'\1' + keyword_str, content) + + # 2. Classifiers + if "classifiers" not in project_data: + classifier_str = """classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Science/Research", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Topic :: Scientific/Engineering", + "Topic :: Scientific/Engineering :: Bio-Informatics", + "Typing :: Typed", +] +""" + # insert after requires-python or keywords + if "keywords = [" in content: + # find end of keywords block + content = re.sub(r'(keywords = \[.*?\]\n)', r'\1' + classifier_str, content, flags=re.DOTALL) + else: + content = re.sub(r'(requires-python = ".*?"\n)', r'\1' + classifier_str, content) + + # 3. License + if "license" not in project_data: + # inject [project.license] block before [project.optional-dependencies] or [build-system] + license_str = '\n[project.license]\nfile = "LICENSE.txt"\n' + if "[project.urls]" in content: + content = content.replace("[project.urls]", license_str + "\n[project.urls]") + elif "[project.optional-dependencies]" in content: + content = content.replace("[project.optional-dependencies]", license_str + "\n[project.optional-dependencies]") + else: + content = content.replace("[build-system]", license_str + "\n[build-system]") + + # 4. URLs + if "urls" not in project_data: + urls_str = f"""\n[project.urls] +Homepage = "https://github.com/{org_url}/{repo_name}" +Documentation = "https://{org_url.lower()}.github.io/{repo_name}/" +Source = "https://github.com/{org_url}/{repo_name}" +"Bug Tracker" = "https://github.com/{org_url}/{repo_name}/issues" +""" + if "[project.optional-dependencies]" in content: + content = content.replace("[project.optional-dependencies]", urls_str + "\n[project.optional-dependencies]") + else: + content = content.replace("[build-system]", urls_str + "\n[build-system]") + + with open(path, 'w') as f: + f.write(content) + + +def main(): + projects_dir = Path("/home/jayaram/Projects") + for pyproj in projects_dir.rglob("pyproject.toml"): + if ".venv" in str(pyproj) or ".tox" in str(pyproj) or "trash" in str(pyproj): + continue + + # Ensure it has a standard build system to avoid touching random pyprojects + with open(pyproj, 'r') as f: + if "hatchling" not in f.read() and "setuptools" not in f.read(): + continue + + print(f"Fixing metadata for {pyproj}") + process_pyproject(pyproj) + +if __name__ == "__main__": + main() diff --git a/fix_toml_duplicate.py b/fix_toml_duplicate.py new file mode 100644 index 0000000..d5ef7a6 --- /dev/null +++ b/fix_toml_duplicate.py @@ -0,0 +1,50 @@ +import os +import glob +from pathlib import Path +import re + +def fix_pyproject_toml(path): + if not path.exists(): return + with open(path, 'r') as f: + content = f.read() + + # We replaced [dependency-groups] with [project.optional-dependencies] + # But if it had [project.optional-dependencies] earlier, it's invalid. + + # Let's check if there are two [project.optional-dependencies] + if content.count("[project.optional-dependencies]") > 1: + # We need to extract the second block (which was dev = [...]) + # and move it to the first block. + + # 1. Find the second block + # It's at the end of the file in our case, but let's be robust + parts = content.split("[project.optional-dependencies]") + + # parts[0] is everything before first block + # parts[1] is the first block + # parts[2] is the second block (dev) + + # We want to append parts[2] to parts[1] + + new_content = parts[0] + "[project.optional-dependencies]" + parts[1].rstrip() + "\n" + parts[2].lstrip() + + with open(path, 'w') as f: + f.write(new_content) + +def main(): + projects_dir = Path("/home/jayaram/Projects") + for pyproj in projects_dir.rglob("pyproject.toml"): + if ".venv" in str(pyproj) or ".tox" in str(pyproj) or "trash" in str(pyproj): + continue + + try: + fix_pyproject_toml(pyproj) + # test validity + import tomllib + with open(pyproj, 'rb') as f: + tomllib.load(f) + except Exception as e: + print(f"Failed on {pyproj}: {e}") + +if __name__ == "__main__": + main() diff --git a/fix_tox_docs.py b/fix_tox_docs.py new file mode 100644 index 0000000..17ae302 --- /dev/null +++ b/fix_tox_docs.py @@ -0,0 +1,69 @@ +import os +from pathlib import Path +import re + +def create_docs_requirements(repo_root): + docs_dir = repo_root / "docs" + docs_dir.mkdir(exist_ok=True) + req_file = docs_dir / "requirements.txt" + if not req_file.exists(): + req_file.write_text("sphinx>=7.0.0\nfuro\nmyst-nb\nsphinx-autodoc-typehints\nlinkify-it-py\n") + +def fix_tox(path): + if not path.exists(): return + with open(path, 'r') as f: + content = f.read() + + # split by testenv blocks + blocks = re.split(r'(\[testenv\]|\[testenv:.*?\])', content) + + for i in range(1, len(blocks), 2): + header = blocks[i] + body = blocks[i+1] + + if header == "[testenv:typecheck]": + body = body.replace("skip_install = True\n", "") + if "extras = dev\n" in body: + body = body.replace("extras = dev\n", "deps = mypy\n") + elif "extras = testing\n" in body: + body = body.replace("extras = testing\n", "deps = mypy\n") + elif "deps = mypy" not in body: + # Add deps = mypy after the description or at the beginning + if "description =" in body: + body = body.replace("description =", "deps = mypy\ndescription =", 1) + else: + body = "deps = mypy\n" + body + + elif "docs" in header and "linkcheck" in header: # [testenv:{docs,doctests,linkcheck}] + body = body.replace("skip_install = True\n", "") + if "extras = dev\n" in body: + body = body.replace("extras = dev\n", "") + elif "extras = testing\n" in body: + body = body.replace("extras = testing\n", "") + + if "deps =" not in body: + deps_str = "deps =\n -r {toxinidir}/docs/requirements.txt\n" + if "setenv =" in body: + body = body.replace("setenv =", deps_str + "setenv =", 1) + else: + body = deps_str + body + + # create the requirements.txt file for this repo if it's missing + repo_root = path.parent + create_docs_requirements(repo_root) + + blocks[i+1] = body + + new_content = "".join(blocks) + with open(path, 'w') as f: + f.write(new_content) + +def main(): + projects_dir = Path("/home/jayaram/Projects") + for tox in projects_dir.rglob("tox.ini"): + if ".venv" in str(tox) or ".tox" in str(tox) or "trash" in str(tox): + continue + fix_tox(tox) + +if __name__ == "__main__": + main() diff --git a/fix_tox_ini.py b/fix_tox_ini.py new file mode 100644 index 0000000..736b304 --- /dev/null +++ b/fix_tox_ini.py @@ -0,0 +1,51 @@ +import os +import glob +from pathlib import Path +import re + +def fix_tox(path): + if not path.exists(): return + with open(path, 'r') as f: + content = f.read() + + # split by testenv blocks + blocks = re.split(r'(\[testenv:.*?\])', content) + # blocks[0] is everything before the first [testenv:...] + # blocks[1] is [testenv:...] + # blocks[2] is the content of that block, etc. + + for i in range(1, len(blocks), 2): + header = blocks[i] + body = blocks[i+1] + + # Remove deps = twine from non-publish blocks + if "publish" not in header: + body = body.replace("deps = twine\n", "") + + # Also clean up any potential duplicate deps in publish itself? + # A block might have two "deps = twine". + # We can just deduplicate lines starting with deps = + lines = body.split('\n') + new_lines = [] + seen_deps = set() + for line in lines: + if line.startswith("deps ="): + if line in seen_deps: + continue + seen_deps.add(line) + new_lines.append(line) + blocks[i+1] = '\n'.join(new_lines) + + new_content = "".join(blocks) + with open(path, 'w') as f: + f.write(new_content) + +def main(): + projects_dir = Path("/home/jayaram/Projects") + for tox in projects_dir.rglob("tox.ini"): + if ".venv" in str(tox) or ".tox" in str(tox) or "trash" in str(tox): + continue + fix_tox(tox) + +if __name__ == "__main__": + main() diff --git a/fix_tox_skip_install.py b/fix_tox_skip_install.py new file mode 100644 index 0000000..a1d06e5 --- /dev/null +++ b/fix_tox_skip_install.py @@ -0,0 +1,77 @@ +import os +import glob +from pathlib import Path +import re + +def fix_tox(path): + if not path.exists(): return + with open(path, 'r') as f: + content = f.read() + + # split by testenv blocks + blocks = re.split(r'(\[testenv\]|\[testenv:.*?\])', content) + + for i in range(1, len(blocks), 2): + header = blocks[i] + body = blocks[i+1] + + if header == "[testenv]": + # Remove skip_install = True + body = body.replace("skip_install = True\n", "") + # Change extras = dev to extras = testing + body = body.replace("extras = dev", "extras = testing") + + blocks[i+1] = body + + new_content = "".join(blocks) + with open(path, 'w') as f: + f.write(new_content) + +def fix_pyproject(path): + if not path.exists(): return + with open(path, 'r') as f: + content = f.read() + + # We want to remove the 'dev = [...]' block inside project.optional-dependencies + # if it's at the end of the file. + # Or actually, we can just remove it using regex since it looks like: + # dev = [ + # ... + # ] + + # A safer way is to just find [project.optional-dependencies]\ndev = [ ... ] + # and remove it if it exists. + # It might be just `dev = [ ... ]` at the end of the file if we merged it earlier. + + # We can just read the lines, and if we encounter `dev = [`, we delete until `]` + lines = content.split('\n') + new_lines = [] + in_dev = False + + for line in lines: + if line.startswith("dev = ["): + in_dev = True + continue + if in_dev: + if line.strip() == "]": + in_dev = False + continue + new_lines.append(line) + + with open(path, 'w') as f: + f.write('\n'.join(new_lines)) + +def main(): + projects_dir = Path("/home/jayaram/Projects") + for tox in projects_dir.rglob("tox.ini"): + if ".venv" in str(tox) or ".tox" in str(tox) or "trash" in str(tox): + continue + fix_tox(tox) + + for pyproj in projects_dir.rglob("pyproject.toml"): + if ".venv" in str(pyproj) or ".tox" in str(pyproj) or "trash" in str(pyproj): + continue + fix_pyproject(pyproj) + +if __name__ == "__main__": + main() diff --git a/fix_uv_run_leftovers.py b/fix_uv_run_leftovers.py new file mode 100644 index 0000000..543b58c --- /dev/null +++ b/fix_uv_run_leftovers.py @@ -0,0 +1,18 @@ +from pathlib import Path + +projects_dir = Path("/home/jayaram/Projects") +for tox in projects_dir.rglob("tox.ini"): + if ".venv" in str(tox) or ".tox" in str(tox) or "trash" in str(tox): + continue + + with open(tox, 'r') as f: + content = f.read() + + original = content + content = content.replace("uv run --all-extras ", "") + content = content.replace("uv run ", "") + + if content != original: + with open(tox, 'w') as f: + f.write(content) + diff --git a/fix_yaml_jinja.py b/fix_yaml_jinja.py new file mode 100644 index 0000000..fab1f05 --- /dev/null +++ b/fix_yaml_jinja.py @@ -0,0 +1,16 @@ +import re +import glob +import os + +files = glob.glob("template/.github/workflows/*.jinja") +for f in files: + with open(f, 'r') as file: + content = file.read() + + # We want to replace ${{ ... }} with {% raw %}${{ ... }}{% endraw %} + # But only if it's not already wrapped. + content = re.sub(r'(?=9.1.1", + "pytest-cov>=7.1.0", ] + +[tool.bandit] +exclude_dirs = ["tests"] +skips = ["B110"] diff --git a/src/hatchit/cli.py b/src/hatchit/cli.py index 547e992..9c4ae08 100644 --- a/src/hatchit/cli.py +++ b/src/hatchit/cli.py @@ -14,6 +14,7 @@ def main(): parser.add_argument("project_path", help="Path where the new project should be created.") parser.add_argument("--description", default="Add a short description here!", help="Optional project description.") parser.add_argument("--license", default="MIT", help="License to use. Defaults to MIT.") + parser.add_argument("--use-uv", action="store_true", help="Use uv for dependency management and tox running.") args = parser.parse_args() @@ -24,7 +25,7 @@ def main(): sys.exit(1) print(f"Hatching project at {project_path}...") - create_hatchit_repository(project_path=project_path, description=args.description, license=args.license) + create_hatchit_repository(project_path=project_path, description=args.description, license=args.license, use_uv=args.use_uv) print("Success! 🚀 💥") diff --git a/src/hatchit/scaffold.py b/src/hatchit/scaffold.py index 3c858fa..6dca8ef 100644 --- a/src/hatchit/scaffold.py +++ b/src/hatchit/scaffold.py @@ -8,7 +8,7 @@ __license__ = "MIT" -def create_hatchit_repository(project_path: str, description: str, license: str) -> None: +def create_hatchit_repository(project_path: str, description: str, license: str, use_uv: bool = False) -> None: """Create a new Python project using uv and hatch. This function initializes a new Python project at the specified `project_path` @@ -25,6 +25,9 @@ def create_hatchit_repository(project_path: str, description: str, license: str) license: The license to use for the project. + use_uv: + Whether to use uv for dependency management. Defaults to False. + Example: >>> create_hatchit_repository( ... "my_project", @@ -59,6 +62,7 @@ def create_hatchit_repository(project_path: str, description: str, license: str) "author_name": git_name, "author_email": git_email, "github_username": github_username, + "use_uv": use_uv, } template_dir = Path(__file__).parent.parent.parent diff --git a/template/.coveragerc b/template/.coveragerc index a920d99..7235976 100644 --- a/template/.coveragerc +++ b/template/.coveragerc @@ -1,13 +1,28 @@ +# .coveragerc to control coverage.py [run] +branch = True source = src omit = tests/* +[paths] +source = + src/ + */site-packages/ + [report] +# Regexes for lines to exclude from consideration exclude_lines = + # Have to re-enable the standard pragma pragma: no cover + + # Don't complain about missing debug-only code: def __repr__ - if self.debug: + if self\.debug + + # Don't complain if tests don't hit defensive assertion code: raise AssertionError raise NotImplementedError + + # Don't complain if non-runnable code isn't run: if 0: if __name__ == .__main__.: diff --git a/template/.github/workflows/pre-commit.yml b/template/.github/workflows/pre-commit.yml index d638c47..e940068 100644 --- a/template/.github/workflows/pre-commit.yml +++ b/template/.github/workflows/pre-commit.yml @@ -9,7 +9,7 @@ jobs: pre-commit: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - uses: actions/setup-python@v5 with: python-version: '3.12' diff --git a/template/.github/workflows/publish-pypi.yml b/template/.github/workflows/publish-pypi.yml.jinja similarity index 79% rename from template/.github/workflows/publish-pypi.yml rename to template/.github/workflows/publish-pypi.yml.jinja index 68e2196..674efa3 100644 --- a/template/.github/workflows/publish-pypi.yml +++ b/template/.github/workflows/publish-pypi.yml.jinja @@ -9,20 +9,27 @@ jobs: name: Build and Test runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 +{% if use_uv %} - name: Set up uv uses: astral-sh/setup-uv@v5 with: enable-cache: true +{% endif %} - name: Set up Python 3.12 uses: actions/setup-python@v5 with: python-version: 3.12 +{% if use_uv %} - name: Install tox run: uv tool install tox --with tox-uv +{% else %} + - name: Install tox + run: python -m pip install tox +{% endif %} - name: Test run: tox -e default @@ -40,20 +47,27 @@ jobs: name: Build Documentation runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 +{% if use_uv %} - name: Set up uv uses: astral-sh/setup-uv@v5 with: enable-cache: true +{% endif %} - name: Set up Python 3.12 uses: actions/setup-python@v5 with: python-version: 3.12 +{% if use_uv %} - name: Install tox run: uv tool install tox --with tox-uv +{% else %} + - name: Install tox + run: python -m pip install tox +{% endif %} - name: Build docs run: tox -e docs @@ -62,7 +76,7 @@ jobs: run: touch ./docs/_build/html/.nojekyll - name: Upload Pages artifact - uses: actions/upload-pages-artifact@v3 + uses: actions/upload-pages-artifact@v5 with: path: ./docs/_build/html @@ -77,7 +91,7 @@ jobs: id-token: write # IMPORTANT: mandatory for trusted publishing steps: - name: Download all the dists - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: name: python-package-distributions path: dist/ @@ -94,8 +108,8 @@ jobs: id-token: write environment: name: github-pages - url: ${{ steps.deployment.outputs.page_url }} + url: {% raw %}${{ steps.deployment.outputs.page_url }}{% endraw %} steps: - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@v4 + uses: actions/deploy-pages@v5 diff --git a/template/.github/workflows/run-tests.yml b/template/.github/workflows/run-tests.yml.jinja similarity index 51% rename from template/.github/workflows/run-tests.yml rename to template/.github/workflows/run-tests.yml.jinja index 662c96f..70e4cf4 100644 --- a/template/.github/workflows/run-tests.yml +++ b/template/.github/workflows/run-tests.yml.jinja @@ -20,8 +20,8 @@ permissions: concurrency: group: >- - ${{ github.workflow }}-${{ github.ref_type }}- - ${{ github.event.pull_request.number || github.sha }} + {% raw %}${{ github.workflow }}{% endraw %}-{% raw %}${{ github.ref_type }}{% endraw %}- + {% raw %}${{ github.event.pull_request.number || github.sha }}{% endraw %} cancel-in-progress: true jobs: @@ -33,45 +33,53 @@ jobs: - ubuntu-latest - macos-latest - windows-latest - runs-on: ${{ matrix.platform }} - name: Python ${{ matrix.python }}, ${{ matrix.platform }} + runs-on: {% raw %}${{ matrix.platform }}{% endraw %} + name: Python {% raw %}${{ matrix.python }}{% endraw %}, {% raw %}${{ matrix.platform }}{% endraw %} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 +{% if use_uv %} - name: Set up uv uses: astral-sh/setup-uv@v5 with: enable-cache: true +{% endif %} - name: Set up Python uses: actions/setup-python@v5 with: - python-version: ${{ matrix.python }} + python-version: {% raw %}${{ matrix.python }}{% endraw %} +{% if use_uv %} - name: Install tox run: uv tool install tox --with tox-uv - name: Install dependencies - run: uv sync --all-extras --python ${{ matrix.python }} + run: uv sync --all-extras --python {% raw %}${{ matrix.python }}{% endraw %} +{% else %} + - name: Install tox + run: python -m pip install tox coverage +{% endif %} - name: Run tests - run: | - tox -e default -- --cov --cov-branch --cov-report=xml + run: >- + tox + -- -rFEx --durations 10 --color yes --cov --cov-branch --cov-report=xml # pytest args - name: Check for codecov token availability id: codecov-check shell: bash run: | - if [ ${{ secrets.CODECOV_TOKEN }} != '' ]; then + if [ {% raw %}${{ secrets.CODECOV_TOKEN }}{% endraw %} != '' ]; then echo "codecov=true" >> $GITHUB_OUTPUT; else echo "codecov=false" >> $GITHUB_OUTPUT; fi - name: Upload coverage reports to Codecov with GitHub Action - uses: codecov/codecov-action@v5 - if: ${{ steps.codecov-check.outputs.codecov == 'true' }} - env: - CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} - slug: ${{ github.repository }} - flags: ${{ matrix.platform }} - py${{ matrix.python }} + uses: codecov/codecov-action@v7 + if: {% raw %}${{ steps.codecov-check.outputs.codecov == 'true' }}{% endraw %} + with: + token: {% raw %}${{ secrets.CODECOV_TOKEN }}{% endraw %} + slug: {% raw %}${{ github.repository }}{% endraw %} + flags: {% raw %}${{ matrix.platform }}{% endraw %} - py{% raw %}${{ matrix.python }}{% endraw %} diff --git a/template/.readthedocs.yml b/template/.readthedocs.yml.jinja similarity index 92% rename from template/.readthedocs.yml rename to template/.readthedocs.yml.jinja index da89049..e839c74 100644 --- a/template/.readthedocs.yml +++ b/template/.readthedocs.yml.jinja @@ -7,10 +7,12 @@ build: os: ubuntu-22.04 tools: python: "3.12" +{% if use_uv %} jobs: post_install: - curl -LsSf https://astral.sh/uv/install.sh | sh - uv sync +{% endif %} sphinx: configuration: docs/conf.py diff --git a/template/README.md b/template/README.md.jinja similarity index 93% rename from template/README.md rename to template/README.md.jinja index 7cd038b..efb6da3 100644 --- a/template/README.md +++ b/template/README.md.jinja @@ -20,4 +20,7 @@ pip install {{ project_name }} ## Note This project has been set up using [hatchit](https://github.com/biocpy/hatchit) +{%- if use_uv %} and [uv](https://docs.astral.sh/uv/). +{%- else %}. +{%- endif %} diff --git a/template/pyproject.toml.jinja b/template/pyproject.toml.jinja index 75225a7..abca07c 100644 --- a/template/pyproject.toml.jinja +++ b/template/pyproject.toml.jinja @@ -31,6 +31,9 @@ classifiers = [ "Programming Language :: Python :: 3.14", ] +[project.license] +file = "LICENSE.txt" + [project.urls] Homepage = "https://github.com/{{ github_username }}/{{ project_name }}" Documentation = "https://github.com/{{ github_username }}/{{ project_name }}" @@ -70,6 +73,7 @@ strict = true addopts = "--cov --cov-report term-missing" testpaths = ["tests"] +{% if use_uv %} [dependency-groups] dev = [ "pytest", @@ -81,3 +85,20 @@ dev = [ "linkify-it-py", "mypy" ] +{% else %} +[project.optional-dependencies] +dev = [ + "pytest", + "pytest-cov", + "sphinx", + "myst-nb", + "furo", + "sphinx-autodoc-typehints", + "linkify-it-py", + "mypy" +] +{% endif %} + +[tool.bandit] +exclude_dirs = ["tests"] +skips = ["B110"] diff --git a/template/tox.ini b/template/tox.ini.jinja similarity index 50% rename from template/tox.ini rename to template/tox.ini.jinja index d759a6a..d707e32 100644 --- a/template/tox.ini +++ b/template/tox.ini.jinja @@ -1,3 +1,4 @@ +{% if use_uv %} # Tox configuration file using uv as the backend runner # Read more under https://tox.wiki/ @@ -57,3 +58,67 @@ description = skip_install = True commands = uv publish {posargs:dist/*} +{% else %} +# Tox configuration file +# Read more under https://tox.wiki/ + +[tox] +minversion = 4.0 +envlist = default + +[testenv] +description = Invoke pytest to run automated tests +extras = testing +commands = + pytest {posargs} + +[testenv:typecheck] +description = Run static type checking with mypy +deps = mypy +commands = + mypy src/ + +[testenv:lint] +description = Perform static analysis and style checks +deps = ruff +skip_install = True +commands = + ruff check {posargs:.} + ruff format --check {posargs:.} + +[testenv:{build,clean}] +description = + build: Build the package + clean: Remove old distribution files +deps = build +skip_install = True +commands = + clean: python -c 'import shutil; [shutil.rmtree(p, True) for p in ("build", "dist", "docs/_build")]' + clean: python -c 'import pathlib, shutil; [shutil.rmtree(p, True) for p in pathlib.Path("src").glob("*.egg-info")]' + build: python -m build {posargs} + +[testenv:{docs,doctests,linkcheck}] +description = + docs: Invoke sphinx-build to build the docs + doctests: Invoke sphinx-build to run doctests + linkcheck: Check for broken links in the documentation +deps = + -r {toxinidir}/docs/requirements.txt +setenv = + DOCSDIR = {toxinidir}/docs + BUILDDIR = {toxinidir}/docs/_build + docs: BUILD = html + doctests: BUILD = doctest + linkcheck: BUILD = linkcheck +commands = + sphinx-apidoc -f -o "{env:DOCSDIR}/api" src/ + sphinx-build --color -b {env:BUILD} -d "{env:BUILDDIR}/doctrees" "{env:DOCSDIR}" "{env:BUILDDIR}/{env:BUILD}" {posargs} + +[testenv:publish] +description = + Publish the package you have been developing to a package index server. +skip_install = True +deps = twine +commands = + python -m twine upload {posargs:dist/*} +{% endif %} diff --git a/test_copier.py b/test_copier.py new file mode 100644 index 0000000..0c9fc50 --- /dev/null +++ b/test_copier.py @@ -0,0 +1,7 @@ +import copier +import tempfile +import os + +with tempfile.TemporaryDirectory() as temp_dir: + copier.run_copy(".", temp_dir, data={"project_name": "test", "description": "test", "license": "MIT", "use_uv": False}, vcs_ref="HEAD", unsafe=True, defaults=True) + print("Success") diff --git a/tests/test_cli.py b/tests/test_cli.py index 6ca78ed..34bd3fb 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -19,6 +19,20 @@ def test_cli_execution(tmp_path): assert (project_path / "pyproject.toml").exists() assert (project_path / "CITATION.cff").exists() + # Run the CLI with --use-uv + project_path_uv = tmp_path / "cli_project_uv" + result_uv = subprocess.run( + ["hatchit", str(project_path_uv), "--description", "Test CLI package", "--use-uv"], + capture_output=True, + text=True + ) + + assert result_uv.returncode == 0 + assert "Hatching project" in result_uv.stdout + assert (project_path_uv / "pyproject.toml").exists() + with open(project_path_uv / "pyproject.toml") as f: + assert "[dependency-groups]" in f.read() + # Test error on non-empty directory (project_path / "extra.txt").touch() result_fail = subprocess.run( diff --git a/tests/test_scaffold.py b/tests/test_scaffold.py index 979e045..6388706 100644 --- a/tests/test_scaffold.py +++ b/tests/test_scaffold.py @@ -1,7 +1,7 @@ from hatchit import create_hatchit_repository -def test_create_hatchit_repository(tmp_path): +def test_create_hatchit_repository_default(tmp_path): project_path = tmp_path / "my_test_project" create_hatchit_repository( @@ -19,7 +19,26 @@ def test_create_hatchit_repository(tmp_path): assert (project_path / "docs" / "conf.py").exists() assert (project_path / ".github" / "workflows" / "run-tests.yml").exists() - # Verify pyproject.toml name interpolation + # Verify pyproject.toml name interpolation and pip structure with open(project_path / "pyproject.toml") as f: content = f.read() assert "my_test_project" in content + assert "[project.optional-dependencies]" in content + assert "[dependency-groups]" not in content + +def test_create_hatchit_repository_with_uv(tmp_path): + project_path = tmp_path / "my_uv_project" + + create_hatchit_repository( + project_path=str(project_path), + description="A test project", + license="MIT", + use_uv=True + ) + + # Verify pyproject.toml name interpolation and uv structure + with open(project_path / "pyproject.toml") as f: + content = f.read() + assert "my_uv_project" in content + assert "[dependency-groups]" in content + assert "[project.optional-dependencies]" not in content diff --git a/tox.ini b/tox.ini index d759a6a..b08857b 100644 --- a/tox.ini +++ b/tox.ini @@ -7,53 +7,56 @@ envlist = default [testenv] description = Invoke pytest to run automated tests -allowlist_externals = uv -skip_install = True +extras = testing commands = - uv run --all-extras pytest {posargs} + pytest {posargs} [testenv:typecheck] +deps = mypy description = Run static type checking with mypy -skip_install = True commands = - uv run --all-extras mypy src/ + mypy src/ [testenv:lint] description = Perform static analysis and style checks +deps = ruff skip_install = True commands = - uv run ruff check {posargs:.} - uv run ruff format --check {posargs:.} + ruff check {posargs:.} + ruff format --check {posargs:.} [testenv:{build,clean}] description = build: Build the package clean: Remove old distribution files +deps = build skip_install = True commands = clean: python -c 'import shutil; [shutil.rmtree(p, True) for p in ("build", "dist", "docs/_build")]' clean: python -c 'import pathlib, shutil; [shutil.rmtree(p, True) for p in pathlib.Path("src").glob("*.egg-info")]' - build: uv build {posargs} + build: python -m build {posargs} [testenv:{docs,doctests,linkcheck}] description = docs: Invoke sphinx-build to build the docs doctests: Invoke sphinx-build to run doctests linkcheck: Check for broken links in the documentation +deps = + -r {toxinidir}/docs/requirements.txt setenv = DOCSDIR = {toxinidir}/docs BUILDDIR = {toxinidir}/docs/_build docs: BUILD = html doctests: BUILD = doctest linkcheck: BUILD = linkcheck -skip_install = True commands = - uv run sphinx-apidoc -f -o "{env:DOCSDIR}/api" src/ - uv run sphinx-build --color -b {env:BUILD} -d "{env:BUILDDIR}/doctrees" "{env:DOCSDIR}" "{env:BUILDDIR}/{env:BUILD}" {posargs} + sphinx-apidoc -f -o "{env:DOCSDIR}/api" src/ + sphinx-build --color -b {env:BUILD} -d "{env:BUILDDIR}/doctrees" "{env:DOCSDIR}" "{env:BUILDDIR}/{env:BUILD}" {posargs} [testenv:publish] description = Publish the package you have been developing to a package index server. skip_install = True +deps = twine commands = - uv publish {posargs:dist/*} + python -m twine upload {posargs:dist/*}