diff --git a/Makefile b/Makefile index 5787634..d334f84 100644 --- a/Makefile +++ b/Makefile @@ -1,9 +1,18 @@ PYTHON?=python -SOURCES=regen.py regen-git.py +SOURCES=regen.py regen-git.py test_skel.py + +UV:=$(shell uv --version) +ifdef UV + VENV:=uv venv + PIP:=uv pip +else + VENV:=python -m venv + PIP:=python -m pip +endif .PHONY: venv venv: - $(PYTHON) -m venv .venv + $(VENV) .venv source .venv/bin/activate && make setup @echo 'run `source .venv/bin/activate` to use virtualenv' @@ -12,16 +21,18 @@ venv: .PHONY: setup setup: - python -m pip install -Ur requirements-dev.txt + $(PIP) install -Ur requirements-dev.txt .PHONY: format format: - python -m isort --recursive -y $(SOURCES) - python -m black $(SOURCES) + ruff format + ruff check --fix .PHONY: lint lint: - python -m isort --recursive --diff $(SOURCES) - python -m black --check $(SOURCES) - python -m flake8 $(SOURCES) - mypy --strict regen.py + ruff check $(SOURCES) + mypy --strict $(SOURCES) + +.PHONY: checkdeps +checkdeps: + python -m checkdeps . --allow-names regen diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..1422247 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,15 @@ +[tool.ruff] +line-length = 88 + +[tool.ruff.lint] +extend-select = [ + "I", # isort +] + +[tool.ruff.lint.isort] +order-by-type = false + +[tool.ruff.lint.per-file-ignores] +"tests/*" = [ + "PLR2004", # Magic value used instead of constant (only in tests) +] diff --git a/regen-git.py b/regen-git.py index 391a6ad..68e1639 100755 --- a/regen-git.py +++ b/regen-git.py @@ -31,7 +31,13 @@ def find_git_dir(path: Optional[Path] = None) -> Path: raise Exception("No git dir found before root") -def main(): +def main() -> None: + skel_rev = subprocess.check_output( + ["git", "describe", "--always", "--dirty"], + cwd=os.path.dirname(__file__), + encoding="utf-8", + ).strip() + git_branches = subprocess.check_output( ["git", "branch", "-a"], encoding="utf-8" ).splitlines(False) @@ -72,14 +78,16 @@ def main(): regen.main() subprocess.check_call(["git", "add", "-A"]) try: - subprocess.check_call(["git", "commit", "-m", "Initialize skel"]) - except subprocess.CalledProcessError as e: + subprocess.check_call( + ["git", "commit", "-m", f"Initialize skel\n\nrev: {skel_rev}"] + ) + except subprocess.CalledProcessError: print("\x1b[33mNo changes?\x1b[0m") raise subprocess.check_call(["git", "push", "origin", SKEL_BRANCH]) print( "Good luck, the first " - f"'\x1b[32mgit merge --allow-unrelated-histories {SKEL_BRANCH}\x1b0m' " + f"'\x1b[32mgit merge --allow-unrelated-histories {SKEL_BRANCH}\x1b[0m' " "is typically full of conflicts." ) else: @@ -95,8 +103,10 @@ def main(): date = datetime.datetime.now().strftime("%Y-%m-%d") try: - subprocess.check_call(["git", "commit", "-m", f"Update skel {date}"]) - except subprocess.CalledProcessError as e: + subprocess.check_call( + ["git", "commit", "-m", f"Update skel {date}\n\nrev: {skel_rev}"] + ) + except subprocess.CalledProcessError: print("\x1b[33mNo changes?\x1b[0m") return diff --git a/regen.py b/regen.py index 9b5331e..fdf0631 100755 --- a/regen.py +++ b/regen.py @@ -4,16 +4,15 @@ # out. import configparser -import os import re from pathlib import Path from typing import Match -THIS_DIR = Path(os.path.abspath(__file__)).parent +THIS_DIR = Path(__file__).absolute().parent TEMPLATE_DIR = THIS_DIR / "templates" # This is very simplistic... -VARIABLE_RE = re.compile(r"(? str: This means that '{ foo }' is not an interpolation, nor is '{{foo}}', but we also don't get '!r' suffix for free. Maybe someday. + + Non-interpolations can be written '{@foo}' which becomes '{foo}' when + rendered. """ def replace(match: Match[str]) -> str: g = match.group(1) + if g[:1] == "@": + return match.group(0).replace("@", "", 1) if g in kwargs: return kwargs[g] return match.group(0) @@ -41,33 +45,34 @@ def main() -> None: if "vars" not in parser: parser.add_section("vars") - for dirpath, dirnames, filenames in os.walk(TEMPLATE_DIR): - for fn in filenames: - if fn.endswith(".in"): - template_path = Path(dirpath) / fn - local_path = (Path(dirpath) / fn[:-3]).relative_to(TEMPLATE_DIR) - with template_path.open("r") as f: - data = f.read() - variables = VARIABLE_RE.findall(data) - for v in variables: - if v not in parser["vars"]: - parser["vars"][v] = input(f"Value for {v}? ").strip() - with open(VARS_FILENAME, "w") as f: - parser.write(f) - - interpolated_data = variable_format(data, **parser["vars"]) - - if local_path.exists(): - with local_path.open("r") as f: - existing_data = f.read() - if existing_data == interpolated_data: - print(f"Unchanged {local_path}") - continue - - print(f"Writing {local_path}") - local_path.parent.mkdir(parents=True, exist_ok=True) - with local_path.open("w") as f: - f.write(interpolated_data) + for template_path in TEMPLATE_DIR.glob("**/*"): + if template_path.suffix == ".in": + data = template_path.read_text() + + variables = [] + variables.extend(VARIABLE_RE.findall(data)) + variables.extend(VARIABLE_RE.findall(str(template_path))) + + for v in variables: + if v[:1] != "@" and v not in parser["vars"]: + parser["vars"][v] = input(f"Value for {v}? ").strip() + with open(VARS_FILENAME, "w") as f: + parser.write(f) + + interpolated_data = variable_format(data, **parser["vars"]) + + local_path = template_path.with_suffix("").relative_to(TEMPLATE_DIR) + local_path = Path(variable_format(str(local_path), **parser["vars"])) + + if local_path.exists(): + existing_data = local_path.read_text() + if existing_data == interpolated_data: + print(f"Unchanged {local_path}") + continue + + print(f"Writing {local_path}") + local_path.parent.mkdir(parents=True, exist_ok=True) + local_path.write_text(interpolated_data) if __name__ == "__main__": diff --git a/requirements-dev.txt b/requirements-dev.txt index a560191..b5c4bd8 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,5 +1,7 @@ -coverage==4.5.4 -isort==4.3.21 -black==19.10b0 -tox==3.14.1 -flake8==3.7.9 +ruff==0.8.0 +coverage==7.4.3 +mypy==1.8.0 +tox==4.13.0 +twine==5.0.0 +volatile==2.1.0 +wheel==0.42.0 diff --git a/templates/.github/workflows/build.yml.in b/templates/.github/workflows/build.yml.in index ccf082a..f236aeb 100644 --- a/templates/.github/workflows/build.yml.in +++ b/templates/.github/workflows/build.yml.in @@ -3,35 +3,73 @@ on: push: branches: - master + - main + - tmp-* tags: - v* pull_request: +env: + UV_SYSTEM_PYTHON: 1 + jobs: - {package}: + test: runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: - python-version: [3.6, 3.7, 3.8] + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] os: [macOS-latest, ubuntu-latest, windows-latest] steps: - name: Checkout - uses: actions/checkout@v1 + uses: actions/checkout@v4 - name: Set Up Python ${{ matrix.python-version }} - uses: actions/setup-python@v1 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} + allow-prereleases: true + - uses: astral-sh/setup-uv@v3 - name: Install run: | - python -m pip install --upgrade pip - make setup - pip install -U . + uv pip install -e .[test,dev] - name: Test - run: make test + run: | + git config --global user.name "Unit Test" + git config --global user.email "example@example.com" + make test - name: Lint - run: make lint - - name: Coverage - run: codecov --token ${{ secrets.CODECOV_TOKEN }} --branch ${{ github.ref }} - continue-on-error: true + run: | + make lint + + build: + needs: test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.14" + - uses: astral-sh/setup-uv@v3 + - name: Install + run: uv pip install build + - name: Build + run: python -m build + - name: Upload + uses: actions/upload-artifact@v4 + with: + name: sdist + path: dist + + publish: + needs: build + runs-on: ubuntu-latest + if: startsWith(github.ref, 'refs/tags/v') + permissions: + id-token: write + steps: + - uses: actions/download-artifact@v4 + with: + name: sdist + path: dist + - uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/templates/.gitignore.in b/templates/.gitignore.in index d431df9..d6fda1d 100644 --- a/templates/.gitignore.in +++ b/templates/.gitignore.in @@ -83,7 +83,7 @@ celerybeat-schedule # Environments .env -.venv +.venv* env/ venv/ ENV/ @@ -105,3 +105,9 @@ venv.bak/ # Visual Studio Code .vscode/ + +# Vim swapfiles +*.sw[op] + +# Setuptools-scm +_version.py diff --git a/templates/Makefile.in b/templates/Makefile.in index 54c8e65..5d9c236 100644 --- a/templates/Makefile.in +++ b/templates/Makefile.in @@ -1,38 +1,37 @@ -PYTHON?=python -SOURCES={package} setup.py +ifeq ($(OS),Windows_NT) + ACTIVATE:=.venv/Scripts/activate +else + ACTIVATE:=.venv/bin/activate +endif -.PHONY: venv -venv: - $(PYTHON) -m venv .venv - source .venv/bin/activate && make setup - @echo 'run `source .venv/bin/activate` to use virtualenv' +UV:=$(shell uv --version) +ifdef UV + VENV:=uv venv + PIP:=uv pip +else + VENV:=python -m venv + PIP:=python -m pip +endif -# The rest of these are intended to be run within the venv, where python points -# to whatever was used to set up the venv. +.venv: + $(VENV) .venv .PHONY: setup -setup: - python -m pip install -Ur requirements-dev.txt +setup: .venv + source $(ACTIVATE) && $(PIP) install -Ue .[dev,test] .PHONY: test test: - python -m coverage run -m {package}.tests $(TESTOPTS) + python -m coverage run -m pytest $(TESTOPTS) python -m coverage report .PHONY: format format: - python -m isort --recursive -y $(SOURCES) - python -m black $(SOURCES) + ruff format + ruff check --fix .PHONY: lint lint: - python -m isort --recursive --diff $(SOURCES) - python -m black --check $(SOURCES) - python -m flake8 $(SOURCES) - mypy --strict {package} - -.PHONY: release -release: - rm -rf dist - python setup.py sdist bdist_wheel - twine upload dist/* + ruff check + python -m checkdeps --allow-names {package} {package} + mypy --strict --install-types --non-interactive {package} diff --git a/templates/README.md.in b/templates/README.md.in index 132fecf..d26ed96 100644 --- a/templates/README.md.in +++ b/templates/README.md.in @@ -1,10 +1,18 @@ -# {package_name} +# {pypi_name} +# Version Compat + +This library is compatile with Python 3.10+, but should be linted under the +newest stable version. + +# Versioning + +This library follows [meanver](https://meanver.org/) which basically means +[semver](https://semver.org/) along with a promise to rename when the major +version changes. # License -{package_name} is copyright [{author}]({author_website}), and licensed under -the MIT license. I am providing code in this repository to you under an open -source license. This is my personal repository; the license you receive to -my code is from me and not from my employer. See the `LICENSE` file for details. +{pypi_name} is copyright [{author}]({author_website}), and licensed under +the MIT license. See the `LICENSE` file for details. diff --git a/templates/pyproject.toml b/templates/pyproject.toml new file mode 100644 index 0000000..1422247 --- /dev/null +++ b/templates/pyproject.toml @@ -0,0 +1,15 @@ +[tool.ruff] +line-length = 88 + +[tool.ruff.lint] +extend-select = [ + "I", # isort +] + +[tool.ruff.lint.isort] +order-by-type = false + +[tool.ruff.lint.per-file-ignores] +"tests/*" = [ + "PLR2004", # Magic value used instead of constant (only in tests) +] diff --git a/templates/requirements-dev.txt.in b/templates/requirements-dev.txt.in deleted file mode 100644 index 3215afb..0000000 --- a/templates/requirements-dev.txt.in +++ /dev/null @@ -1,6 +0,0 @@ -coverage==4.5.4 -isort==4.3.21 -black==19.10b0 -tox==3.14.1 -flake8==3.7.9 -mypy==0.740 diff --git a/templates/setup.cfg.in b/templates/setup.cfg.in index 8516a43..c946f71 100644 --- a/templates/setup.cfg.in +++ b/templates/setup.cfg.in @@ -9,14 +9,28 @@ author = {author} author_email = {author_email} [options] -packages = {package} +packages = find: +python_requires = >=3.10 setup_requires = - setuptools_scm - setuptools >= 38.3.0 -python_requires = >=3.6 + setuptools_scm >= 8 + setuptools >= 65 +include_package_data = true +install_requires = -[bdist_wheel] -universal = true +[options.extras_require] +dev = + checkdeps == 0.9.0 + mypy == 1.19.1 + ruff == 0.15.6 + tox == 4.50.0 + tox-uv == 1.33.4 +test = + coverage >= 6 + pytest >= 8 + +[options.entry_points] +# console_scripts = +# foo=foo:bar [check] metadata = true @@ -24,8 +38,7 @@ strict = true [coverage:run] branch = True -include = {package}/* -omit = {package}/tests/* +source = {package},tests [coverage:report] fail_under = 70 @@ -33,27 +46,26 @@ precision = 1 show_missing = True skip_covered = True -[isort] -line_length = 88 -multi_line_output = 3 -force_grid_wrap = False -include_trailing_comma = True -use_parentheses = True - [mypy] ignore_missing_imports = True [tox:tox] -envlist = py36, py37, py38 +envlist = py{@310,311,312,313,314}, coverage [testenv] -deps = -rrequirements-dev.txt -whitelist_externals = make +deps = .[test] commands = - make test + coverage run -m pytest setenv = - py{36,37,38}: COVERAGE_FILE={envdir}/.coverage + COVERAGE_FILE={@toxworkdir}/.coverage.{@envname} + +[testenv:coverage] +deps = coverage +setenv = + COVERAGE_FILE={@toxworkdir}/.coverage +commands = + coverage combine + coverage report +depends = + py{@10,311,312,313,314} -[flake8] -ignore = E203, E231, E266, E302, E501, W503 -max-line-length = 88 diff --git a/templates/setup.py.in b/templates/setup.py.in index 460aabe..8423a4c 100644 --- a/templates/setup.py.in +++ b/templates/setup.py.in @@ -1,2 +1,3 @@ from setuptools import setup -setup(use_scm_version=True) + +setup(use_scm_version={"write_to": "{package}/_version.py"}) diff --git a/templates/tests/conftest.py.in b/templates/tests/conftest.py.in new file mode 100644 index 0000000..e69de29 diff --git a/templates/{package}/__init__.py.in b/templates/{package}/__init__.py.in new file mode 100644 index 0000000..9976658 --- /dev/null +++ b/templates/{package}/__init__.py.in @@ -0,0 +1,4 @@ +try: + from ._version import __version__ +except ImportError: # pragma: no cover + __version__ = "dev" diff --git a/templates/{package}/py.typed.in b/templates/{package}/py.typed.in new file mode 100644 index 0000000..e69de29 diff --git a/test_skel.py b/test_skel.py new file mode 100644 index 0000000..8962656 --- /dev/null +++ b/test_skel.py @@ -0,0 +1,17 @@ +import unittest + +import regen + + +class RegenTest(unittest.TestCase): + def test_variable_format(self) -> None: + self.assertEqual("1", regen.variable_format("{foo}", foo="1")) + self.assertEqual("{foo}", regen.variable_format("{foo}")) + self.assertEqual("{ foo }", regen.variable_format("{ foo }", foo="1")) + self.assertEqual("{{foo}}", regen.variable_format("{{foo}}", foo="1")) + with self.assertRaises(TypeError): + self.assertEqual("1", regen.variable_format("{foo}", foo=1, bar=2)) # type: ignore [arg-type] + + +if __name__ == "__main__": + unittest.main()