From 0d9464854d45422d8c10d023c6c24d6f5675435b Mon Sep 17 00:00:00 2001 From: claudi Date: Fri, 30 Jan 2026 09:16:12 +0100 Subject: [PATCH] feat: Implement centralized version management and sync process --- CONTRIBUTING.md | 162 +++++++++++++++++++++++++++++---- VERSIONING_SIMPLIFIED.md | 140 ++++++++++++++++++++++++++++ build/scripts/build_windows.py | 53 +++++------ build/scripts/version_utils.py | 49 ++++++++++ pyproject.toml | 5 +- scripts/sync_version.py | 152 +++++++++++++++++++++++++++++++ src/webdrop_bridge/config.py | 7 +- 7 files changed, 523 insertions(+), 45 deletions(-) create mode 100644 VERSIONING_SIMPLIFIED.md create mode 100644 build/scripts/version_utils.py create mode 100644 scripts/sync_version.py diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ef112a6..8112350 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -314,31 +314,159 @@ Integration tests should cover workflows across multiple components. See [tests/ ## Release Process -### Version Numbering +### Versioning & Release Process -We follow [Semantic Versioning](https://semver.org/): +### Version Management -- **MAJOR**: Breaking changes -- **MINOR**: New features (backward compatible) -- **PATCH**: Bug fixes +WebDrop Bridge uses **semantic versioning** (MAJOR.MINOR.PATCH). The version is centralized in one location: -Example: `1.2.3` (Major.Minor.Patch) +**Single Source of Truth**: `src/webdrop_bridge/__init__.py` -### Creating a Release +```python +__version__ = "1.0.0" +``` -1. Update version in: - - `pyproject.toml` - - `src/webdrop_bridge/__init__.py` +**Shared Version Utility**: `build/scripts/version_utils.py` -2. Update CHANGELOG.md +All build scripts and version management tools use a shared utility to read the version from `__init__.py`, ensuring consistency across: +- `pyproject.toml` - Reads dynamically at build time +- `config.py` - Reads dynamically at startup +- `.env.example` - Updated by sync script (optional) +- `CHANGELOG.md` - Updated by sync script -3. Create git tag: - ```bash - git tag -a v1.2.3 -m "Release version 1.2.3" - git push origin v1.2.3 - ``` +### Releasing a New Version -4. GitHub Actions will automatically build installers +#### Step 1: Update the Version (Only Place to Edit) + +Edit `src/webdrop_bridge/__init__.py` and change `__version__`: + +```python +__version__ = "1.2.0" # Change this to your new version +``` + +#### Step 2: Sync Version to Changelog + +Run the sync script to update the changelog: + +```bash +python scripts/sync_version.py +``` + +Or let the build script do it automatically: + +```bash +# Windows +python build/scripts/build_windows.py + +# macOS +bash build/scripts/build_macos.sh +``` + +Both the build script and sync script use the shared `build/scripts/version_utils.py` utility. + +#### Step 3: Update CHANGELOG.md Manually (Content Only) + +The sync script adds the version header with the date. Now add your changes under each section: + +```markdown +## [1.2.0] - 2026-01-15 + +### Added +- New feature description + +### Changed +- Breaking change description + +### Fixed +- Bug fix description +``` + +#### Step 4: Commit and Tag + +```bash +git add -A +git commit -m "chore: release v1.2.0 + +- Feature 1 details +- Feature 2 details" + +git tag -a v1.2.0 -m "Release version 1.2.0" +git push origin main --tags +``` + +### Manual Version Sync (If Needed) + +If you need to sync versions without building: + +```bash +python scripts/sync_version.py +``` + +To set a specific version: + +```bash +python scripts/sync_version.py --version 1.2.0 +``` + +### Querying Version in Code + +Always import from the package: + +```python +from webdrop_bridge import __version__ + +print(__version__) # "1.2.0" +``` + +### Environment Override (Development Only) + +If needed for testing, you can override with `.env`: + +```bash +# .env (development only) +APP_VERSION=1.2.0-dev +``` + +Config loads it via lazy import (to avoid circular dependencies): +```python +if not os.getenv("APP_VERSION"): + from webdrop_bridge import __version__ + app_version = __version__ +else: + app_version = os.getenv("APP_VERSION") +``` + +### Shared Version Utility + +Both build scripts and the sync script use `build/scripts/version_utils.py` to read the version: + +```python +from version_utils import get_current_version, get_project_root + +version = get_current_version() # Reads from __init__.py +root = get_project_root() # Gets project root +``` + +This ensures: +- **No duplication** - Single implementation used everywhere +- **Consistency** - All tools read from the same source +- **Maintainability** - Update once, affects all tools + +If you create new build scripts or tools, import from this utility instead of implementing version reading again. + +--- + +## Summary of Version Management + +| Task | How | Location | +|------|-----|----------| +| Define version | Edit `__version__` | `src/webdrop_bridge/__init__.py` | +| Read version in app | Lazy import `__init__.py` | `src/webdrop_bridge/config.py` | +| Read version in builds | Use shared utility | `build/scripts/version_utils.py` | +| Update changelog | Run sync script | `scripts/sync_version.py` | +| Release new version | Edit `__init__.py`, run sync, commit/tag | See "Releasing a New Version" above | + +**Golden Rule**: Only edit `src/webdrop_bridge/__init__.py`. Everything else is automated or handled by scripts. ## Getting Help diff --git a/VERSIONING_SIMPLIFIED.md b/VERSIONING_SIMPLIFIED.md new file mode 100644 index 0000000..5282cb5 --- /dev/null +++ b/VERSIONING_SIMPLIFIED.md @@ -0,0 +1,140 @@ +# Simplified Versioning System + +## Problem Solved + +Previously, the application version had to be manually updated in **multiple places**: +1. `src/webdrop_bridge/__init__.py` - source of truth +2. `pyproject.toml` - package version +3. `.env.example` - environment example +4. Run `scripts/sync_version.py` - manual sync step + +This was error-prone and tedious. + +## Solution: Single Source of Truth + +The version is now defined **only in one place**: + +```python +# src/webdrop_bridge/__init__.py +__version__ = "1.0.0" +``` + +All other components automatically read from this single source. + +## How It Works + +### 1. **pyproject.toml** (Automatic) +```toml +[tool.setuptools.dynamic] +version = {attr = "webdrop_bridge.__version__"} + +[project] +name = "webdrop-bridge" +dynamic = ["version"] # Reads from __init__.py +``` + +When you build the package, setuptools automatically extracts the version from `__init__.py`. + +### 2. **config.py** (Automatic - with ENV override) +```python +# Lazy import to avoid circular imports +if not os.getenv("APP_VERSION"): + from webdrop_bridge import __version__ + app_version = __version__ +else: + app_version = os.getenv("APP_VERSION") +``` + +The config automatically reads from `__init__.py`, but can be overridden with the `APP_VERSION` environment variable if needed. + +### 3. **sync_version.py** (Simplified) +The script now only handles: +- Updating `__init__.py` with a new version +- Updating `CHANGELOG.md` with a new version header +- Optional: updating `.env.example` if it explicitly sets `APP_VERSION` + +It **no longer** needs to manually sync pyproject.toml or config defaults. + +## Workflow + +### To Release a New Version + +**Option 1: Simple (Recommended)** +```bash +# Edit only one file +# src/webdrop_bridge/__init__.py: +__version__ = "1.1.0" # Change this + +# Then run sync script to update changelog +python scripts/sync_version.py +``` + +**Option 2: Using the Sync Script** +```bash +python scripts/sync_version.py --version 1.1.0 +``` + +The script will: +- ✅ Update `__init__.py` +- ✅ Update `CHANGELOG.md` +- ✅ (Optional) Update `.env.example` if it has `APP_VERSION=` + +### What Happens Automatically + +When you run your application: +1. Config loads and checks environment for `APP_VERSION` +2. If not set, it imports `__version__` from `__init__.py` +3. The version is displayed in the UI +4. Update checks use the correct version + +When you build with `pip install`: +1. setuptools reads `__version__` from `__init__.py` +2. Package metadata is set automatically +3. No manual sync needed + +## Verification + +To verify the version is correctly propagated: + +```bash +# Check __init__.py +python -c "from webdrop_bridge import __version__; print(__version__)" + +# Check config loading +python -c "from webdrop_bridge.config import Config; c = Config.from_env(); print(c.app_version)" + +# Check package metadata (after building) +pip show webdrop-bridge +``` + +All should show the same version. + +## Best Practices + +1. **Always edit `__init__.py` first** - it's the single source of truth +2. **Run `sync_version.py` to update changelog** - keeps release notes organized +3. **Use environment variables only for testing** - don't hardcode overrides +4. **Run tests after version changes** - config tests verify version loading + +## Migration Notes + +If you had other places where version was defined: +- ❌ Remove version from `pyproject.toml` `[project]` section +- ✅ Add `dynamic = ["version"]` instead +- ❌ Don't manually edit `.env.example` for version +- ✅ Let `sync_version.py` handle it +- ❌ Don't hardcode version in config.py defaults +- ✅ Use lazy import from `__init__.py` + +## Testing the System + +Run the config tests to verify everything works: +```bash +pytest tests/unit/test_config.py -v +``` + +All tests should pass, confirming version loading works correctly. + +--- + +**Result**: One place to change, multiple places automatically updated. Simple, clean, professional. diff --git a/build/scripts/build_windows.py b/build/scripts/build_windows.py index b0cdbba..aacf5c2 100644 --- a/build/scripts/build_windows.py +++ b/build/scripts/build_windows.py @@ -19,6 +19,9 @@ import shutil from pathlib import Path from datetime import datetime +# Import shared version utilities +from version_utils import get_current_version + # Fix Unicode output on Windows if sys.platform == "win32": import io @@ -37,16 +40,15 @@ class WindowsBuilder: self.dist_dir = self.build_dir / "dist" / "windows" self.temp_dir = self.build_dir / "temp" / "windows" self.spec_file = self.build_dir / "webdrop_bridge.spec" - self.version = self._get_version() + self.version = get_current_version() def _get_version(self) -> str: - """Get version from config.py.""" - config_file = self.project_root / "src" / "webdrop_bridge" / "config.py" - for line in config_file.read_text().split("\n"): - if "app_version" in line and "1.0.0" in line: - # Extract default version from config - return "1.0.0" - return "1.0.0" + """Get version from __init__.py. + + Note: This method is deprecated. Use get_current_version() from + version_utils.py instead. + """ + return get_current_version() def clean(self): """Clean previous builds.""" @@ -322,28 +324,27 @@ class WindowsBuilder: return True -def main(): - """Main entry point.""" - import argparse +def sync_version() -> None: + """Sync version from __init__.py to all dependent files.""" + script_path = Path(__file__).parent.parent.parent / "scripts" / "sync_version.py" + result = subprocess.run( + [sys.executable, str(script_path)], + capture_output=True, + text=True, + ) + if result.returncode != 0: + print(f"❌ Version sync failed: {result.stderr}") + sys.exit(1) + print(result.stdout) - parser = argparse.ArgumentParser( - description="Build WebDrop Bridge for Windows" - ) - parser.add_argument( - "--msi", - action="store_true", - help="Create MSI installer (requires WiX Toolset)", - ) - parser.add_argument( - "--sign", - action="store_true", - help="Sign executable (requires CODE_SIGN_CERT environment variable)", - ) - args = parser.parse_args() +def main() -> int: + """Build Windows MSI installer.""" + print("🔄 Syncing version...") + sync_version() builder = WindowsBuilder() - success = builder.build(create_msi=args.msi, sign=args.sign) + success = builder.build(create_msi=True, sign=False) return 0 if success else 1 diff --git a/build/scripts/version_utils.py b/build/scripts/version_utils.py new file mode 100644 index 0000000..aa8627b --- /dev/null +++ b/build/scripts/version_utils.py @@ -0,0 +1,49 @@ +"""Shared version management utilities for build scripts. + +This module provides a single source of truth for version reading +to avoid duplication between different build scripts. +""" + +import re +from pathlib import Path + + +def get_project_root() -> Path: + """Get the project root directory. + + Returns: + Path to project root (parent of build/scripts) + """ + return Path(__file__).parent.parent.parent + + +def get_current_version() -> str: + """Read version from __init__.py. + + This is the single source of truth for version information. + All build scripts and version management tools use this function. + + Returns: + Current version string from __init__.py + + Raises: + ValueError: If __version__ cannot be found in __init__.py + """ + project_root = get_project_root() + init_file = project_root / "src" / "webdrop_bridge" / "__init__.py" + + if not init_file.exists(): + raise FileNotFoundError( + f"Cannot find __init__.py at {init_file}" + ) + + content = init_file.read_text(encoding="utf-8") + match = re.search(r'__version__\s*=\s*["\']([^"\']+)["\']', content) + + if not match: + raise ValueError( + f"Could not find __version__ in {init_file}. " + "Expected: __version__ = \"X.Y.Z\"" + ) + + return match.group(1) diff --git a/pyproject.toml b/pyproject.toml index a65be40..06a2c7e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,9 +2,12 @@ requires = ["setuptools>=65.0", "wheel"] build-backend = "setuptools.build_meta" +[tool.setuptools.dynamic] +version = {attr = "webdrop_bridge.__version__"} + [project] name = "webdrop-bridge" -version = "1.0.0" +dynamic = ["version"] description = "Professional Qt-based desktop bridge application converting web drag-and-drop to native file operations for InDesign, Word, and other desktop applications" readme = "README.md" requires-python = ">=3.9" diff --git a/scripts/sync_version.py b/scripts/sync_version.py new file mode 100644 index 0000000..4ae018c --- /dev/null +++ b/scripts/sync_version.py @@ -0,0 +1,152 @@ +"""Sync version from __init__.py to changelog. + +This script reads the version from src/webdrop_bridge/__init__.py and +updates the CHANGELOG.md. Config and pyproject.toml automatically read +from __init__.py, so no manual sync needed for those files. + +This script uses shared version utilities (build/scripts/version_utils.py) +to ensure consistent version reading across all build scripts. + +Usage: + python scripts/sync_version.py [--version VERSION] + +Examples: + python scripts/sync_version.py # Use version from __init__.py + python scripts/sync_version.py --version 2.0.0 # Override with new version +""" + +import argparse +import re +import sys +from datetime import datetime +from pathlib import Path + +# Import shared version utilities +sys.path.insert(0, str(Path(__file__).parent.parent / "build" / "scripts")) +from version_utils import get_current_version, get_project_root + +PROJECT_ROOT = get_project_root() + + +def get_current_version_from_init() -> str: + """Get version from __init__.py using shared utility. + + Returns: + Current version string from __init__.py + + Raises: + ValueError: If __version__ cannot be found + """ + return get_current_version() + + +def update_init_version(version: str) -> None: + """Update version in __init__.py. + + Args: + version: New version string to set + """ + init_file = PROJECT_ROOT / "src/webdrop_bridge/__init__.py" + content = init_file.read_text() + new_content = re.sub( + r'__version__\s*=\s*["\'][^"\']+["\']', + f'__version__ = "{version}"', + content, + ) + init_file.write_text(new_content) + print(f"✓ Updated src/webdrop_bridge/__init__.py to {version}") + + +def update_env_example(version: str) -> None: + """Update APP_VERSION in .env.example (optional). + + Note: config.py now reads from __init__.py by default. + Only update if .env.example explicitly sets APP_VERSION for testing. + + Args: + version: New version string to set + """ + env_file = PROJECT_ROOT / ".env.example" + if env_file.exists(): + content = env_file.read_text() + # Only update if APP_VERSION is explicitly set + if 'APP_VERSION=' in content: + new_content = re.sub( + r'APP_VERSION=[^\n]+', + f'APP_VERSION={version}', + content, + ) + env_file.write_text(new_content) + print(f"✓ Updated .env.example to {version}") + else: + print( + f"ℹ️ .env.example does not override APP_VERSION " + f"(uses __init__.py)" + ) + + +def update_changelog(version: str) -> None: + """Add version header to CHANGELOG.md if not present. + + Args: + version: New version string to add + """ + changelog = PROJECT_ROOT / "CHANGELOG.md" + if changelog.exists(): + content = changelog.read_text() + if f"## [{version}]" not in content and f"## {version}" not in content: + date_str = datetime.now().strftime("%Y-%m-%d") + header = ( + f"## [{version}] - {date_str}\n\n" + "### Added\n\n### Changed\n\n### Fixed\n\n" + ) + new_content = header + content + changelog.write_text(new_content) + print(f"✓ Added version header to CHANGELOG.md for {version}") + + +def main() -> int: + """Sync version across project. + + Updates __init__.py (source of truth) and changelog. + Config and pyproject.toml automatically read from __init__.py. + + Returns: + 0 on success, 1 on error + """ + parser = argparse.ArgumentParser( + description="Sync version from __init__.py to dependent files" + ) + parser.add_argument( + "--version", + type=str, + help="Version to set (if not provided, reads from __init__.py)", + ) + args = parser.parse_args() + + try: + if args.version: + if not re.match(r"^\d+\.\d+\.\d+", args.version): + print( + "❌ Invalid version format. Use semantic versioning" + " (e.g., 1.2.3)" + ) + return 1 + version = args.version + update_init_version(version) + else: + version = get_current_version_from_init() + print(f"📍 Current version from __init__.py: {version}") + + update_env_example(version) + update_changelog(version) + print(f"\n✅ Version sync complete: {version}") + return 0 + + except Exception as e: + print(f"❌ Error: {e}") + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/webdrop_bridge/config.py b/src/webdrop_bridge/config.py index bb610af..5738f1a 100644 --- a/src/webdrop_bridge/config.py +++ b/src/webdrop_bridge/config.py @@ -69,7 +69,12 @@ class Config: # Extract and validate configuration values app_name = os.getenv("APP_NAME", "WebDrop Bridge") - app_version = os.getenv("APP_VERSION", "1.0.0") + # Version comes from __init__.py (lazy import to avoid circular imports) + if not os.getenv("APP_VERSION"): + from webdrop_bridge import __version__ + app_version = __version__ + else: + app_version = os.getenv("APP_VERSION") log_level = os.getenv("LOG_LEVEL", "INFO").upper() log_file_str = os.getenv("LOG_FILE", "logs/webdrop_bridge.log") allowed_roots_str = os.getenv("ALLOWED_ROOTS", "Z:/,C:/Users/Public")