feat: initial p2pkg tool with CI, ruff, and stable tagging
Some checks failed
CI (tests + ruff) and stable tag / unittest (py3.10) (push) Has been cancelled
CI (tests + ruff) and stable tag / unittest (py3.11) (push) Has been cancelled
CI (tests + ruff) and stable tag / unittest (py3.12) (push) Has been cancelled
CI (tests + ruff) and stable tag / unittest (py3.13) (push) Has been cancelled
CI (tests + ruff) and stable tag / ruff (py3.12) (push) Has been cancelled
CI (tests + ruff) and stable tag / Tag stable (if version commit) (push) Has been cancelled

https://chatgpt.com/share/69468609-0584-800f-a3e0-9d58210fb0e8
This commit is contained in:
2025-12-20 12:18:28 +01:00
parent 87689b1d8b
commit d8e5fdff26
11 changed files with 441 additions and 26 deletions

106
.github/workflows/ci-and-stable.yml vendored Normal file
View File

@@ -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

22
LICENSE
View File

@@ -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.

4
MIRRORS Normal file
View File

@@ -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

20
Makefile Normal file
View File

@@ -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 {} +

View File

@@ -1,6 +1,61 @@
# p2pkg # p2pkg
Homepage: https://git.veen.world/kevinveenbirkenbach/p2pkg A small, purpose-built repository for a very specific migration:
## Author - `foo.py``foo/__main__.py`
Kevin Veen-Birkenbach <kevin@veen.world> - 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

View File

@@ -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 ];
};
};
}

View File

@@ -1,21 +1,38 @@
[build-system] [build-system]
requires = ["setuptools>=68", "wheel"] requires = ["hatchling>=1.24.2"]
build-backend = "setuptools.build_meta" build-backend = "hatchling.build"
[project] [project]
name = "p2pkg" name = "p2pkg"
version = "0.1.0" 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" readme = "README.md"
requires-python = ">=3.10" requires-python = ">=3.10"
authors = [{ name = "Kevin Veen-Birkenbach", email = "kevin@veen.world" }] license = { file = "LICENSE" }
license = { text = "All rights reserved by Kevin Veen-Birkenbach" } authors = [
urls = { Homepage = "https://git.veen.world/kevinveenbirkenbach/p2pkg" } { 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] [tool.hatch.build.targets.wheel]
package-dir = {"" = "src"} packages = ["src/p2pkg"]
[tool.setuptools.packages.find] [tool.hatch.build.targets.sdist]
where = ["src"] include = [
"/src",
"/tests",
"/tools",
"/README.md",
"/LICENSE",
"/Makefile",
"/pyproject.toml",
]

1
src/p2pkg/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""p2pkg: migrate flat modules into packages with __main__.py."""

112
src/p2pkg/__main__.py Normal file
View File

@@ -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())

0
tests/__init__.py Normal file
View File

91
tests/test_migration.py Normal file
View File

@@ -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)