diff --git a/.github/workflows/ci-and-stable.yml b/.github/workflows/ci-and-stable.yml new file mode 100644 index 0000000..7883062 --- /dev/null +++ b/.github/workflows/ci-and-stable.yml @@ -0,0 +1,106 @@ +name: CI (tests + ruff) and stable tag + +on: + push: + branches: ["main"] + pull_request: + +permissions: + contents: write + +jobs: + test: + name: unittest (py${{ matrix.python-version }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.10", "3.11", "3.12", "3.13"] + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: pip + + - name: Install (editable) + run: | + python -m pip install --upgrade pip + pip install -e . + + - name: Run tests + run: | + make test + + ruff: + name: ruff (py3.12) + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + + - name: Install (editable) + ruff + run: | + python -m pip install --upgrade pip + pip install -e . + pip install ruff + + - name: Ruff check + run: | + ruff check . + + # Optional: falls du ruff format nutzt + # - name: Ruff format (check) + # run: | + # ruff format --check . + + stable: + name: Tag stable (if version commit) + runs-on: ubuntu-latest + needs: [test, ruff] + if: > + github.event_name == 'push' && + github.ref == 'refs/heads/main' && + github.repository_owner == 'kevinveenbirkenbach' + steps: + - name: Checkout (full history for tags) + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Check commit message for version + id: version + shell: bash + run: | + set -euo pipefail + msg="$(git log -1 --pretty=%B)" + echo "Commit message:" + echo "$msg" + if echo "$msg" | grep -Eq '(^|[^0-9])(v?[0-9]+\.[0-9]+\.[0-9]+)([^0-9]|$)'; then + echo "has_version=true" >> "$GITHUB_OUTPUT" + else + echo "has_version=false" >> "$GITHUB_OUTPUT" + fi + + - name: Update stable tag + if: steps.version.outputs.has_version == 'true' + shell: bash + run: | + set -euo pipefail + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + # Force-update lightweight tag "stable" to current commit + git tag -f stable + + # Push (force) the tag + git push -f origin stable diff --git a/LICENSE b/LICENSE index c30e6e6..8c4bd30 100644 --- a/LICENSE +++ b/LICENSE @@ -1 +1,21 @@ -All rights reserved by Kevin Veen-Birkenbach +MIT License + +Copyright (c) 2025 Kevin Veen-Birkenbach + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/MIRRORS b/MIRRORS new file mode 100644 index 0000000..5cb6c2c --- /dev/null +++ b/MIRRORS @@ -0,0 +1,4 @@ +https://pypi.org/project/p2pkg/ +ssh://git@git.veen.world:2201/kevinveenbirkenbach/p2pkg.git +ssh://git@code.infinito.nexus:2201/kevinveenbirkenbach/p2pkg.git +git@github.com:kevinveenbirkenbach/p2pkg.git diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..55ff7e5 --- /dev/null +++ b/Makefile @@ -0,0 +1,20 @@ +SHELL := /usr/bin/env bash + +PYTHON ?= python3 +PIP ?= $(PYTHON) -m pip + +.PHONY: venv install test clean + +venv: + $(PYTHON) -m venv .venv + @echo "Activate with: . .venv/bin/activate" + +install: + $(PIP) install -e . + +test: + $(PYTHON) -m unittest discover -s tests -p "test_*.py" -v + +clean: + rm -rf .venv .pytest_cache .ruff_cache dist build *.egg-info + find . -name "__pycache__" -type d -prune -exec rm -rf {} + diff --git a/README.md b/README.md index c90bf88..bcb2c90 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,61 @@ # p2pkg -Homepage: https://git.veen.world/kevinveenbirkenbach/p2pkg +A small, purpose-built repository for a very specific migration: -## Author -Kevin Veen-Birkenbach +- `foo.py` ➜ `foo/__main__.py` +- Generates `foo/__init__.py` that re-exports the public API from `__main__` + so existing code like `import foo` or `from foo import some_function` keeps working. +- Keeps the original module code *as-is* in `__main__.py` (one-off refactor helper). + +## Install (editable) + +```bash +python -m venv .venv +. .venv/bin/activate +pip install -e . +``` + +## Usage + +```bash +# Migrate one or more flat modules into packages +p2pkg roles_list.py another_module.py + +# Or run directly +python tools/p2pkg.py roles_list.py +``` + +### Behavior + +Given `roles_list.py`: + +``` +roles_list.py +``` + +After migration: + +``` +roles_list/ +├── __init__.py # re-exports public API from __main__ +└── __main__.py # contains the original implementation (moved) +``` + +- Running `python -m roles_list` executes `roles_list/__main__.py`. +- Existing imports remain compatible (via re-exports in `__init__.py`). + +## Development + +Run tests: + +```bash +make test +``` + +## License + +MIT License. See `LICENSE`. + +--- + +Author: Kevin Veen-Birkenbach diff --git a/flake.nix b/flake.nix deleted file mode 100644 index e25a7e8..0000000 --- a/flake.nix +++ /dev/null @@ -1,11 +0,0 @@ -{ - description = "p2pkg"; - inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; - outputs = { self, nixpkgs }: - let system = "x86_64-linux"; pkgs = import nixpkgs { inherit system; }; - in { - devShells.${system}.default = pkgs.mkShell { - packages = with pkgs; [ python312 python312Packages.pytest python312Packages.ruff ]; - }; - }; -} diff --git a/pyproject.toml b/pyproject.toml index 7a8b902..5e87664 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,21 +1,38 @@ [build-system] -requires = ["setuptools>=68", "wheel"] -build-backend = "setuptools.build_meta" +requires = ["hatchling>=1.24.2"] +build-backend = "hatchling.build" [project] name = "p2pkg" version = "0.1.0" -description = "" +description = "One-off refactor helper: migrate foo.py -> foo/__main__.py and generate foo/__init__.py that re-exports public API." readme = "README.md" requires-python = ">=3.10" -authors = [{ name = "Kevin Veen-Birkenbach", email = "kevin@veen.world" }] -license = { text = "All rights reserved by Kevin Veen-Birkenbach" } -urls = { Homepage = "https://git.veen.world/kevinveenbirkenbach/p2pkg" } +license = { file = "LICENSE" } +authors = [ + { name = "Kevin Veen-Birkenbach" } +] +keywords = ["refactor", "python", "imports", "package"] +classifiers = [ + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3 :: Only", + "Topic :: Software Development :: Refactoring" +] -dependencies = [] +[project.scripts] +p2pkg = "p2pkg.__main__:main" -[tool.setuptools] -package-dir = {"" = "src"} +[tool.hatch.build.targets.wheel] +packages = ["src/p2pkg"] -[tool.setuptools.packages.find] -where = ["src"] +[tool.hatch.build.targets.sdist] +include = [ + "/src", + "/tests", + "/tools", + "/README.md", + "/LICENSE", + "/Makefile", + "/pyproject.toml", +] diff --git a/src/p2pkg/__init__.py b/src/p2pkg/__init__.py new file mode 100644 index 0000000..26ab062 --- /dev/null +++ b/src/p2pkg/__init__.py @@ -0,0 +1 @@ +"""p2pkg: migrate flat modules into packages with __main__.py.""" diff --git a/src/p2pkg/__main__.py b/src/p2pkg/__main__.py new file mode 100644 index 0000000..2a3fdf0 --- /dev/null +++ b/src/p2pkg/__main__.py @@ -0,0 +1,112 @@ +from __future__ import annotations + +import argparse +import os +import pathlib +import subprocess + + +INIT_TEMPLATE = """\ +\"\"\"Compatibility wrapper. + +This package was migrated from a flat module ({name}.py) to a package layout: + {name}/__main__.py contains the original implementation. + +We re-export the public API so existing imports keep working. +\"\"\" + +from __future__ import annotations + +from . import __main__ as _main +from .__main__ import * # noqa: F401,F403 + +# Prefer explicit __all__ if the original module defined it. +__all__ = getattr(_main, "__all__", [n for n in dir(_main) if not n.startswith("_")]) +""" + + +def _run(cmd: list[str], cwd: pathlib.Path | None = None) -> None: + subprocess.run(cmd, check=True, cwd=str(cwd) if cwd else None) + + +def _have_git_repo(root: pathlib.Path) -> bool: + try: + subprocess.run( + ["git", "rev-parse", "--is-inside-work-tree"], + cwd=str(root), + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=True, + ) + return True + except Exception: + return False + + +def _git_mv(src: pathlib.Path, dst: pathlib.Path, cwd: pathlib.Path) -> None: + dst.parent.mkdir(parents=True, exist_ok=True) + _run(["git", "mv", str(src), str(dst)], cwd=cwd) + + +def _fs_mv(src: pathlib.Path, dst: pathlib.Path) -> None: + dst.parent.mkdir(parents=True, exist_ok=True) + src.rename(dst) + + +def migrate_one(py_file: pathlib.Path, use_git: bool, repo_root: pathlib.Path) -> None: + """Migrate a single flat module file (foo.py) into a package (foo/__main__.py).""" + py_file = py_file.resolve() + if py_file.suffix != ".py": + raise ValueError(f"Not a .py file: {py_file}") + + name = py_file.stem + pkg_dir = py_file.parent / name + + if pkg_dir.exists() and not pkg_dir.is_dir(): + raise RuntimeError(f"Target exists but is not a directory: {pkg_dir}") + + target_main = pkg_dir / "__main__.py" + target_init = pkg_dir / "__init__.py" + + if target_main.exists(): + raise RuntimeError(f"Refusing to overwrite existing: {target_main}") + + if use_git: + _git_mv(py_file, target_main, cwd=repo_root) + else: + _fs_mv(py_file, target_main) + + if not target_init.exists(): + target_init.write_text(INIT_TEMPLATE.format(name=name), encoding="utf-8") + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser( + prog="p2pkg", + description="Migrate foo.py -> foo/__main__.py and generate foo/__init__.py that re-exports public API.", + ) + parser.add_argument("files", nargs="+", help="Python module files to migrate (e.g. roles_list.py other.py).") + parser.add_argument("--no-git", action="store_true", help="Do not use `git mv` even if inside a git repo.") + parser.add_argument( + "--repo-root", + default=".", + help="Repository root used for git operations (default: current working directory).", + ) + + ns = parser.parse_args(argv) + repo_root = pathlib.Path(ns.repo_root).resolve() + use_git = (not ns.no_git) and _have_git_repo(repo_root) + + for f in ns.files: + path = pathlib.Path(f) + if not path.is_absolute(): + path = (repo_root / path).resolve() + if not path.exists(): + raise FileNotFoundError(str(path)) + migrate_one(path, use_git=use_git, repo_root=repo_root) + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_migration.py b/tests/test_migration.py new file mode 100644 index 0000000..fa45556 --- /dev/null +++ b/tests/test_migration.py @@ -0,0 +1,91 @@ +from __future__ import annotations + +import importlib.util +import os +import sys +import tempfile +import textwrap +import unittest +from pathlib import Path + +from p2pkg.__main__ import migrate_one + + +class TestMigration(unittest.TestCase): + def test_migrate_creates_package_and_exports_public_api(self) -> None: + with tempfile.TemporaryDirectory() as td: + root = Path(td) + mod = root / "roles_list.py" + mod.write_text(textwrap.dedent("""\ + __all__ = ["add", "PUBLIC_CONST"] + PUBLIC_CONST = 123 + _PRIVATE_CONST = 999 + + def add(a: int, b: int) -> int: + return a + b + + def _hidden() -> int: + return 1 + + if __name__ == "__main__": + print("running as script") + """),encoding="utf-8", + ) + + migrate_one(mod, use_git=False, repo_root=root) + + pkg = root / "roles_list" + self.assertTrue((pkg / "__main__.py").exists()) + self.assertTrue((pkg / "__init__.py").exists()) + self.assertFalse(mod.exists()) + + # Import the package and ensure re-exports work + sys.path.insert(0, str(root)) + try: + import roles_list # type: ignore + + self.assertTrue(hasattr(roles_list, "add")) + self.assertEqual(roles_list.add(2, 3), 5) + self.assertTrue(hasattr(roles_list, "PUBLIC_CONST")) + self.assertEqual(roles_list.PUBLIC_CONST, 123) + + # __all__ should be preserved + self.assertEqual(set(roles_list.__all__), {"add", "PUBLIC_CONST"}) + self.assertFalse(hasattr(roles_list, "_hidden")) + finally: + sys.path.remove(str(root)) + if "roles_list" in sys.modules: + del sys.modules["roles_list"] + + def test_migrate_without_explicit_all_exports_public_names(self) -> None: + with tempfile.TemporaryDirectory() as td: + root = Path(td) + mod = root / "foo.py" + mod.write_text(textwrap.dedent("""\ + VALUE = "ok" + + def hello() -> str: + return "hi" + + def _private() -> str: + return "no" + """), encoding="utf-8") + migrate_one(mod, use_git=False, repo_root=root) + + sys.path.insert(0, str(root)) + try: + import foo # type: ignore + + self.assertEqual(foo.hello(), "hi") + self.assertEqual(foo.VALUE, "ok") + self.assertIn("hello", foo.__all__) + self.assertIn("VALUE", foo.__all__) + self.assertNotIn("_private", foo.__all__) + finally: + sys.path.remove(str(root)) + if "foo" in sys.modules: + del sys.modules["foo"] + + +if __name__ == "__main__": + unittest.main(verbosity=2)