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