From 93af8743f87276e9b9a9167304cb785f545df14e Mon Sep 17 00:00:00 2001 From: Pavel Klinger Date: Thu, 23 Oct 2025 19:06:06 +0200 Subject: [PATCH 1/6] :bug: Make --quiet and --verbose mutually exclusive - Add validation to prevent simultaneous use of conflicting flags - Raise UsageError with clear message when both are specified - Add test coverage for mutual exclusion behavior - Resolves user testing issue #8 --- docs/testing/report1.pdf | Bin 0 -> 44097 bytes src/pwa_forge/cli.py | 4 ++++ tests/unit/test_cli.py | 11 +++++++++++ 3 files changed, 15 insertions(+) create mode 100644 docs/testing/report1.pdf diff --git a/docs/testing/report1.pdf b/docs/testing/report1.pdf new file mode 100644 index 0000000000000000000000000000000000000000..91b1c29e8fd237a03c685696c4a47f44f949c46c GIT binary patch literal 44097 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 L|<>{2jN%@S-8-a8xb${H@)!i|irTrGuH4Ma7 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 l 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 literal 0 HcmV?d00001 diff --git a/src/pwa_forge/cli.py b/src/pwa_forge/cli.py index 37769c6..556360f 100644 --- a/src/pwa_forge/cli.py +++ b/src/pwa_forge/cli.py @@ -68,6 +68,10 @@ def cli(ctx: click.Context, verbose: int, quiet: bool, no_color: bool) -> None: pwa-forge list pwa-forge remove chatgpt """ + # Validate mutually exclusive options + if quiet and verbose > 0: + raise click.UsageError("--quiet and --verbose are mutually exclusive") + # Load configuration config = load_config() diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index fa982f8..a5e9066 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -260,6 +260,17 @@ def test_verbose_flag(self) -> None: result = runner.invoke(cli.cli, ["-vv", "version"]) assert result.exit_code == 0 + def test_quiet_and_verbose_mutually_exclusive(self) -> None: + """Test that --quiet and --verbose cannot be used together.""" + runner = CliRunner() + result = runner.invoke(cli.cli, ["--quiet", "--verbose", "version"]) + assert result.exit_code != 0 + assert "mutually exclusive" in result.output + + result = runner.invoke(cli.cli, ["-q", "-v", "version"]) + assert result.exit_code != 0 + assert "mutually exclusive" in result.output + class TestGenerateHandlerCommand: """Test generate-handler command.""" From d80547a8a88252af2a1456e7ca73c32bb6dd19ac Mon Sep 17 00:00:00 2001 From: Pavel Klinger Date: Thu, 23 Oct 2025 19:11:29 +0200 Subject: [PATCH 2/6] :sparkles: Add shutil.which() fallback for browser detection - Add PATH search using shutil.which() for browser executables - Supports non-standard install locations (Snap, Flatpak, custom paths) - Falls back to PATH search after configured and known paths - Add comprehensive unit tests for browser detection logic - Update E2E test to mock shutil.which() for failure scenarios - Resolves user testing issue #4 (hard-coded browser paths) --- src/pwa_forge/commands/add.py | 15 ++++ tests/e2e/test_e2e_system.py | 3 + tests/unit/test_add_browser_detection.py | 104 +++++++++++++++++++++++ 3 files changed, 122 insertions(+) create mode 100644 tests/unit/test_add_browser_detection.py diff --git a/src/pwa_forge/commands/add.py b/src/pwa_forge/commands/add.py index 230508e..64d9dff 100644 --- a/src/pwa_forge/commands/add.py +++ b/src/pwa_forge/commands/add.py @@ -286,6 +286,14 @@ def _get_browser_executable(browser: str, config: Config) -> Path: "edge": ["/usr/bin/microsoft-edge-stable", "/usr/bin/microsoft-edge"], } + # Map browser names to common executable names for shutil.which() + browser_executables = { + "chrome": ["google-chrome-stable", "google-chrome"], + "chromium": ["chromium-browser", "chromium"], + "firefox": ["firefox"], + "edge": ["microsoft-edge-stable", "microsoft-edge"], + } + # Try configured path first if hasattr(config.browsers, browser): browser_path = getattr(config.browsers, browser) @@ -300,6 +308,13 @@ def _get_browser_executable(browser: str, config: Config) -> Path: logger.debug(f"Found browser at: {path}") return path + # Fallback: search in PATH using shutil.which() + for executable_name in browser_executables.get(browser, []): + which_path = shutil.which(executable_name) + if which_path: + logger.debug(f"Found browser in PATH: {which_path}") + return Path(which_path) + raise AddCommandError(f"Browser '{browser}' not found. Please install it or specify the path in config.") diff --git a/tests/e2e/test_e2e_system.py b/tests/e2e/test_e2e_system.py index 99e8d4a..925bc33 100644 --- a/tests/e2e/test_e2e_system.py +++ b/tests/e2e/test_e2e_system.py @@ -146,6 +146,9 @@ def mock_exists(self: Path) -> bool: monkeypatch.setattr(Path, "exists", mock_exists) + # Mock shutil.which to return None (browser not in PATH) + monkeypatch.setattr("shutil.which", lambda _: None) + # Add PWA should fail from pwa_forge.commands.add import AddCommandError diff --git a/tests/unit/test_add_browser_detection.py b/tests/unit/test_add_browser_detection.py new file mode 100644 index 0000000..1f0a956 --- /dev/null +++ b/tests/unit/test_add_browser_detection.py @@ -0,0 +1,104 @@ +"""Unit tests for browser detection in add command.""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import patch + +import pytest +from pwa_forge.commands.add import AddCommandError, _get_browser_executable +from pwa_forge.config import Config + + +class TestBrowserDetection: + """Test browser executable detection logic.""" + + def test_browser_found_in_config(self) -> None: + """Test browser found via configured path.""" + config = Config() + + # Mock the browser path to a temporary file + with ( + patch.object(config.browsers, "firefox", "/usr/bin/firefox"), + patch("pathlib.Path.exists", return_value=True), + ): + result = _get_browser_executable("firefox", config) + assert result == Path("/usr/bin/firefox") + + def test_browser_found_in_known_paths(self) -> None: + """Test browser found in hard-coded known paths.""" + config = Config() + + # Make config path not exist, but known path exist + with patch("pathlib.Path.exists") as mock_exists: + # First call (config path) returns False, second call (known path) returns True + mock_exists.side_effect = [False, True] + + result = _get_browser_executable("firefox", config) + assert result == Path("/usr/bin/firefox") + + def test_browser_found_via_which(self) -> None: + """Test browser found via shutil.which() fallback.""" + config = Config() + + # All hard-coded paths fail, but shutil.which() succeeds + with ( + patch("pathlib.Path.exists", return_value=False), + patch("shutil.which", return_value="/snap/bin/firefox"), + ): + result = _get_browser_executable("firefox", config) + assert result == Path("/snap/bin/firefox") + + def test_browser_not_found_raises_error(self) -> None: + """Test that missing browser raises AddCommandError.""" + config = Config() + + # All detection methods fail + with ( + patch("pathlib.Path.exists", return_value=False), + patch("shutil.which", return_value=None), + pytest.raises(AddCommandError, match="Browser 'firefox' not found"), + ): + _get_browser_executable("firefox", config) + + def test_chrome_found_with_multiple_names(self) -> None: + """Test chrome can be found under various executable names.""" + config = Config() + + # Configured and known paths don't exist + with ( + patch("pathlib.Path.exists", return_value=False), + patch("shutil.which") as mock_which, + ): + # shutil.which() tries google-chrome-stable first (fails), then google-chrome (succeeds) + mock_which.side_effect = [None, "/usr/local/bin/google-chrome"] + + result = _get_browser_executable("chrome", config) + assert result == Path("/usr/local/bin/google-chrome") + + # Verify it tried both names + assert mock_which.call_count == 2 + + def test_chromium_browser_executable_name(self) -> None: + """Test chromium with chromium-browser executable name.""" + config = Config() + + with ( + patch("pathlib.Path.exists", return_value=False), + patch("shutil.which") as mock_which, + ): + mock_which.side_effect = ["/usr/bin/chromium-browser", None] + + result = _get_browser_executable("chromium", config) + assert result == Path("/usr/bin/chromium-browser") + + def test_edge_browser_detection(self) -> None: + """Test Microsoft Edge browser detection.""" + config = Config() + + with ( + patch("pathlib.Path.exists", return_value=False), + patch("shutil.which", return_value="/opt/microsoft/msedge/microsoft-edge"), + ): + result = _get_browser_executable("edge", config) + assert result == Path("/opt/microsoft/msedge/microsoft-edge") From ee073f2b84fbec9f99cf52b681e994b514e1f88d Mon Sep 17 00:00:00 2001 From: Pavel Klinger Date: Thu, 23 Oct 2025 19:13:43 +0200 Subject: [PATCH 3/6] :bug: Skip browser validation in --dry-run mode - Move browser executable detection after dry-run check - Use placeholder browser path in dry-run to avoid system checks - Add comprehensive tests for dry-run behavior - Allows testing in environments without browsers installed - Resolves user testing issue #3 (dry-run requires browser) --- src/pwa_forge/commands/add.py | 10 ++- tests/unit/test_add_browser_detection.py | 80 ++++++++++++++++++++++++ 2 files changed, 88 insertions(+), 2 deletions(-) diff --git a/src/pwa_forge/commands/add.py b/src/pwa_forge/commands/add.py index 64d9dff..5c2b230 100644 --- a/src/pwa_forge/commands/add.py +++ b/src/pwa_forge/commands/add.py @@ -109,8 +109,14 @@ def add_app( # Handle icon icon_path = _handle_icon(icon, app_id, config.icons_dir, dry_run) if icon else None - # Get browser executable - browser_exec = _get_browser_executable(browser, config) + # Get browser executable (skip in dry-run to allow testing without browsers installed) + if not dry_run: + browser_exec = _get_browser_executable(browser, config) + else: + # In dry-run, use placeholder path that won't be written + browser_exec_str = f"/usr/bin/{browser}" + logger.info(f"[DRY-RUN] Would use browser: {browser_exec_str}") + browser_exec = Path(browser_exec_str) # Parse chrome flags parsed_flags = _parse_chrome_flags(chrome_flags) if chrome_flags else {} diff --git a/tests/unit/test_add_browser_detection.py b/tests/unit/test_add_browser_detection.py index 1f0a956..8cb2dd9 100644 --- a/tests/unit/test_add_browser_detection.py +++ b/tests/unit/test_add_browser_detection.py @@ -102,3 +102,83 @@ def test_edge_browser_detection(self) -> None: ): result = _get_browser_executable("edge", config) assert result == Path("/opt/microsoft/msedge/microsoft-edge") + + +class TestDryRunBehavior: + """Test dry-run mode behavior.""" + + def test_dry_run_does_not_require_browser(self, tmp_path: Path) -> None: + """Test that dry-run mode works without browser installed.""" + from pwa_forge.commands.add import add_app + + config = Config() + config.directories.desktop = tmp_path / "applications" + config.directories.icons = tmp_path / "icons" + config.directories.wrappers = tmp_path / "wrappers" + config.directories.apps = tmp_path / "apps" + + # Create directories + for directory in [ + config.directories.desktop, + config.directories.icons, + config.directories.wrappers, + config.directories.apps, + ]: + directory.mkdir(parents=True, exist_ok=True) + + # Mock all browser detection to fail, and mock the data dir for registry + with ( + patch("pathlib.Path.exists", return_value=False), + patch("shutil.which", return_value=None), + patch("pwa_forge.utils.paths.get_app_data_dir", return_value=tmp_path / "data"), + ): + # This should NOT raise an error in dry-run mode + result = add_app( + url="https://example.com", + config=config, + name="Test App", + app_id="test-app", + browser="chrome", + dry_run=True, + ) + + # Verify result contains expected fields + assert result["id"] == "test-app" + assert result["name"] == "Test App" + assert result["browser"] == "chrome" + + def test_non_dry_run_requires_browser(self, tmp_path: Path) -> None: + """Test that non-dry-run mode requires browser to be found.""" + from pwa_forge.commands.add import AddCommandError, add_app + + config = Config() + config.directories.desktop = tmp_path / "applications" + config.directories.icons = tmp_path / "icons" + config.directories.wrappers = tmp_path / "wrappers" + config.directories.apps = tmp_path / "apps" + + # Create directories + for directory in [ + config.directories.desktop, + config.directories.icons, + config.directories.wrappers, + config.directories.apps, + ]: + directory.mkdir(parents=True, exist_ok=True) + + # Mock all browser detection to fail + with ( + patch("pathlib.Path.exists", return_value=False), + patch("shutil.which", return_value=None), + patch("pwa_forge.utils.paths.get_app_data_dir", return_value=tmp_path / "data"), + pytest.raises(AddCommandError, match="Browser 'chrome' not found"), + ): + # This SHOULD raise an error in non-dry-run mode + add_app( + url="https://example.com", + config=config, + name="Test App", + app_id="test-app-2", + browser="chrome", + dry_run=False, + ) From 5d868a77c6e14872d63c01796c0ebf13f29200bc Mon Sep 17 00:00:00 2001 From: Pavel Klinger Date: Thu, 23 Oct 2025 19:16:39 +0200 Subject: [PATCH 4/6] :sparkles: Improve URL validation with explicit status codes - Add ValidationStatus class with OK/WARNING/ERROR constants - Change validate_url() to return 3-tuple (is_valid, status, message) - Replace fragile string matching ('Warning' in message) with status checks - Update all callers to use explicit status comparison - Update all tests for new validation signature - Resolves user testing issue #9 (fragile validation detection) --- src/pwa_forge/commands/add.py | 5 ++-- src/pwa_forge/validation.py | 29 +++++++++++++++------- tests/unit/test_validation.py | 46 +++++++++++++++++++++++------------ 3 files changed, 54 insertions(+), 26 deletions(-) diff --git a/src/pwa_forge/commands/add.py b/src/pwa_forge/commands/add.py index 5c2b230..54554a1 100644 --- a/src/pwa_forge/commands/add.py +++ b/src/pwa_forge/commands/add.py @@ -12,6 +12,7 @@ from pwa_forge.registry import Registry from pwa_forge.templates import render_template from pwa_forge.validation import ( + ValidationStatus, extract_name_from_url, generate_id, generate_wm_class, @@ -65,10 +66,10 @@ def add_app( logger.info(f"Adding PWA for URL: {url}") # Validate URL - is_valid, message = validate_url(url, verify=False) + is_valid, status, message = validate_url(url, verify=False) if not is_valid: raise AddCommandError(f"Invalid URL: {message}") - if "Warning" in message: + if status == ValidationStatus.WARNING: logger.warning(message) # Determine app name diff --git a/src/pwa_forge/validation.py b/src/pwa_forge/validation.py index 7aae712..643126f 100644 --- a/src/pwa_forge/validation.py +++ b/src/pwa_forge/validation.py @@ -11,7 +11,15 @@ logger = logging.getLogger(__name__) -def validate_url(url: str, verify: bool = False, timeout: int = 5) -> tuple[bool, str]: +class ValidationStatus: + """URL validation result status codes.""" + + OK = "OK" + WARNING = "WARNING" + ERROR = "ERROR" + + +def validate_url(url: str, verify: bool = False, timeout: int = 5) -> tuple[bool, str, str]: """Validate URL format and optionally check accessibility. Args: @@ -20,30 +28,33 @@ def validate_url(url: str, verify: bool = False, timeout: int = 5) -> tuple[bool timeout: Connection timeout in seconds for verification. Returns: - Tuple of (is_valid, message) where message is "OK" or error description. + Tuple of (is_valid, status, message) where: + - is_valid: True if URL is valid (may have warnings) + - status: One of ValidationStatus.OK, ValidationStatus.WARNING, ValidationStatus.ERROR + - message: Description of the validation result """ # Parse URL try: parsed = urlparse(url) except Exception as e: logger.debug(f"URL parsing failed for '{url}': {e}") - return False, f"Invalid URL format: {e}" + return False, ValidationStatus.ERROR, f"Invalid URL format: {e}" # Check scheme if parsed.scheme not in ("http", "https"): logger.debug(f"Invalid URL scheme: {parsed.scheme}") - return False, "URL must use http:// or https://" + return False, ValidationStatus.ERROR, "URL must use http:// or https://" # Check host if not parsed.netloc: logger.debug("URL missing hostname") - return False, "URL must include a hostname" + return False, ValidationStatus.ERROR, "URL must include a hostname" # Warn about localhost hostname = parsed.netloc.split(":")[0] if ":" in parsed.netloc else parsed.netloc if hostname in ("localhost", "127.0.0.1", "::1"): logger.warning(f"Localhost URL detected: {url}") - return True, "Warning: localhost URLs won't work from system launcher" + return True, ValidationStatus.WARNING, "localhost URLs won't work from system launcher" # Optional connectivity check if verify: @@ -52,13 +63,13 @@ def validate_url(url: str, verify: bool = False, timeout: int = 5) -> tuple[bool response = requests.head(url, timeout=timeout, allow_redirects=True) if response.status_code >= 400: logger.warning(f"URL returned HTTP {response.status_code}: {url}") - return False, f"URL returned HTTP {response.status_code}" + return False, ValidationStatus.ERROR, f"URL returned HTTP {response.status_code}" except requests.RequestException as e: logger.warning(f"URL not accessible: {url} - {e}") - return False, f"URL not accessible: {e}" + return False, ValidationStatus.ERROR, f"URL not accessible: {e}" logger.debug(f"URL validation passed: {url}") - return True, "OK" + return True, ValidationStatus.OK, "OK" def generate_id(name: str) -> str: diff --git a/tests/unit/test_validation.py b/tests/unit/test_validation.py index faa582a..230f27b 100644 --- a/tests/unit/test_validation.py +++ b/tests/unit/test_validation.py @@ -6,6 +6,7 @@ import requests from pwa_forge.validation import ( + ValidationStatus, extract_name_from_url, generate_id, generate_wm_class, @@ -19,62 +20,72 @@ class TestValidateUrl: def test_valid_http_url(self) -> None: """Test validation of valid HTTP URL.""" - is_valid, message = validate_url("http://example.com") + is_valid, status, message = validate_url("http://example.com") assert is_valid is True + assert status == ValidationStatus.OK assert message == "OK" def test_valid_https_url(self) -> None: """Test validation of valid HTTPS URL.""" - is_valid, message = validate_url("https://example.com") + is_valid, status, message = validate_url("https://example.com") assert is_valid is True + assert status == ValidationStatus.OK assert message == "OK" def test_valid_url_with_path(self) -> None: """Test validation of URL with path.""" - is_valid, message = validate_url("https://example.com/path/to/app") + is_valid, status, message = validate_url("https://example.com/path/to/app") assert is_valid is True + assert status == ValidationStatus.OK assert message == "OK" def test_valid_url_with_query(self) -> None: """Test validation of URL with query parameters.""" - is_valid, message = validate_url("https://example.com?param=value") + is_valid, status, message = validate_url("https://example.com?param=value") assert is_valid is True + assert status == ValidationStatus.OK assert message == "OK" def test_invalid_scheme_ftp(self) -> None: """Test rejection of FTP URLs.""" - is_valid, message = validate_url("ftp://example.com") + is_valid, status, message = validate_url("ftp://example.com") assert is_valid is False + assert status == ValidationStatus.ERROR assert "http" in message.lower() def test_invalid_scheme_file(self) -> None: """Test rejection of file URLs.""" - is_valid, message = validate_url("file:///path/to/file") + is_valid, status, message = validate_url("file:///path/to/file") assert is_valid is False + assert status == ValidationStatus.ERROR assert "http" in message.lower() def test_missing_hostname(self) -> None: """Test rejection of URL without hostname.""" - is_valid, message = validate_url("https://") + is_valid, status, message = validate_url("https://") assert is_valid is False + assert status == ValidationStatus.ERROR assert "hostname" in message.lower() def test_localhost_url(self) -> None: """Test warning for localhost URLs.""" - is_valid, message = validate_url("http://localhost:8080") + is_valid, status, message = validate_url("http://localhost:8080") assert is_valid is True + assert status == ValidationStatus.WARNING assert "localhost" in message.lower() def test_localhost_ip(self) -> None: """Test warning for 127.0.0.1 URLs.""" - is_valid, message = validate_url("http://127.0.0.1:3000") + is_valid, status, message = validate_url("http://127.0.0.1:3000") assert is_valid is True + assert status == ValidationStatus.WARNING assert "localhost" in message.lower() def test_invalid_url_format(self) -> None: """Test rejection of malformed URLs.""" - is_valid, _ = validate_url("not a url") + is_valid, status, _ = validate_url("not a url") assert is_valid is False + assert status == ValidationStatus.ERROR def test_url_parsing_exception(self) -> None: """Test handling of URL parsing exceptions.""" @@ -85,24 +96,27 @@ def test_url_parsing_exception(self) -> None: with patch("pwa_forge.validation.urlparse") as mock_urlparse: mock_urlparse.side_effect = ValueError("Invalid URL") - is_valid, message = validate_url("http://invalid") + is_valid, status, message = validate_url("http://invalid") assert is_valid is False + assert status == ValidationStatus.ERROR assert "Invalid URL format" in message def test_url_connectivity_check_success(self) -> None: """Test URL connectivity check when verify=True.""" # This will try to make a real HTTP request # We can't control the network, so this may pass or fail depending on connectivity - is_valid, message = validate_url("https://httpbin.org/status/200", verify=True) + is_valid, status, message = validate_url("https://httpbin.org/status/200", verify=True) # Just check that it doesn't crash assert isinstance(is_valid, bool) + assert isinstance(status, str) assert isinstance(message, str) def test_url_connectivity_check_failure(self) -> None: """Test URL connectivity check failure.""" # Use a non-existent domain - is_valid, message = validate_url("https://this-domain-does-not-exist-12345.com", verify=True) + is_valid, status, message = validate_url("https://this-domain-does-not-exist-12345.com", verify=True) assert is_valid is False + assert status == ValidationStatus.ERROR assert "not accessible" in message def test_url_connectivity_check_http_error(self) -> None: @@ -111,16 +125,18 @@ def test_url_connectivity_check_http_error(self) -> None: mock_response = Mock() mock_response.status_code = 404 mock_head.return_value = mock_response - is_valid, message = validate_url("https://example.com/notfound", verify=True) + is_valid, status, message = validate_url("https://example.com/notfound", verify=True) assert is_valid is False + assert status == ValidationStatus.ERROR assert "404" in message def test_url_connectivity_check_request_exception(self) -> None: """Test URL connectivity check with request exception.""" with patch("pwa_forge.validation.requests.head") as mock_head: mock_head.side_effect = requests.RequestException("Connection timeout") - is_valid, message = validate_url("https://example.com", verify=True) + is_valid, status, message = validate_url("https://example.com", verify=True) assert is_valid is False + assert status == ValidationStatus.ERROR assert "not accessible" in message From d7b2dbfdebd966040b0c765d4d010d1ceecc391a Mon Sep 17 00:00:00 2001 From: Pavel Klinger Date: Thu, 23 Oct 2025 19:18:30 +0200 Subject: [PATCH 5/6] :sparkles: Add cross-platform file locking and fix registry race condition - Add platform-specific locking: fcntl (Unix) and msvcrt (Windows) - Make add_app() atomic with single-lock read-check-write operation - Prevent race condition where two simultaneous adds could corrupt registry - Add type annotations to satisfy mypy strict type checking - Resolves user testing issues #6 and #7 (registry concurrency and platform locking) --- src/pwa_forge/registry.py | 66 ++++++++++++++++++++++++++++++--------- 1 file changed, 51 insertions(+), 15 deletions(-) diff --git a/src/pwa_forge/registry.py b/src/pwa_forge/registry.py index cb7b77c..a028f69 100644 --- a/src/pwa_forge/registry.py +++ b/src/pwa_forge/registry.py @@ -2,9 +2,9 @@ from __future__ import annotations -import fcntl import json import logging +import sys from collections.abc import Iterator from contextlib import contextmanager from datetime import datetime @@ -13,6 +13,12 @@ logger = logging.getLogger(__name__) +# Platform-specific imports for file locking +if sys.platform == "win32": + import msvcrt +else: + import fcntl + class RegistryError(Exception): """Base exception for registry operations.""" @@ -47,17 +53,31 @@ def __init__(self, registry_path: Path) -> None: def _lock(self) -> Iterator[None]: """Acquire exclusive lock on registry file. + Uses platform-specific locking mechanisms: + - Unix/Linux/macOS: fcntl.flock + - Windows: msvcrt.locking + Yields: None - context is locked for the duration of the block. """ self._lock_path.parent.mkdir(parents=True, exist_ok=True) with open(self._lock_path, "w") as lock_file: try: - fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX) + if sys.platform == "win32": + # Windows: lock the entire file + msvcrt.locking(lock_file.fileno(), msvcrt.LK_LOCK, 1) + else: + # Unix-like systems: use fcntl + fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX) logger.debug(f"Acquired lock on {self._lock_path}") yield finally: - fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN) + if sys.platform == "win32": + # Windows: unlock the file + msvcrt.locking(lock_file.fileno(), msvcrt.LK_UNLCK, 1) + else: + # Unix-like systems: unlock + fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN) logger.debug(f"Released lock on {self._lock_path}") def _read(self) -> dict[str, Any]: @@ -138,18 +158,34 @@ def add_app(self, app_data: dict[str, Any]) -> None: app_id = app_data["id"] - # Check if app already exists - try: - self.get_app(app_id) - raise AppExistsError(f"PWA '{app_id}' already exists in registry") - except AppNotFoundError: - pass # This is expected - - # Add app to registry - data = self._read() - data["apps"].append(app_data) - self._write(data) - logger.info(f"Added app to registry: {app_id}") + # Atomic read-check-write within a single lock to prevent race conditions + with self._lock(): + # Read current registry + if not self.registry_path.exists(): + data: dict[str, Any] = {"version": 1, "apps": [], "handlers": []} + else: + try: + content = self.registry_path.read_text() + data = json.loads(content) + except json.JSONDecodeError as e: + logger.error(f"Invalid JSON in registry file: {e}") + raise RegistryError(f"Corrupted registry file: {e}") from e + + # Check if app already exists + apps: list[dict[str, Any]] = data.get("apps", []) + for app in apps: + if app.get("id") == app_id: + raise AppExistsError(f"PWA '{app_id}' already exists in registry") + + # Add app to registry + apps.append(app_data) + data["apps"] = apps + + # Write atomically + self.registry_path.parent.mkdir(parents=True, exist_ok=True) + content = json.dumps(data, indent=2) + self.registry_path.write_text(content) + logger.info(f"Added app to registry: {app_id}") def update_app(self, app_id: str, updates: dict[str, Any]) -> None: """Update an existing PWA entry. From a08b78c421947a5dfbbbf2daeb2c05eb5b4b332d Mon Sep 17 00:00:00 2001 From: Pavel Klinger Date: Thu, 23 Oct 2025 19:20:11 +0200 Subject: [PATCH 6/6] :memo: Update documentation with correct repository URL and PyPI status - Replace placeholder 'yourusername' with actual 'bigr' org name - Update git clone URLs from pwa-forge to pwa_forge (correct repo name) - Add prominent notes that package is not yet on PyPI - Provide alternative installation via pip+git - Update all affected docs: USAGE.md, TROUBLESHOOTING.md, Implementation-specification.md - Resolves user testing issues #1 and #2 (PyPI availability and repo URL) --- docs/Implementation-specification.md | 21 ++++++++++++++------- docs/TROUBLESHOOTING.md | 2 +- docs/USAGE.md | 14 ++++++++------ 3 files changed, 23 insertions(+), 14 deletions(-) diff --git a/docs/Implementation-specification.md b/docs/Implementation-specification.md index 0149cfa..a7bae6d 100644 --- a/docs/Implementation-specification.md +++ b/docs/Implementation-specification.md @@ -836,16 +836,23 @@ Development: ### Installation Methods -#### From PyPI +**Note:** PWA Forge is not yet published to PyPI. Install from source: + +#### From Source (Recommended) ```bash -pip install pwa-forge +git clone https://github.com/bigr/pwa_forge.git +cd pwa_forge +pip install -e . ``` -#### From Source +#### Via pip with git ```bash -git clone https://github.com/yourusername/pwa-forge.git -cd pwa-forge -pip install -e . +pip install git+https://github.com/bigr/pwa_forge.git +``` + +#### From PyPI (Future) +```bash +pip install pwa-forge # Not yet available ``` #### System Package (Future) @@ -2661,7 +2668,7 @@ Examples: pwa-forge list --verbose pwa-forge audit chatgpt --fix - For more help: https://github.com/yourusername/pwa-forge + For more help: https://github.com/bigr/pwa_forge ``` ### Add Command Help diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index f7ffc78..61c50f8 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -786,7 +786,7 @@ If your issue isn't covered here: ``` 4. **Search existing issues:** - - GitHub Issues: https://github.com/yourusername/pwa-forge/issues + - GitHub Issues: https://github.com/bigr/pwa_forge/issues 5. **Create a bug report:** Include: diff --git a/docs/USAGE.md b/docs/USAGE.md index 2feb462..1d80881 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -24,14 +24,16 @@ Complete guide to using PWA Forge for managing Progressive Web Apps on Linux. ### Installation -```bash -# Install from PyPI (when published) -pip install pwa-forge +**Note:** PWA Forge is not yet published to PyPI. Please install from source: -# Or install from source -git clone https://github.com/yourusername/pwa-forge.git -cd pwa-forge +```bash +# Install from source +git clone https://github.com/bigr/pwa_forge.git +cd pwa_forge pip install -e . + +# Or install directly via pip with git +pip install git+https://github.com/bigr/pwa_forge.git ``` ### Create Your First PWA