From 2be62e36fd0cd917ae354b820abd23ad116c1e02 Mon Sep 17 00:00:00 2001 From: claudi Date: Tue, 7 Apr 2026 11:37:43 +0200 Subject: [PATCH 1/2] Add versioning scripts and update README for packaging instructions --- README.md | 26 +++++++++++ pyproject.toml | 1 + scripts/build_wheel.py | 87 ++++++++++++++++++++++++++++++++++ scripts/set_version.py | 88 +++++++++++++++++++++++++++++++++++ tests/test_release_scripts.py | 55 ++++++++++++++++++++++ 5 files changed, 257 insertions(+) create mode 100644 scripts/build_wheel.py create mode 100644 scripts/set_version.py create mode 100644 tests/test_release_scripts.py diff --git a/README.md b/README.md index 7ae5473..888347e 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,32 @@ Install development dependencies when you want tests and code generation tooling & .\.venv\Scripts\python.exe -m pip install -e .[dev] ``` +## Packaging + +Print the current package version: + +```powershell +& .\.venv\Scripts\python.exe .\scripts\set_version.py --print-current +``` + +Update the package version in `pyproject.toml`: + +```powershell +& .\.venv\Scripts\python.exe .\scripts\set_version.py 0.1.1 +``` + +Build a wheel into `dist\`: + +```powershell +& .\.venv\Scripts\python.exe .\scripts\build_wheel.py +``` + +Set the version and build the wheel in one step: + +```powershell +& .\.venv\Scripts\python.exe .\scripts\build_wheel.py --version 0.1.1 +``` + Create a client with your eBay credentials: ```python diff --git a/pyproject.toml b/pyproject.toml index 6c6ed98..2d582d8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,7 @@ dependencies = [ [project.optional-dependencies] dev = [ + "build>=1.2,<2", "datamodel-code-generator>=0.28,<0.31", "pytest>=8,<9", "pytest-httpx>=0.35,<0.36", diff --git a/scripts/build_wheel.py b/scripts/build_wheel.py new file mode 100644 index 0000000..a726235 --- /dev/null +++ b/scripts/build_wheel.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +import argparse +import importlib.util +import shutil +import subprocess +import sys +from pathlib import Path + +from set_version import PYPROJECT_PATH, extract_project_version, write_project_version + + +ROOT = Path(__file__).resolve().parent.parent + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Build a wheel for the project.") + parser.add_argument("--version", help="Optionally set the project version before building.") + parser.add_argument("--outdir", default="dist", help="Directory where the wheel will be written.") + parser.add_argument("--no-clean", action="store_true", help="Keep existing build and output directories.") + return parser.parse_args() + + +def resolve_output_dir(outdir: str) -> Path: + output_dir = Path(outdir) + if not output_dir.is_absolute(): + output_dir = ROOT / output_dir + return output_dir + + +def ensure_build_dependency() -> None: + if importlib.util.find_spec("build") is None: + raise RuntimeError( + 'Missing dependency "build". Install development dependencies with '\ + '"python -m pip install -e .[dev]" or install "build" directly.' + ) + + +def clean_build_artifacts(output_dir: Path) -> None: + for path in (ROOT / "build", output_dir): + if path.exists(): + shutil.rmtree(path) + + +def build_wheel(output_dir: Path) -> None: + command = [ + sys.executable, + "-m", + "build", + "--wheel", + "--outdir", + str(output_dir), + ] + subprocess.run(command, check=True, cwd=ROOT) + + +def main() -> int: + args = parse_args() + output_dir = resolve_output_dir(args.outdir) + + if args.version: + previous_version, updated_version = write_project_version(PYPROJECT_PATH, args.version) + if previous_version == updated_version: + print(f"Version already set to {updated_version}") + else: + print(f"Updated version: {previous_version} -> {updated_version}") + + ensure_build_dependency() + + if not args.no_clean: + clean_build_artifacts(output_dir) + output_dir.mkdir(parents=True, exist_ok=True) + + version = extract_project_version(PYPROJECT_PATH.read_text(encoding="utf-8")) + print(f"Building wheel for version {version} into {output_dir}") + build_wheel(output_dir) + + wheels = sorted(output_dir.glob("*.whl")) + if not wheels: + raise RuntimeError("Build completed without producing a wheel file.") + for wheel in wheels: + print(f"Built wheel: {wheel}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) \ No newline at end of file diff --git a/scripts/set_version.py b/scripts/set_version.py new file mode 100644 index 0000000..009bfab --- /dev/null +++ b/scripts/set_version.py @@ -0,0 +1,88 @@ +from __future__ import annotations + +import argparse +import re +import tomllib +from pathlib import Path + + +ROOT = Path(__file__).resolve().parent.parent +PYPROJECT_PATH = ROOT / "pyproject.toml" +PROJECT_VERSION_PATTERN = re.compile( + r'(?ms)(^\[project\]\s*$.*?^version\s*=\s*")([^"\r\n]+)(")' +) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Update the project version in pyproject.toml.") + parser.add_argument("version", nargs="?", help="New project version.") + parser.add_argument("--print-current", action="store_true", help="Print the current project version.") + args = parser.parse_args() + if not args.print_current and not args.version: + parser.error("Provide a version or use --print-current.") + return args + + +def validate_version(version: str) -> str: + normalized = version.strip() + if not normalized: + raise ValueError("Version cannot be empty.") + if normalized != version: + raise ValueError("Version cannot start or end with whitespace.") + if any(character.isspace() for character in version): + raise ValueError("Version cannot contain whitespace.") + if '"' in version or "'" in version: + raise ValueError("Version cannot contain quote characters.") + return version + + +def extract_project_version(pyproject_text: str) -> str: + match = PROJECT_VERSION_PATTERN.search(pyproject_text) + if match is None: + raise ValueError("Could not find [project].version in pyproject.toml.") + return match.group(2) + + +def set_project_version_in_text(pyproject_text: str, new_version: str) -> str: + validate_version(new_version) + + def replace(match: re.Match[str]) -> str: + return f"{match.group(1)}{new_version}{match.group(3)}" + + updated_text, replacements = PROJECT_VERSION_PATTERN.subn(replace, pyproject_text, count=1) + if replacements != 1: + raise ValueError("Could not update [project].version in pyproject.toml.") + + tomllib.loads(updated_text) + return updated_text + + +def write_project_version(pyproject_path: Path, new_version: str) -> tuple[str, str]: + original_text = pyproject_path.read_text(encoding="utf-8") + current_version = extract_project_version(original_text) + updated_text = set_project_version_in_text(original_text, new_version) + + if updated_text != original_text: + pyproject_path.write_text(updated_text, encoding="utf-8") + + return current_version, new_version + + +def main() -> int: + args = parse_args() + pyproject_text = PYPROJECT_PATH.read_text(encoding="utf-8") + + if args.print_current: + print(extract_project_version(pyproject_text)) + return 0 + + previous_version, updated_version = write_project_version(PYPROJECT_PATH, args.version) + if previous_version == updated_version: + print(f"Version already set to {updated_version}") + else: + print(f"Updated version: {previous_version} -> {updated_version}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) \ No newline at end of file diff --git a/tests/test_release_scripts.py b/tests/test_release_scripts.py new file mode 100644 index 0000000..f557a29 --- /dev/null +++ b/tests/test_release_scripts.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +import sys +import tomllib +from pathlib import Path + + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT / "scripts")) + +from set_version import extract_project_version, set_project_version_in_text, write_project_version + + +def test_extract_project_version_reads_project_version() -> None: + pyproject_text = ( + "[build-system]\n" + 'requires = ["setuptools>=69", "wheel"]\n\n' + "[project]\n" + 'name = "ebay-rest-client"\n' + 'version = "0.1.0"\n' + ) + + assert extract_project_version(pyproject_text) == "0.1.0" + + +def test_set_project_version_in_text_updates_project_version_only() -> None: + pyproject_text = ( + "[build-system]\n" + 'requires = ["setuptools>=69", "wheel"]\n\n' + "[project]\n" + 'name = "ebay-rest-client"\n' + 'version = "0.1.0"\n' + 'description = "test package"\n' + ) + + updated_text = set_project_version_in_text(pyproject_text, "1.2.3") + + assert extract_project_version(updated_text) == "1.2.3" + assert tomllib.loads(updated_text)["project"]["version"] == "1.2.3" + + +def test_write_project_version_updates_file(tmp_path: Path) -> None: + pyproject_path = tmp_path / "pyproject.toml" + pyproject_path.write_text( + "[project]\n" + 'name = "ebay-rest-client"\n' + 'version = "0.1.0"\n', + encoding="utf-8", + ) + + previous_version, updated_version = write_project_version(pyproject_path, "2.0.0") + + assert previous_version == "0.1.0" + assert updated_version == "2.0.0" + assert tomllib.loads(pyproject_path.read_text(encoding="utf-8"))["project"]["version"] == "2.0.0" \ No newline at end of file From 9c8ddfdc8a7e4b345d3c3eed70cc7e519f27b6f0 Mon Sep 17 00:00:00 2001 From: claudi Date: Tue, 7 Apr 2026 11:55:38 +0200 Subject: [PATCH 2/2] Add Forgejo upload script and update documentation for package registry --- .gitignore | 1 + .pypirc.example | 7 ++ README.md | 20 +++++ pyproject.toml | 1 + upload_wheel_to_forgejo_pypi.ps1 | 131 +++++++++++++++++++++++++++++++ 5 files changed, 160 insertions(+) create mode 100644 .pypirc.example create mode 100644 upload_wheel_to_forgejo_pypi.ps1 diff --git a/.gitignore b/.gitignore index dd2cb32..1134237 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,7 @@ dist/ *.egg-info/ .eggs/ pip-wheel-metadata/ +.pypirc # Test and coverage output .pytest_cache/ diff --git a/.pypirc.example b/.pypirc.example new file mode 100644 index 0000000..568588d --- /dev/null +++ b/.pypirc.example @@ -0,0 +1,7 @@ +[distutils] +index-servers = forgejo + +[forgejo] +repository = https://git.him-tools.de/api/packages/HIM-public/pypi +username = __token__ +password = YOUR_FORGEJO_ACCESS_TOKEN \ No newline at end of file diff --git a/README.md b/README.md index 888347e..7f0e1be 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,26 @@ Set the version and build the wheel in one step: & .\.venv\Scripts\python.exe .\scripts\build_wheel.py --version 0.1.1 ``` +Upload the wheel to the Forgejo package registry: + +```powershell +.\upload_wheel_to_forgejo_pypi.ps1 +``` + +Force a rebuild before upload: + +```powershell +.\upload_wheel_to_forgejo_pypi.ps1 -Build +``` + +Build a specific version and upload it: + +```powershell +.\upload_wheel_to_forgejo_pypi.ps1 -Build -Version 0.1.1 +``` + +Create `.pypirc` in the project root from `.pypirc.example` and fill in your Forgejo token before uploading. + Create a client with your eBay credentials: ```python diff --git a/pyproject.toml b/pyproject.toml index 2d582d8..12b1bc8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,6 +21,7 @@ dev = [ "datamodel-code-generator>=0.28,<0.31", "pytest>=8,<9", "pytest-httpx>=0.35,<0.36", + "twine>=6,<7", ] [tool.setuptools.packages.find] diff --git a/upload_wheel_to_forgejo_pypi.ps1 b/upload_wheel_to_forgejo_pypi.ps1 new file mode 100644 index 0000000..c179e2c --- /dev/null +++ b/upload_wheel_to_forgejo_pypi.ps1 @@ -0,0 +1,131 @@ +param( + [switch]$Build = $false, + [string]$Version, + [switch]$Help = $false +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +function Show-Help { + Write-Host @" +Upload wheel to the Forgejo PyPI registry for ebay-rest-client + +USAGE: + .\upload_wheel_to_forgejo_pypi.ps1 [-Build] [-Version ] + +OPTIONS: + -Build Force a rebuild before uploading + -Version Set the package version before building + -Help Display this help message + +SETUP: + 1. Copy .pypirc.example to .pypirc in the project root + 2. Add your Forgejo access token to .pypirc + 3. Run this script again + +EXAMPLES: + .\upload_wheel_to_forgejo_pypi.ps1 + .\upload_wheel_to_forgejo_pypi.ps1 -Build + .\upload_wheel_to_forgejo_pypi.ps1 -Build -Version 0.1.1 +"@ +} + +if ($Help) { + Show-Help + exit 0 +} + +$projectRoot = Split-Path -Parent $MyInvocation.MyCommand.Path +$python = Join-Path $projectRoot ".venv\Scripts\python.exe" +$pypiRcPath = Join-Path $projectRoot ".pypirc" +$buildScript = Join-Path $projectRoot "scripts\build_wheel.py" +$distPath = Join-Path $projectRoot "dist" + +Write-Host "" +Write-Host "========================================================================" +Write-Host "eBay REST Client - Upload to Forgejo PyPI" +Write-Host "========================================================================" +Write-Host "" + +if (!(Test-Path $python)) { + Write-Host "[!] Virtual environment Python not found at .venv\\Scripts\\python.exe" -ForegroundColor Red + exit 1 +} + +if (!(Test-Path $pypiRcPath)) { + Write-Host "[!] .pypirc not found in project root." -ForegroundColor Red + Write-Host "" + Write-Host "Setup instructions:" + Write-Host " 1. Copy .pypirc.example to .pypirc" + Write-Host " 2. Edit .pypirc and add your Forgejo access token" + Write-Host " 3. Run this script again" + Write-Host "" + Write-Host "For help: .\upload_wheel_to_forgejo_pypi.ps1 -Help" + exit 1 +} + +if (!(Test-Path $buildScript)) { + Write-Host "[!] Build script not found at scripts\\build_wheel.py" -ForegroundColor Red + exit 1 +} + +$null = & $python -c "import importlib.util, sys; sys.exit(0 if importlib.util.find_spec('twine') else 1)" +if ($LASTEXITCODE -ne 0) { + Write-Host "Installing twine..." + & $python -m pip install twine + if ($LASTEXITCODE -ne 0) { + Write-Host "[!] Failed to install twine." -ForegroundColor Red + exit 1 + } +} + +$wheelFiles = @(Get-ChildItem -Path $distPath -Filter "*.whl" -ErrorAction SilentlyContinue) + +if ($Build -or $wheelFiles.Count -eq 0) { + if ($Build) { + Write-Host "Building wheel (forced)..." + } else { + Write-Host "Building wheel..." + } + + $buildArgs = @($buildScript) + if ($Version) { + $buildArgs += "--version" + $buildArgs += $Version + } + + & $python @buildArgs + if ($LASTEXITCODE -ne 0) { + Write-Host "[!] Wheel build failed." -ForegroundColor Red + exit $LASTEXITCODE + } + + $wheelFiles = @(Get-ChildItem -Path $distPath -Filter "*.whl" -ErrorAction Stop) +} + +if ($wheelFiles.Count -eq 0) { + Write-Host "[!] No wheel files found in dist." -ForegroundColor Red + exit 1 +} + +Write-Host "" +Write-Host "========================================================================" +Write-Host "Uploading to Forgejo PyPI..." +Write-Host "========================================================================" +Write-Host "" + +& $python -m twine upload --config-file $pypiRcPath -r forgejo $wheelFiles.FullName +$uploadResult = $LASTEXITCODE + +Write-Host "" +Write-Host "========================================================================" +if ($uploadResult -eq 0) { + Write-Host "Upload successful!" -ForegroundColor Green +} else { + Write-Host "Upload failed" -ForegroundColor Red +} +Write-Host "========================================================================" +Write-Host "" + +exit $uploadResult \ No newline at end of file