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
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:
106
.github/workflows/ci-and-stable.yml
vendored
Normal file
106
.github/workflows/ci-and-stable.yml
vendored
Normal 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
22
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.
|
||||
|
||||
4
MIRRORS
Normal file
4
MIRRORS
Normal 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
20
Makefile
Normal 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 {} +
|
||||
61
README.md
61
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 <kevin@veen.world>
|
||||
- `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
|
||||
|
||||
11
flake.nix
11
flake.nix
@@ -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 ];
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
1
src/p2pkg/__init__.py
Normal file
1
src/p2pkg/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""p2pkg: migrate flat modules into packages with __main__.py."""
|
||||
112
src/p2pkg/__main__.py
Normal file
112
src/p2pkg/__main__.py
Normal 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
0
tests/__init__.py
Normal file
91
tests/test_migration.py
Normal file
91
tests/test_migration.py
Normal 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)
|
||||
Reference in New Issue
Block a user