From 0da67c0b562be72acfb1a97676fe51c515665648 Mon Sep 17 00:00:00 2001 From: "Jens W. Klein" Date: Wed, 22 Oct 2025 17:46:37 +0200 Subject: [PATCH 1/4] Modernize pre-commit: replace black with ruff, use isort with plone profile - Replace black with ruff for linting and formatting - Keep isort for import sorting with plone profile and alphabetical rules - Configure ruff with line-length=120, target Python 3.10+ - Add ruff configuration with appropriate rule selections and ignores - Ignore F841 (unused variables) in tests directory - Fix compatibility issues: - Fix undefined s() function in svn.py (use stderr.decode()) - Fix undefined b() function in test_mercurial.py (use b":" literal) - Update pyproject.toml with ruff and isort configurations - Update Makefile (mxmake auto-updated qa.black -> qa.ruff) - Apply ruff-format and isort to all files This modernizes the tooling stack while keeping the project's code style consistent. Ruff provides faster linting/formatting and better Python 3.10+ support. --- .pre-commit-config.yaml | 19 +- Makefile | 83 +++--- PLAN_ISSUE_54.md | 494 +++++++++++++++++++++++++++++++++++ pyproject.toml | 42 ++- src/mxdev/hooks.py | 2 - src/mxdev/including.py | 1 - src/mxdev/logging.py | 4 +- src/mxdev/main.py | 12 +- src/mxdev/processing.py | 11 +- src/mxdev/state.py | 2 - src/mxdev/vcs/bazaar.py | 11 +- src/mxdev/vcs/common.py | 33 +-- src/mxdev/vcs/darcs.py | 16 +- src/mxdev/vcs/filesystem.py | 9 +- src/mxdev/vcs/git.py | 51 +--- src/mxdev/vcs/mercurial.py | 19 +- src/mxdev/vcs/svn.py | 77 ++---- tests/conftest.py | 16 +- tests/test_common.py | 29 +- tests/test_config.py | 35 +-- tests/test_entry_points.py | 12 +- tests/test_git.py | 12 +- tests/test_git_additional.py | 61 ++--- tests/test_git_submodules.py | 106 +++----- tests/test_hooks.py | 26 +- tests/test_logging.py | 9 +- tests/test_main.py | 8 +- tests/test_mercurial.py | 5 +- tests/test_processing.py | 30 ++- tests/test_svn.py | 15 +- tests/test_vcs_filesystem.py | 28 +- tests/utils.py | 2 +- 32 files changed, 785 insertions(+), 495 deletions(-) create mode 100644 PLAN_ISSUE_54.md diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 890150a..5e50557 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,11 +1,20 @@ --- repos: - - repo: https://github.com/psf/black.git - rev: 24.2.0 + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.8.4 hooks: - - id: black - language_version: python3 - exclude: ^(tests\/hooks-abort-render\/hooks|docs\/HelloCookieCutter1) + # Run the linter + - id: ruff + args: [--fix] + # Run the formatter + - id: ruff-format + + - repo: https://github.com/pycqa/isort + rev: 5.13.2 + hooks: + - id: isort + args: [--profile, plone, --force-alphabetical-sort, --force-single-line, --lines-after-imports, "2"] + additional_dependencies: [setuptools] - repo: https://github.com/pre-commit/mirrors-mypy rev: 'v1.9.0' # Use the sha / tag you want to point at diff --git a/Makefile b/Makefile index 80523b1..dbae116 100644 --- a/Makefile +++ b/Makefile @@ -6,9 +6,9 @@ #: core.mxenv #: core.mxfiles #: core.packages -#: qa.black #: qa.isort #: qa.mypy +#: qa.ruff #: qa.test # # SETTINGS (ALL CHANGES MADE BELOW SETTINGS WILL BE LOST) @@ -94,17 +94,17 @@ MXDEV?=mxdev # Default: mxmake MXMAKE?=mxmake -## qa.isort +## qa.ruff -# Source folder to scan for Python files to run isort on. +# Source folder to scan for Python files to run ruff on. # Default: src -ISORT_SRC?=src +RUFF_SRC?=src -## qa.black +## qa.isort -# Source folder to scan for Python files to run black on. +# Source folder to scan for Python files to run isort on. # Default: src -BLACK_SRC?=src +ISORT_SRC?=src ## core.mxfiles @@ -263,6 +263,41 @@ INSTALL_TARGETS+=mxenv DIRTY_TARGETS+=mxenv-dirty CLEAN_TARGETS+=mxenv-clean +############################################################################## +# ruff +############################################################################## + +RUFF_TARGET:=$(SENTINEL_FOLDER)/ruff.sentinel +$(RUFF_TARGET): $(MXENV_TARGET) + @echo "Install Ruff" + @$(PYTHON_PACKAGE_COMMAND) install ruff + @touch $(RUFF_TARGET) + +.PHONY: ruff-check +ruff-check: $(RUFF_TARGET) + @echo "Run ruff check" + @ruff check $(RUFF_SRC) + +.PHONY: ruff-format +ruff-format: $(RUFF_TARGET) + @echo "Run ruff format" + @ruff format $(RUFF_SRC) + +.PHONY: ruff-dirty +ruff-dirty: + @rm -f $(RUFF_TARGET) + +.PHONY: ruff-clean +ruff-clean: ruff-dirty + @test -e $(MXENV_PYTHON) && $(MXENV_PYTHON) -m pip uninstall -y ruff || : + @rm -rf .ruff_cache + +INSTALL_TARGETS+=$(RUFF_TARGET) +CHECK_TARGETS+=ruff-check +FORMAT_TARGETS+=ruff-format +DIRTY_TARGETS+=ruff-dirty +CLEAN_TARGETS+=ruff-clean + ############################################################################## # isort ############################################################################## @@ -297,40 +332,6 @@ FORMAT_TARGETS+=isort-format DIRTY_TARGETS+=isort-dirty CLEAN_TARGETS+=isort-clean -############################################################################## -# black -############################################################################## - -BLACK_TARGET:=$(SENTINEL_FOLDER)/black.sentinel -$(BLACK_TARGET): $(MXENV_TARGET) - @echo "Install Black" - @$(PYTHON_PACKAGE_COMMAND) install black - @touch $(BLACK_TARGET) - -.PHONY: black-check -black-check: $(BLACK_TARGET) - @echo "Run black checks" - @black --check $(BLACK_SRC) - -.PHONY: black-format -black-format: $(BLACK_TARGET) - @echo "Run black format" - @black $(BLACK_SRC) - -.PHONY: black-dirty -black-dirty: - @rm -f $(BLACK_TARGET) - -.PHONY: black-clean -black-clean: black-dirty - @test -e $(MXENV_PYTHON) && $(MXENV_PYTHON) -m pip uninstall -y black || : - -INSTALL_TARGETS+=$(BLACK_TARGET) -CHECK_TARGETS+=black-check -FORMAT_TARGETS+=black-format -DIRTY_TARGETS+=black-dirty -CLEAN_TARGETS+=black-clean - ############################################################################## # mxfiles ############################################################################## diff --git a/PLAN_ISSUE_54.md b/PLAN_ISSUE_54.md new file mode 100644 index 0000000..25ee4ed --- /dev/null +++ b/PLAN_ISSUE_54.md @@ -0,0 +1,494 @@ +# Plan to Solve Issue #54: Add Non-Editable Install Mode + +## Problem Summary +Currently, mxdev always installs local packages as **editable** (with `-e` prefix) in the generated requirements file. This is ideal for development but problematic for deployment/Docker containers where packages should be installed to site-packages as standard packages. + +**Current behavior:** +``` +-e ./sources/iaem.mediaarchive +``` + +**Desired behavior for deployment:** +``` +./sources/iaem.mediaarchive +``` + +## Solution +Update `install-mode` configuration with clearer naming and add non-editable mode: + +**New install modes:** +- `editable` (default): Install as editable with `-e` prefix (development) - **NEW NAME** +- `fixed`: Install as standard package without `-e` (deployment) - **NEW MODE** +- `direct`: Deprecated alias for `editable` (backward compatibility) - **DEPRECATED** +- `skip`: Don't install at all (existing) + +## TDD Implementation Steps + +### Step 1: Write Failing Tests First 🔴 + +Following Test-Driven Development, we write tests that define the desired behavior before implementing any code. + +#### 1.1 Add Test Data Files ([tests/data/config_samples/](tests/data/config_samples/)) + +Create test configuration files first: + +**config_editable_mode.ini:** +```ini +[settings] +default-install-mode = editable + +[example.package] +url = git+https://github.com/example/package.git +``` + +**config_fixed_mode.ini:** +```ini +[settings] +default-install-mode = fixed + +[example.package] +url = git+https://github.com/example/package.git +``` + +**config_deprecated_direct.ini:** +```ini +[settings] +default-install-mode = direct # Should log deprecation warning + +[example.package] +url = git+https://github.com/example/package.git +``` + +**config_package_direct.ini:** +```ini +[settings] +default-install-mode = editable + +[example.package] +url = git+https://github.com/example/package.git +install-mode = direct # Should log deprecation warning +``` + +**Update config_invalid_mode.ini:** +```ini +[settings] +default-install-mode = invalid-mode # Should raise error mentioning valid modes + +[example.package] +url = git+https://github.com/example/package.git +``` + +#### 1.2 Add Configuration Tests ([tests/test_config.py](tests/test_config.py)) + +Add tests that will initially FAIL: + +```python +def test_configuration_editable_install_mode(): + """Test Configuration with editable install-mode (new default).""" + from mxdev.config import Configuration + + base = pathlib.Path(__file__).parent / "data" / "config_samples" + config = Configuration(str(base / "config_editable_mode.ini")) + + # Test that editable mode works + pkg = config.packages["example.package"] + assert pkg["install-mode"] == "editable" + + # Test that it's the default (when not specified) + config2 = Configuration(str(base / "config_minimal.ini")) + pkg2 = config2.packages["example.package"] + assert pkg2["install-mode"] == "editable" + + +def test_configuration_fixed_install_mode(): + """Test Configuration with fixed install-mode (new non-editable mode).""" + from mxdev.config import Configuration + + base = pathlib.Path(__file__).parent / "data" / "config_samples" + config = Configuration(str(base / "config_fixed_mode.ini")) + + # Test that fixed mode works + pkg = config.packages["example.package"] + assert pkg["install-mode"] == "fixed" + + +def test_configuration_direct_mode_deprecated(caplog): + """Test that 'direct' mode shows deprecation warning but still works.""" + from mxdev.config import Configuration + import logging + + base = pathlib.Path(__file__).parent / "data" / "config_samples" + + # Test default-install-mode deprecation + with caplog.at_level(logging.WARNING): + config = Configuration(str(base / "config_deprecated_direct.ini")) + + # Verify deprecation warning is logged + assert "install-mode 'direct' is deprecated" in caplog.text + assert "use 'editable' instead" in caplog.text + + # Verify it's treated as 'editable' internally + pkg = config.packages["example.package"] + assert pkg["install-mode"] == "editable" + + # Test per-package level deprecation + caplog.clear() + with caplog.at_level(logging.WARNING): + config2 = Configuration(str(base / "config_package_direct.ini")) + + assert "install-mode 'direct' in package" in caplog.text + + +def test_configuration_invalid_install_mode_new_message(): + """Test that error messages mention new mode names.""" + from mxdev.config import Configuration + + base = pathlib.Path(__file__).parent / "data" / "config_samples" + + # Test invalid default-install-mode + with pytest.raises(ValueError, match="must be one of 'editable', 'fixed', or 'skip'"): + Configuration(str(base / "config_invalid_mode.ini")) +``` + +**Update existing tests:** +```python +# Update test_configuration_invalid_default_install_mode() +def test_configuration_invalid_default_install_mode(): + """Test Configuration with invalid default-install-mode.""" + from mxdev.config import Configuration + + base = pathlib.Path(__file__).parent / "data" / "config_samples" + with pytest.raises(ValueError, match="default-install-mode must be one of 'editable', 'fixed', or 'skip'"): + Configuration(str(base / "config_invalid_mode.ini")) + + +# Update test_configuration_invalid_package_install_mode() +def test_configuration_invalid_package_install_mode(): + """Test Configuration with invalid package install-mode.""" + from mxdev.config import Configuration + + base = pathlib.Path(__file__).parent / "data" / "config_samples" + with pytest.raises(ValueError, match="install-mode in .* must be one of 'editable', 'fixed', or 'skip'"): + Configuration(str(base / "config_package_invalid_mode.ini")) + + +# Update test_configuration_minimal() to verify new default +def test_configuration_minimal(): + """Test Configuration with minimal settings.""" + from mxdev.config import Configuration + + base = pathlib.Path(__file__).parent / "data" / "config_samples" + config = Configuration(str(base / "config_minimal.ini")) + + pkg = config.packages["example.package"] + assert pkg["install-mode"] == "editable" # Changed from "direct" to "editable" +``` + +#### 1.3 Add Processing Tests ([tests/test_processing.py](tests/test_processing.py)) + +Update and add tests that will initially FAIL: + +```python +def test_write_dev_sources(tmp_path): + """Test write_dev_sources() creates correct output for different install modes.""" + from mxdev.processing import write_dev_sources + + packages = { + "editable.package": { + "target": "sources", + "extras": "", + "subdirectory": "", + "install-mode": "editable", # Should output: -e ./sources/editable.package + }, + "fixed.package": { + "target": "sources", + "extras": "", + "subdirectory": "", + "install-mode": "fixed", # Should output: ./sources/fixed.package (no -e) + }, + "skip.package": { + "target": "sources", + "extras": "", + "subdirectory": "", + "install-mode": "skip", # Should not appear in output + }, + "extras.package": { + "target": "sources", + "extras": "test,docs", + "subdirectory": "packages/core", + "install-mode": "fixed", # Test fixed mode with extras and subdirectory + }, + } + + outfile = tmp_path / "requirements.txt" + with open(outfile, "w") as fio: + write_dev_sources(fio, packages) + + content = outfile.read_text() + + # Verify editable mode includes -e prefix + assert "-e ./sources/editable.package\n" in content + + # Verify fixed mode does NOT include -e prefix + assert "./sources/fixed.package\n" in content + assert "-e ./sources/fixed.package" not in content + + # Verify skip mode is not in output + assert "skip.package" not in content + + # Verify fixed mode with extras and subdirectory + assert "./sources/extras.package/packages/core[test,docs]\n" in content + assert "-e ./sources/extras.package" not in content +``` + +### Step 2: Run Tests to Verify They Fail 🔴 + +Run pytest to confirm tests fail (Red phase): + +```bash +source .venv/bin/activate +pytest tests/test_config.py::test_configuration_editable_install_mode -v +pytest tests/test_config.py::test_configuration_fixed_install_mode -v +pytest tests/test_config.py::test_configuration_direct_mode_deprecated -v +pytest tests/test_processing.py::test_write_dev_sources -v +``` + +Expected: All new tests should FAIL because the implementation doesn't exist yet. + +### Step 3: Implement Configuration Changes 🟢 + +Now implement the code to make tests pass. + +#### 3.1 Update Configuration Validation ([config.py](src/mxdev/config.py)) +**Files:** `src/mxdev/config.py` lines 54-55, 111-113 + +Add deprecation handling and new validation: + +**Changes:** +```python +# Line 53-55: Update default-install-mode validation with deprecation +mode = settings.get("default-install-mode", "editable") # Changed default from "direct" + +# Handle deprecated "direct" mode +if mode == "direct": + logger.warning( + "install-mode 'direct' is deprecated and will be removed in a future version. " + "Please use 'editable' instead." + ) + mode = "editable" # Treat as editable internally + +if mode not in ["editable", "fixed", "skip"]: + raise ValueError( + "default-install-mode must be one of 'editable', 'fixed', or 'skip' " + "('direct' is deprecated, use 'editable')" + ) + +# Line 104: Set package install-mode +package.setdefault("install-mode", mode) + +# Line 111-113: Update per-package install-mode validation with deprecation +pkg_mode = package.get("install-mode") + +# Handle deprecated "direct" mode at package level +if pkg_mode == "direct": + logger.warning( + f"install-mode 'direct' in package [{name}] is deprecated and will be removed " + "in a future version. Please use 'editable' instead." + ) + package["install-mode"] = "editable" # Normalize internally + +if package.get("install-mode") not in ["editable", "fixed", "skip"]: + raise ValueError( + f"install-mode in [{name}] must be one of 'editable', 'fixed', or 'skip' " + "('direct' is deprecated, use 'editable')" + ) +``` + +#### 3.2 Update Processing Logic ([processing.py](src/mxdev/processing.py)) +**Files:** `src/mxdev/processing.py` lines 213-227 + +Modify `write_dev_sources()` function to handle the new modes: + +**Changes:** +```python +def write_dev_sources(fio, packages: typing.Dict[str, typing.Dict[str, typing.Any]]): + """Create requirements configuration for fetched source packages.""" + if not packages: + return + fio.write("#" * 79 + "\n") + fio.write("# mxdev development sources\n") + for name, package in packages.items(): + if package["install-mode"] == "skip": + continue + extras = f"[{package['extras']}]" if package["extras"] else "" + subdir = f"/{package['subdirectory']}" if package["subdirectory"] else "" + + # Add -e prefix only for 'editable' mode (not for 'fixed') + prefix = "-e " if package["install-mode"] == "editable" else "" + install_line = f"""{prefix}./{package['target']}/{name}{subdir}{extras}\n""" + + logger.debug(f"-> {install_line.strip()}") + fio.write(install_line) + fio.write("\n\n") +``` + +### Step 4: Run Tests to Verify They Pass 🟢 + +Run pytest again to confirm all tests now pass (Green phase): + +```bash +source .venv/bin/activate +pytest tests/test_config.py::test_configuration_editable_install_mode -v +pytest tests/test_config.py::test_configuration_fixed_install_mode -v +pytest tests/test_config.py::test_configuration_direct_mode_deprecated -v +pytest tests/test_processing.py::test_write_dev_sources -v + +# Run all tests to ensure nothing broke +pytest tests/test_config.py -v +pytest tests/test_processing.py -v +``` + +Expected: All tests should PASS now. + +### Step 5: Update Documentation + +Once tests pass, update user-facing documentation: + +#### README.md +**Location:** Lines 89 and 223 + +Update the tables describing install-mode: + +```markdown +| `default-install-mode` | Default `install-mode` for packages: `editable`, `fixed`, or `skip` | `editable` | +``` + +```markdown +| `install-mode` | `editable`: Install as editable with `pip install -e PACKAGEPATH` (development)
`fixed`: Install as regular package with `pip install PACKAGEPATH` (deployment)
`skip`: Only clone, don't install

**Note:** `direct` is deprecated, use `editable` | `default-install-mode` | +``` + +**Add a Migration/Deprecation Notice section:** +```markdown +### Deprecation Notice + +**`install-mode = direct` is deprecated** and will be removed in a future version. Please update your configuration to use `install-mode = editable` instead. The behavior is identical - only the name has changed for clarity. + +```ini +# Old (deprecated) +[settings] +default-install-mode = direct + +# New (recommended) +[settings] +default-install-mode = editable +``` + +mxdev will log a warning when deprecated mode names are used. +``` + +#### CLAUDE.md +**Location:** Line 215 + +Update description: +```markdown +- Validates install-mode (`editable`, `fixed`, or `skip`; `direct` deprecated), version overrides, and package settings +``` + +**Location:** Line 289-294 + +Update example showing new modes: +```ini +[package1] +url = git+https://github.com/org/package1.git +branch = feature-branch +extras = test +install-mode = editable # For development (with -e) + +[package2] +url = git+https://github.com/org/package2.git +branch = main +install-mode = fixed # For deployment/production (without -e) + +[package3] +url = git+https://github.com/org/package3.git +install-mode = skip # Clone only, don't install +``` + +### Step 6: Update CHANGES.md + +Add entry: +```markdown +- Fix #54: Add `fixed` install-mode option for non-editable installations. Packages with `install-mode = fixed` are installed as regular packages without the `-e` (editable) flag, making them suitable for deployment/production builds where packages should be installed to site-packages. + + **Breaking change (naming only):** Renamed `direct` to `editable` for clarity. The `direct` mode name is still supported but deprecated and will be removed in a future version. Update your configs to use `install-mode = editable` instead of `install-mode = direct`. A deprecation warning is logged when the old name is used. + + [jensens] +``` + +### Step 7: Update Example Configs (Optional) + +**example/mx.ini** - Update any references to show new naming: +```ini +[settings] +default-install-mode = editable # Development (was: direct) + +[some.package] +install-mode = fixed # For production deployment +``` + +## Testing Strategy + +### Manual Testing +1. Create test config with `install-mode = fixed` +2. Run mxdev +3. Verify generated requirements file has packages without `-e` prefix +4. Test that `pip install -r requirements-mxdev.txt` installs to site-packages +5. Test deprecated `install-mode = direct` shows warning but works + +### Automated Testing +1. Unit tests for config validation (all modes including deprecated) +2. Unit tests for deprecation warnings +3. Unit tests for processing output (editable vs fixed) +4. Integration test verifying end-to-end behavior + +## Backward Compatibility + +✅ **Fully backward compatible** +- `direct` mode continues to work (with deprecation warning) +- Existing configs work unchanged +- Default behavior unchanged (still installs as editable) +- Migration path is clear and documented + +## Deprecation Timeline + +**Current Release:** +- Add `editable` and `fixed` modes +- Make `direct` deprecated alias for `editable` +- Log warning when `direct` is used +- Update all documentation + +**Future Release (e.g., 5.0.0):** +- Remove support for `direct` mode +- Raise error if `direct` is used + +## Files to Modify + +1. `src/mxdev/config.py` (validation + deprecation logic) +2. `src/mxdev/processing.py` (output generation) +3. `README.md` (user documentation + migration guide) +4. `CLAUDE.md` (developer documentation) +5. `tests/test_config.py` (configuration tests + deprecation tests) +6. `tests/test_processing.py` (processing tests) +7. `CHANGES.md` (changelog with breaking change notice) +8. `tests/data/config_samples/` (test fixtures) +9. `example/mx.ini` (if exists - update examples) + +## Summary of Changes + +| Old Mode | New Mode | Behavior | Status | +|----------|----------|----------|--------| +| `direct` | `editable` | Install with `-e` flag | Deprecated alias | +| N/A | `fixed` | Install without `-e` flag | **NEW** | +| `skip` | `skip` | Don't install | Unchanged | + +**Default:** `editable` (same behavior as old `direct`, just clearer name) diff --git a/pyproject.toml b/pyproject.toml index 0c27119..6733e2d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -111,27 +111,51 @@ testpaths = [ ] [tool.isort] -profile = "black" +profile = "plone" force_alphabetical_sort = true force_single_line = true lines_after_imports = 2 +[tool.ruff] +line-length = 120 +target-version = "py310" + +[tool.ruff.lint] +# Enable pycodestyle (E/W), pyflakes (F), and pyupgrade (UP) rules +# Note: isort (I) rules are disabled because we use isort directly +select = ["E", "W", "F", "UP", "D"] +# Ignore specific rules that conflict with our style or are too strict +ignore = [ + "D100", "D101", "D102", "D103", "D104", "D105", "D106", "D107", # Missing docstrings + "D202", # No blank lines allowed after function docstring + "D205", # 1 blank line required between summary line and description + "D301", # Use r""" if any backslashes in a docstring + "D400", # First line should end with a period + "D401", # First line should be in imperative mood + "D415", # First line should end with a period, question mark, or exclamation point +] + +[tool.ruff.format] +# Use tabs instead of spaces (if needed), but default is spaces +quote-style = "double" +indent-style = "space" + +[tool.ruff.lint.per-file-ignores] +# Ignore unused variables in tests (often used for mocking) +"tests/**/*.py" = ["F841"] + [tool.mypy] ignore_missing_imports = true [tool.flake8] -# Excludes due to known issues or incompatibilities with black: -# BLK100: Black would make changes. https://pypi.org/project/flake8-black/ -# W503: https://github.com/psf/black/search?q=W503&unscoped_q=W503 -# E231: https://github.com/psf/black/issues/1202 -ignore = "BLK100,E231,W503,D100,D101,D102,D102,D103,D104,D105,D106,D107,D202,D205" +# Note: flake8 is now largely replaced by ruff, but keeping config for compatibility +# Excludes for docstring rules (now handled by ruff) +ignore = "D100,D101,D102,D103,D104,D105,D106,D107,D202,D205" statistics = 1 -# black official is 88, but can get longer max-line-length = 120 [tool.doc8] -# TODO: Remove current max-line-lengh ignore in follow-up and adopt black limit. -# max-line-length = 88 +# Using 120 character line length to match ruff configuration ignore = "D001" [tool.coverage.run] diff --git a/src/mxdev/hooks.py b/src/mxdev/hooks.py index ff55330..b2d42f7 100644 --- a/src/mxdev/hooks.py +++ b/src/mxdev/hooks.py @@ -1,8 +1,6 @@ from .entry_points import load_eps_by_group from .state import State -import typing - try: # do we have Python 3.12+ diff --git a/src/mxdev/including.py b/src/mxdev/including.py index 3fb16a3..f88e83e 100644 --- a/src/mxdev/including.py +++ b/src/mxdev/including.py @@ -6,7 +6,6 @@ import os import tempfile -import typing def resolve_dependencies( diff --git a/src/mxdev/logging.py b/src/mxdev/logging.py index 1cec1ad..de4f915 100644 --- a/src/mxdev/logging.py +++ b/src/mxdev/logging.py @@ -11,8 +11,6 @@ def setup_logger(level: int) -> None: handler = logging.StreamHandler(sys.stdout) handler.setLevel(level) if level == logging.DEBUG: - formatter = logging.Formatter( - "%(asctime)s - %(name)s - %(levelname)s - %(message)s" - ) + formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s") handler.setFormatter(formatter) root.addHandler(handler) diff --git a/src/mxdev/main.py b/src/mxdev/main.py index bf82040..9d3dd3c 100644 --- a/src/mxdev/main.py +++ b/src/mxdev/main.py @@ -27,15 +27,9 @@ type=str, default="mx.ini", ) -parser.add_argument( - "-n", "--no-fetch", help="Do not fetch sources", action="store_true" -) -parser.add_argument( - "-f", "--fetch-only", help="Only fetch sources", action="store_true" -) -parser.add_argument( - "-o", "--offline", help="Do not fetch sources, work offline", action="store_true" -) +parser.add_argument("-n", "--no-fetch", help="Do not fetch sources", action="store_true") +parser.add_argument("-f", "--fetch-only", help="Only fetch sources", action="store_true") +parser.add_argument("-o", "--offline", help="Do not fetch sources, work offline", action="store_true") parser.add_argument( "-t", "--threads", diff --git a/src/mxdev/processing.py b/src/mxdev/processing.py index f212a4d..8855c7d 100644 --- a/src/mxdev/processing.py +++ b/src/mxdev/processing.py @@ -82,9 +82,7 @@ def process_io( and constraint lists. """ for line in fio: - new_requirements, new_constraints = process_line( - line, package_keys, override_keys, ignore_keys, variety - ) + new_requirements, new_constraints = process_line(line, package_keys, override_keys, ignore_keys, variety) requirements += new_requirements constraints += new_constraints @@ -127,8 +125,7 @@ def resolve_dependencies( ) else: logger.info( - f"Can not read {variety_verbose} file '{file_or_url}', " - "it does not exist. Empty file assumed." + f"Can not read {variety_verbose} file '{file_or_url}', " "it does not exist. Empty file assumed." ) else: try: @@ -237,9 +234,7 @@ def write_dev_overrides(fio, overrides: dict[str, str], package_keys: list[str]) fio.write("# mxdev constraint overrides\n") for pkg, line in overrides.items(): if pkg.lower() in [k.lower() for k in package_keys]: - fio.write( - f"# {line} IGNORE mxdev constraint override. Source override wins!\n" - ) + fio.write(f"# {line} IGNORE mxdev constraint override. Source override wins!\n") else: fio.write(f"{line}\n") fio.write("\n\n") diff --git a/src/mxdev/state.py b/src/mxdev/state.py index 9526e52..a272c3f 100644 --- a/src/mxdev/state.py +++ b/src/mxdev/state.py @@ -2,8 +2,6 @@ from dataclasses import dataclass from dataclasses import field -import typing - @dataclass class State: diff --git a/src/mxdev/vcs/bazaar.py b/src/mxdev/vcs/bazaar.py index 7d1796f..ec07155 100644 --- a/src/mxdev/vcs/bazaar.py +++ b/src/mxdev/vcs/bazaar.py @@ -66,13 +66,10 @@ def checkout(self, **kwargs): if update: self.update(**kwargs) elif self.matches(): - self.output( - (logger.info, f"Skipped checkout of existing package {name!r}.") - ) + self.output((logger.info, f"Skipped checkout of existing package {name!r}.")) else: raise BazaarError( - "Source URL for existing package {!r} differs. " - "Expected {!r}.".format(name, self.source["url"]) + "Source URL for existing package {!r} differs. " "Expected {!r}.".format(name, self.source["url"]) ) else: return self.bzr_branch(**kwargs) @@ -114,9 +111,7 @@ def status(self, **kwargs): def update(self, **kwargs): name = self.source["name"] if not self.matches(): - raise BazaarError( - f"Can't update package {name!r} because its URL doesn't match." - ) + raise BazaarError(f"Can't update package {name!r} because its URL doesn't match.") if self.status() != "clean" and not kwargs.get("force", False): raise BazaarError(f"Can't update package {name!r} because it's dirty.") return self.bzr_pull(**kwargs) diff --git a/src/mxdev/vcs/common.py b/src/mxdev/vcs/common.py index a04849b..a2b1289 100644 --- a/src/mxdev/vcs/common.py +++ b/src/mxdev/vcs/common.py @@ -45,8 +45,7 @@ def which(name_root: str, default: str | None = None) -> str: def version_sorted(inp: list, *args, **kwargs) -> list: - """ - Sorts components versions, it means that numeric parts of version + """Sorts components versions, it means that numeric parts of version treats as numeric and string as string. Eg.: version-1-0-1 < version-1-0-2 < version-1-0-10 @@ -173,9 +172,7 @@ def __init__( self.errors = False self.workingcopytypes = get_workingcopytypes() - def _separate_https_packages( - self, packages: list[str] - ) -> tuple[list[str], list[str]]: + def _separate_https_packages(self, packages: list[str]) -> tuple[list[str], list[str]]: """Separate HTTPS packages from others for smart threading. Returns (https_packages, other_packages) @@ -260,20 +257,12 @@ def _checkout_impl(self, packages: list[str], **kwargs) -> None: elif kwargs["update"].lower() in ("false", "no", "off"): kwargs["update"] = False else: - logger.error( - "Unknown value '{}' for always-checkout option.".format( - kwargs["update"] - ) - ) + logger.error("Unknown value '{}' for always-checkout option.".format(kwargs["update"])) sys.exit(1) kwargs.setdefault("submodules", "always") # XXX: submodules is git related, move to GitWorkingCopy if kwargs["submodules"] not in ["always", "never", "checkout", "recursive"]: - logger.error( - "Unknown value '{}' for update-git-submodules option.".format( - kwargs["submodules"] - ) - ) + logger.error("Unknown value '{}' for update-git-submodules option.".format(kwargs["submodules"])) sys.exit(1) for name in packages: kw = kwargs.copy() @@ -295,9 +284,7 @@ def _checkout_impl(self, packages: list[str], **kwargs) -> None: continue elif update and not kw.get("force", False) and wc.status() != "clean": print_stderr(f"The package '{name}' is dirty.") - answer = yesno( - "Do you want to update it anyway?", default=False, all=True - ) + answer = yesno("Do you want to update it anyway?", default=False, all=True) if answer: kw["force"] = True if answer == "all": @@ -409,9 +396,7 @@ def _update_impl(self, packages: list[str], **kwargs) -> None: wc = wc_class(source) if wc.status() != "clean" and not kw.get("force", False): print_stderr(f"The package '{name}' is dirty.") - answer = yesno( - "Do you want to update it anyway?", default=False, all=True - ) + answer = yesno("Do you want to update it anyway?", default=False, all=True) if answer: kw["force"] = True if answer == "all": @@ -444,11 +429,7 @@ def worker(working_copies: WorkingCopies, the_queue: queue.Queue) -> None: with output_lock: for lvl, msg in wc._output: lvl(msg) - if ( - kwargs.get("verbose", False) - and output is not None - and output.strip() - ): + if kwargs.get("verbose", False) and output is not None and output.strip(): if isinstance(output, bytes): output = output.decode("utf8") print(output) diff --git a/src/mxdev/vcs/darcs.py b/src/mxdev/vcs/darcs.py index 9f56a21..5868b4e 100755 --- a/src/mxdev/vcs/darcs.py +++ b/src/mxdev/vcs/darcs.py @@ -49,9 +49,7 @@ def darcs_update(self, **kwargs) -> str | None: ) stdout, stderr = cmd.communicate() if cmd.returncode != 0: - raise DarcsError( - f"darcs pull for '{name}' failed.\n{stderr.decode('utf8')}" - ) + raise DarcsError(f"darcs pull for '{name}' failed.\n{stderr.decode('utf8')}") if kwargs.get("verbose", False): return stdout.decode("utf8") return None @@ -66,13 +64,9 @@ def checkout(self, **kwargs) -> str | None: self.update(**kwargs) return None if self.matches(): - self.output( - (logger.info, f"Skipped checkout of existing package '{name}'.") - ) + self.output((logger.info, f"Skipped checkout of existing package '{name}'.")) return None - raise DarcsError( - f"Checkout URL for existing package '{name}' differs. Expected '{self.source['url']}'." - ) + raise DarcsError(f"Checkout URL for existing package '{name}' differs. Expected '{self.source['url']}'.") def _darcs_related_repositories(self) -> typing.Generator: name = self.source["name"] @@ -134,9 +128,7 @@ def status(self, **kwargs) -> str | tuple[str, str]: def update(self, **kwargs) -> str | None: name = self.source["name"] if not self.matches(): - raise DarcsError( - f"Can't update package '{name}' because it's URL doesn't match." - ) + raise DarcsError(f"Can't update package '{name}' because it's URL doesn't match.") if self.status() != "clean" and not kwargs.get("force", False): raise DarcsError(f"Can't update package '{name}' because it's dirty.") return self.darcs_update(**kwargs) diff --git a/src/mxdev/vcs/filesystem.py b/src/mxdev/vcs/filesystem.py index 60c5c66..a3076ed 100644 --- a/src/mxdev/vcs/filesystem.py +++ b/src/mxdev/vcs/filesystem.py @@ -1,7 +1,6 @@ from . import common import os -import typing logger = common.logger @@ -25,8 +24,9 @@ def checkout(self, **kwargs) -> str | None: ) else: raise FilesystemError( - "Directory name for existing package {!r} differs. " - "Expected {!r}.".format(name, self.source["url"]) + "Directory name for existing package {!r} differs. " "Expected {!r}.".format( + name, self.source["url"] + ) ) else: raise FilesystemError( @@ -48,8 +48,7 @@ def update(self, **kwargs): name = self.source["name"] if not self.matches(): raise FilesystemError( - "Directory name for existing package {!r} differs. " - "Expected {!r}.".format(name, self.source["url"]) + "Directory name for existing package {!r} differs. " "Expected {!r}.".format(name, self.source["url"]) ) self.output((logger.info, f"Filesystem package {name!r} doesn't need update.")) return "" diff --git a/src/mxdev/vcs/git.py b/src/mxdev/vcs/git.py index 6f8abe7..4d5f420 100644 --- a/src/mxdev/vcs/git.py +++ b/src/mxdev/vcs/git.py @@ -5,7 +5,6 @@ import re import subprocess import sys -import typing logger = common.logger @@ -30,8 +29,7 @@ def __init__(self, source: dict[str, str]): self.git_executable = common.which("git") if "rev" in source and "revision" in source: raise ValueError( - "The source definition of '{}' contains " - "duplicate revision options.".format(source["name"]) + "The source definition of '{}' contains " "duplicate revision options.".format(source["name"]) ) # 'rev' is canonical if "revision" in source: @@ -43,8 +41,7 @@ def __init__(self, source: dict[str, str]): del source["branch"] elif "branch" in source: logger.error( - "Cannot specify both branch (%s) and rev/revision " - "(%s) in source for %s", + "Cannot specify both branch (%s) and rev/revision " "(%s) in source for %s", source["branch"], source["rev"], source["name"], @@ -103,9 +100,7 @@ def run_git(self, commands: list[str], **kwargs) -> subprocess.Popen: kwargs["universal_newlines"] = True return subprocess.Popen(commands, **kwargs) - def git_merge_rbranch( - self, stdout_in: str, stderr_in: str, accept_missing: bool = False - ) -> tuple[str, str]: + def git_merge_rbranch(self, stdout_in: str, stderr_in: str, accept_missing: bool = False) -> tuple[str, str]: path = self.source["path"] branch = self.source.get("branch", "master") @@ -129,9 +124,7 @@ def git_merge_rbranch( cmd = self.run_git(["merge", f"{rbp}/{branch}"], cwd=path) stdout, stderr = cmd.communicate() if cmd.returncode != 0: - raise GitError( - f"git merge of remote branch 'origin/{branch}' failed.\n{stderr}" - ) + raise GitError(f"git merge of remote branch 'origin/{branch}' failed.\n{stderr}") return stdout_in + stdout, stderr_in + stderr def git_checkout(self, **kwargs) -> str | None: @@ -169,9 +162,7 @@ def git_checkout(self, **kwargs) -> str | None: # Update only new submodules that we just registered. this is for safety reasons # as git submodule update on modified submodules may cause code loss for submodule in initialized: - stdout, stderr = self.git_update_submodules( - stdout, stderr, submodule=submodule - ) + stdout, stderr = self.git_update_submodules(stdout, stderr, submodule=submodule) self.output( ( logger.info, @@ -183,9 +174,7 @@ def git_checkout(self, **kwargs) -> str | None: return stdout return None - def git_switch_branch( - self, stdout_in: str, stderr_in: str, accept_missing: bool = False - ) -> tuple[str, str]: + def git_switch_branch(self, stdout_in: str, stderr_in: str, accept_missing: bool = False) -> tuple[str, str]: """Switch branches. If accept_missing is True, we do not switch the branch if it @@ -203,16 +192,12 @@ def git_switch_branch( if "rev" in self.source: # A tag or revision was specified instead of a branch argv = ["checkout", self.source["rev"]] - self.output( - (logger.info, "Switching to rev '{}'.".format(self.source["rev"])) - ) + self.output((logger.info, "Switching to rev '{}'.".format(self.source["rev"]))) elif re.search(rf"^(\*| ) {re.escape(branch)}$", stdout, re.M): # the branch is local, normal checkout will work argv = ["checkout", branch] self.output((logger.info, f"Switching to branch '{branch}'.")) - elif re.search( - "^ " + re.escape(rbp) + r"\/" + re.escape(branch) + "$", stdout, re.M - ): + elif re.search("^ " + re.escape(rbp) + r"\/" + re.escape(branch) + "$", stdout, re.M): # the branch is not local, normal checkout won't work here rbranch = f"{rbp}/{branch}" argv = ["checkout", "-b", branch, rbranch] @@ -266,9 +251,7 @@ def git_update(self, **kwargs) -> str | None: cmd = self.run_git(["checkout", branch_value], cwd=path) tag_stdout, tag_stderr = cmd.communicate() if cmd.returncode != 0: - raise GitError( - f"git checkout of tag '{branch_value}' failed.\n{tag_stderr}" - ) + raise GitError(f"git checkout of tag '{branch_value}' failed.\n{tag_stderr}") stdout += tag_stdout stderr += tag_stderr self.output((logger.info, f"Switched to tag '{branch_value}'.")) @@ -315,16 +298,12 @@ def checkout(self, **kwargs) -> str | None: if update: return self.update(**kwargs) elif self.matches(): - self.output( - (logger.info, f"Skipped checkout of existing package '{name}'.") - ) + self.output((logger.info, f"Skipped checkout of existing package '{name}'.")) else: self.output( ( logger.warning, - "Checkout URL for existing package '{}' differs. Expected '{}'.".format( - name, self.source["url"] - ), + "Checkout URL for existing package '{}' differs. Expected '{}'.".format(name, self.source["url"]), ) ) return None @@ -382,9 +361,7 @@ def git_set_pushurl(self, stdout_in, stderr_in) -> tuple[str, str]: if cmd.returncode != 0: raise GitError( - "git config remote.{}.pushurl {} \nfailed.\n".format( - self._upstream_name, self.source["pushurl"] - ) + "git config remote.{}.pushurl {} \nfailed.\n".format(self._upstream_name, self.source["pushurl"]) ) return (stdout_in + stdout, stderr_in + stderr) @@ -399,9 +376,7 @@ def git_init_submodules(self, stdout_in, stderr_in) -> tuple[str, str, list]: initialized_submodules = re.findall(r'\s+[\'"](.*?)[\'"]\s+\(.+\)', output) return (stdout_in + stdout, stderr_in + stderr, initialized_submodules) - def git_update_submodules( - self, stdout_in, stderr_in, submodule="all", recursive: bool = False - ) -> tuple[str, str]: + def git_update_submodules(self, stdout_in, stderr_in, submodule="all", recursive: bool = False) -> tuple[str, str]: params = ["submodule", "update"] if recursive: params.append("--init") diff --git a/src/mxdev/vcs/mercurial.py b/src/mxdev/vcs/mercurial.py index c0891db..e0f9c31 100644 --- a/src/mxdev/vcs/mercurial.py +++ b/src/mxdev/vcs/mercurial.py @@ -58,9 +58,7 @@ def get_rev(self): if branch != "default": if rev: - raise ValueError( - "'branch' and 'rev' parameters cannot be used simultanously" - ) + raise ValueError("'branch' and 'rev' parameters cannot be used simultanously") else: rev = branch else: @@ -118,9 +116,7 @@ def get_tag_name(line): return [tag for tag in tags if tag and tag != "tip"] def _get_newest_tag(self): - mask = self.source.get( - "newest_tag_prefix", self.source.get("newest_tag_mask", "") - ) + mask = self.source.get("newest_tag_prefix", self.source.get("newest_tag_mask", "")) name = self.source["name"] tags = self._get_tags() if mask: @@ -174,13 +170,10 @@ def checkout(self, **kwargs): if update: self.update(**kwargs) elif self.matches(): - self.output( - (logger.info, f"Skipped checkout of existing package {name!r}.") - ) + self.output((logger.info, f"Skipped checkout of existing package {name!r}.")) else: raise MercurialError( - "Source URL for existing package {!r} differs. " - "Expected {!r}.".format(name, self.source["url"]) + "Source URL for existing package {!r} differs. " "Expected {!r}.".format(name, self.source["url"]) ) else: return self.hg_clone(**kwargs) @@ -235,9 +228,7 @@ def status(self, **kwargs): def update(self, **kwargs): name = self.source["name"] if not self.matches(): - raise MercurialError( - f"Can't update package {name!r} because its URL doesn't match." - ) + raise MercurialError(f"Can't update package {name!r} because its URL doesn't match.") if self.status() != "clean" and not kwargs.get("force", False): raise MercurialError(f"Can't update package {name!r} because it's dirty.") return self.hg_pull(**kwargs) diff --git a/src/mxdev/vcs/svn.py b/src/mxdev/vcs/svn.py index 4430500..7a6c512 100644 --- a/src/mxdev/vcs/svn.py +++ b/src/mxdev/vcs/svn.py @@ -7,7 +7,6 @@ import re import subprocess import sys -import typing import xml.etree.ElementTree as etree @@ -53,9 +52,7 @@ def _normalized_url_rev(self): url[2] = path if "rev" in self.source and "revision" in self.source: raise ValueError( - "The source definition of '{}' contains duplicate revision options.".format( - self.source["name"] - ) + "The source definition of '{}' contains duplicate revision options.".format(self.source["name"]) ) if rev is not None and ("rev" in self.source or "revision" in self.source): raise ValueError( @@ -139,11 +136,7 @@ def _svn_error_wrapper(self, f, **kwargs): common.input_lock.release() common.output_lock.release() continue - print( - "Authorization needed for '{}' at '{}'".format( - self.source["name"], self.source["url"] - ) - ) + print("Authorization needed for '{}' at '{}'".format(self.source["name"], self.source["url"])) user = input("Username: ") passwd = getpass.getpass("Password: ") self._svn_auth_cache[root] = dict( @@ -170,9 +163,7 @@ def _svn_error_wrapper(self, f, **kwargs): if answer.lower() in ["r", "t"]: break else: - print( - "Invalid answer, type 'r' for reject or 't' for temporarily." - ) + print("Invalid answer, type 'r' for reject or 't' for temporarily.") if answer == "r": self._svn_cert_cache[root] = False else: @@ -188,11 +179,7 @@ def _svn_checkout(self, **kwargs): args = [self.svn_executable, "checkout", url, path] stdout, stderr, returncode = self._svn_communicate(args, url, **kwargs) if returncode != 0: - raise SVNError( - "Subversion checkout for '{}' failed.\n{}".format( - name, stderr.decode("utf8") - ) - ) + raise SVNError("Subversion checkout for '{}' failed.\n{}".format(name, stderr.decode("utf8"))) if kwargs.get("verbose", False): return stdout.decode("utf8") @@ -217,15 +204,9 @@ def _svn_communicate(self, args, url, **kwargs): stdout, stderr = cmd.communicate() if cmd.returncode != 0: lines = stderr.strip().split(b"\n") - if ( - "authorization failed" in lines[-1] - or "Could not authenticate to server" in lines[-1] - ): + if "authorization failed" in lines[-1] or "Could not authenticate to server" in lines[-1]: raise SVNAuthorizationError(stderr.strip()) - if ( - "Server certificate verification failed: issuer is not trusted" - in lines[-1] - ): + if "Server certificate verification failed: issuer is not trusted" in lines[-1]: cmd = subprocess.Popen( interactive_args, stdin=subprocess.PIPE, @@ -248,11 +229,7 @@ def _svn_info(self): ) stdout, stderr = cmd.communicate() if cmd.returncode != 0: - raise SVNError( - "Subversion info for '{}' failed.\n{}".format( - name, stderr.decode("utf8") - ) - ) + raise SVNError("Subversion info for '{}' failed.\n{}".format(name, stderr.decode("utf8"))) info = etree.fromstring(stdout) result = {} entry = info.find("entry") @@ -280,11 +257,7 @@ def _svn_switch(self, **kwargs): args.insert(2, f"-r{rev}") stdout, stderr, returncode = self._svn_communicate(args, url, **kwargs) if returncode != 0: - raise SVNError( - "Subversion switch of '{}' failed.\n{}".format( - name, stderr.decode("utf8") - ) - ) + raise SVNError("Subversion switch of '{}' failed.\n{}".format(name, stderr.decode("utf8"))) if kwargs.get("verbose", False): return stdout.decode("utf8") @@ -297,11 +270,7 @@ def _svn_update(self, **kwargs): args.insert(2, f"-r{rev}") stdout, stderr, returncode = self._svn_communicate(args, url, **kwargs) if returncode != 0: - raise SVNError( - "Subversion update of '{}' failed.\n{}".format( - name, stderr.decode("utf8") - ) - ) + raise SVNError("Subversion update of '{}' failed.\n{}".format(name, stderr.decode("utf8"))) if kwargs.get("verbose", False): return stdout.decode("utf8") @@ -309,9 +278,7 @@ def svn_checkout(self, **kwargs): name = self.source["name"] path = self.source["path"] if os.path.exists(path): - self.output( - (logger.info, f"Skipped checkout of existing package '{name}'.") - ) + self.output((logger.info, f"Skipped checkout of existing package '{name}'.")) return self.output((logger.info, f"Checked out '{name}' with subversion.")) return self._svn_error_wrapper(self._svn_checkout, **kwargs) @@ -349,15 +316,9 @@ def checkout(self, **kwargs): url = self._svn_info().get("url", "") if url: msg = f"The current checkout of '{name}' is from '{url}'." - msg += ( - "\nCan't switch package to '{}' because it's dirty.".format( - self.source["url"] - ) - ) + msg += "\nCan't switch package to '{}' because it's dirty.".format(self.source["url"]) else: - msg = "Can't switch package '{}' to '{}' because it's dirty.".format( - name, self.source["url"] - ) + msg = "Can't switch package '{}' to '{}' because it's dirty.".format(name, self.source["url"]) raise SVNError(msg) else: return self.svn_checkout(**kwargs) @@ -370,13 +331,9 @@ def matches(self): if rev is None: rev = info.get("revision") if rev.startswith(">="): - return (info.get("url") == url) and ( - int(info.get("revision")) >= int(rev[2:]) - ) + return (info.get("url") == url) and (int(info.get("revision")) >= int(rev[2:])) elif rev.startswith(">"): - return (info.get("url") == url) and ( - int(info.get("revision")) > int(rev[1:]) - ) + return (info.get("url") == url) and (int(info.get("revision")) > int(rev[1:])) else: return (info.get("url") == url) and (info.get("revision") == rev) @@ -390,7 +347,7 @@ def status(self, **kwargs): ) stdout, stderr = cmd.communicate() if cmd.returncode != 0: - raise SVNError(f"Subversion status for '{name}' failed.\n{s(stderr)}") + raise SVNError(f"Subversion status for '{name}' failed.\n{stderr.decode()}") info = etree.fromstring(stdout) clean = True for target in info.findall("target"): @@ -408,9 +365,7 @@ def status(self, **kwargs): ) stdout, stderr = cmd.communicate() if cmd.returncode != 0: - raise SVNError( - f"Subversion status for '{name}' failed.\n{stderr.decode('utf8')}" - ) + raise SVNError(f"Subversion status for '{name}' failed.\n{stderr.decode('utf8')}") return status, stdout.decode("utf8") return status diff --git a/tests/conftest.py b/tests/conftest.py index 523dc25..d29cad0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,8 +1,8 @@ +from utils import Process + import os import pytest -from utils import Process - @pytest.fixture def tempdir(tmp_path): @@ -37,19 +37,13 @@ def _mkgitrepo(name): @pytest.fixture def git_allow_file_protocol(): - """ - Allow file protocol + """Allow file protocol This is needed for the submodule to be added from a local path """ - from utils import GitRepo shell = Process() - file_allow = ( - shell.check_call("git config --global --get protocol.file.allow")[0] - .decode("utf8") - .strip() - ) - shell.check_call(f"git config --global protocol.file.allow always") + file_allow = shell.check_call("git config --global --get protocol.file.allow")[0].decode("utf8").strip() + shell.check_call("git config --global protocol.file.allow always") yield file_allow shell.check_call(f"git config --global protocol.file.allow {file_allow}") diff --git a/tests/test_common.py b/tests/test_common.py index 54f7665..a6b957f 100644 --- a/tests/test_common.py +++ b/tests/test_common.py @@ -5,7 +5,6 @@ import os import pytest import queue -import typing def test_print_stderr(mocker): @@ -182,9 +181,7 @@ def __init__(self): with pytest.raises(SysExit): wc.checkout(packages=[], submodules="invalid") - assert caplog.messages == [ - "Unknown value 'invalid' for update-git-submodules option." - ] + assert caplog.messages == ["Unknown value 'invalid' for update-git-submodules option."] caplog.clear() with pytest.raises(SysExit): @@ -209,9 +206,7 @@ def __init__(self): caplog.clear() package_dir = tmpdir.mkdir("package_dir") - os.symlink( - package_dir.strpath, tmpdir.join("package").strpath, target_is_directory=True - ) + os.symlink(package_dir.strpath, tmpdir.join("package").strpath, target_is_directory=True) wc.checkout(packages=["package"], update=True) assert caplog.messages == ["Skipped update of linked 'package'."] caplog.clear() @@ -322,9 +317,7 @@ def update(self, **kwargs): exit_mock = mocker.patch("sys.exit") mocker.patch("mxdev.vcs.common._workingcopytypes", {"test": TestWorkingCopy}) - wc = common.WorkingCopies( - sources={"package": {"vcs": "test", "name": "package", "url": "test://url"}} - ) + wc = common.WorkingCopies(sources={"package": {"vcs": "test", "name": "package", "url": "test://url"}}) # Test successful match result = wc.matches({"name": "package"}) @@ -385,9 +378,7 @@ def update(self, **kwargs): exit_mock = mocker.patch("sys.exit") mocker.patch("mxdev.vcs.common._workingcopytypes", {"test": TestWorkingCopy}) - wc = common.WorkingCopies( - sources={"package": {"vcs": "test", "name": "package", "url": "test://url"}} - ) + wc = common.WorkingCopies(sources={"package": {"vcs": "test", "name": "package", "url": "test://url"}}) # Test successful status result = wc.status({"name": "package"}) @@ -455,9 +446,7 @@ def update(self, **kwargs): package_dir = tmp_path / "package" package_dir.mkdir() wc = common.WorkingCopies( - sources={ - "package": {"vcs": "test", "name": "package", "path": str(package_dir)} - }, + sources={"package": {"vcs": "test", "name": "package", "path": str(package_dir)}}, threads=1, ) @@ -473,9 +462,7 @@ def update(self, **kwargs): caplog.clear() # Test with unregistered VCS type - wc.sources = { - "package": {"vcs": "unknown", "name": "package", "path": str(package_dir)} - } + wc.sources = {"package": {"vcs": "unknown", "name": "package", "path": str(package_dir)}} try: wc.update(packages=["package"]) except TypeError: @@ -492,9 +479,7 @@ def update(self, **kwargs): print_stderr = mocker.patch("mxdev.vcs.common.print_stderr") TestWorkingCopy.package_status = "dirty" - wc.sources = { - "package": {"vcs": "test", "name": "package", "path": str(package_dir)} - } + wc.sources = {"package": {"vcs": "test", "name": "package", "path": str(package_dir)}} wc.update(packages=["package"]) print_stderr.assert_called_with("The package 'package' is dirty.") assert "Skipped update of 'package'." in caplog.text diff --git a/tests/test_config.py b/tests/test_config.py index e643b12..de0b322 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -42,10 +42,7 @@ def test_configuration_basic(): assert config.settings["requirements-out"] == "requirements-mxdev.txt" assert config.settings["constraints-out"] == "constraints-mxdev.txt" assert "example.package" in config.packages - assert ( - config.packages["example.package"]["url"] - == "https://github.com/example/package.git" - ) + assert config.packages["example.package"]["url"] == "https://github.com/example/package.git" assert config.packages["example.package"]["branch"] == "main" @@ -123,10 +120,7 @@ def test_configuration_direct_mode_deprecated(caplog): assert config.packages["example.package"]["install-mode"] == "editable" # Should have logged deprecation warning - assert any( - "install-mode 'direct' is deprecated" in record.message - for record in caplog.records - ) + assert any("install-mode 'direct' is deprecated" in record.message for record in caplog.records) def test_configuration_package_direct_mode_deprecated(caplog): @@ -140,10 +134,7 @@ def test_configuration_package_direct_mode_deprecated(caplog): assert config.packages["example.package"]["install-mode"] == "editable" # Should have logged deprecation warning - assert any( - "install-mode 'direct' is deprecated" in record.message - for record in caplog.records - ) + assert any("install-mode 'direct' is deprecated" in record.message for record in caplog.records) def test_configuration_invalid_default_install_mode(): @@ -201,9 +192,7 @@ def test_configuration_override_args_offline(): from mxdev.config import Configuration base = pathlib.Path(__file__).parent / "data" / "config_samples" - config = Configuration( - str(base / "basic_config.ini"), override_args={"offline": True} - ) + config = Configuration(str(base / "basic_config.ini"), override_args={"offline": True}) assert config.settings["offline"] == "true" # Package should inherit offline setting @@ -215,9 +204,7 @@ def test_configuration_override_args_threads(): from mxdev.config import Configuration base = pathlib.Path(__file__).parent / "data" / "config_samples" - config = Configuration( - str(base / "basic_config.ini"), override_args={"threads": 16} - ) + config = Configuration(str(base / "basic_config.ini"), override_args={"threads": 16}) assert config.settings["threads"] == "16" @@ -267,9 +254,7 @@ def test_per_package_target_override(): # Normalize paths for comparison (handles both Unix / and Windows \) assert ( pathlib.Path(pkg_default["path"]).as_posix() - == pathlib.Path(pkg_default["target"]) - .joinpath("package.with.default.target") - .as_posix() + == pathlib.Path(pkg_default["target"]).joinpath("package.with.default.target").as_posix() ) # Package with custom target should use its own target @@ -277,9 +262,7 @@ def test_per_package_target_override(): assert pkg_custom["target"] == "custom-dir" assert ( pathlib.Path(pkg_custom["path"]).as_posix() - == pathlib.Path(pkg_custom["target"]) - .joinpath("package.with.custom.target") - .as_posix() + == pathlib.Path(pkg_custom["target"]).joinpath("package.with.custom.target").as_posix() ) # Package with interpolated target should use the interpolated value @@ -287,7 +270,5 @@ def test_per_package_target_override(): assert pkg_interpolated["target"] == "documentation" assert ( pathlib.Path(pkg_interpolated["path"]).as_posix() - == pathlib.Path(pkg_interpolated["target"]) - .joinpath("package.with.interpolated.target") - .as_posix() + == pathlib.Path(pkg_interpolated["target"]).joinpath("package.with.interpolated.target").as_posix() ) diff --git a/tests/test_entry_points.py b/tests/test_entry_points.py index 4c46370..45de7fa 100644 --- a/tests/test_entry_points.py +++ b/tests/test_entry_points.py @@ -1,5 +1,5 @@ -import pytest -from unittest.mock import patch, MagicMock +from unittest.mock import MagicMock +from unittest.mock import patch def test_has_importlib_entrypoints_constant(): @@ -22,9 +22,7 @@ def test_load_eps_by_group_with_python312(): mock_eps = [mock_ep1, mock_ep2] with patch("mxdev.entry_points.HAS_IMPORTLIB_ENTRYPOINTS", True): - with patch( - "mxdev.entry_points.entry_points", return_value=mock_eps - ) as mock_entry_points: + with patch("mxdev.entry_points.entry_points", return_value=mock_eps) as mock_entry_points: result = load_eps_by_group("test-group") # Should call entry_points with group parameter @@ -48,9 +46,7 @@ def test_load_eps_by_group_with_old_python(): mock_eps_dict = {"test-group": [mock_ep1, mock_ep2], "other-group": []} with patch("mxdev.entry_points.HAS_IMPORTLIB_ENTRYPOINTS", False): - with patch( - "mxdev.entry_points.entry_points", return_value=mock_eps_dict - ) as mock_entry_points: + with patch("mxdev.entry_points.entry_points", return_value=mock_eps_dict) as mock_entry_points: result = load_eps_by_group("test-group") # Should call entry_points without parameters diff --git a/tests/test_git.py b/tests/test_git.py index 935dceb..f33233c 100644 --- a/tests/test_git.py +++ b/tests/test_git.py @@ -1,16 +1,14 @@ # pylint: disable=redefined-outer-name from logging import getLogger from logging import Logger -from struct import pack from unittest.mock import patch - -import os -import pytest - from utils import vcs_checkout from utils import vcs_status from utils import vcs_update +import os +import pytest + logger: Logger = getLogger("vcs_test_git") @@ -110,9 +108,7 @@ def test_update_with_revision_pin_branch(mkgitrepo, src): vcs_update(sources, packages, verbose) assert {x for x in path.iterdir()} == {path / ".git", path / "foo", path / "foo2"} - sources = { - "egg": dict(vcs="git", name="egg", url=str(repository.base), path=str(path)) - } + sources = {"egg": dict(vcs="git", name="egg", url=str(repository.base), path=str(path))} vcs_update(sources, packages, verbose) assert {x for x in path.iterdir()} == {path / ".git", path / "bar", path / "foo"} diff --git a/tests/test_git_additional.py b/tests/test_git_additional.py index 33807a6..1b7bc0b 100644 --- a/tests/test_git_additional.py +++ b/tests/test_git_additional.py @@ -1,15 +1,15 @@ """Additional tests for git.py to increase coverage to >90%.""" -import os +from unittest.mock import Mock +from unittest.mock import patch + import pytest -from unittest.mock import Mock, patch, MagicMock -import sys def test_git_error_class(): """Test GitError exception class.""" - from mxdev.vcs.git import GitError from mxdev.vcs.common import WCError + from mxdev.vcs.git import GitError assert issubclass(GitError, WCError) @@ -228,7 +228,8 @@ def test_remote_branch_prefix_new_git(): def test_git_merge_rbranch_failure(): """Test git_merge_rbranch handles git branch failure.""" - from mxdev.vcs.git import GitWorkingCopy, GitError + from mxdev.vcs.git import GitError + from mxdev.vcs.git import GitWorkingCopy with patch("mxdev.vcs.common.which", return_value="/usr/bin/git"): source = { @@ -298,7 +299,8 @@ def test_git_merge_rbranch_missing_branch_no_accept(): def test_git_merge_rbranch_merge_failure(): """Test git_merge_rbranch handles merge failure.""" - from mxdev.vcs.git import GitWorkingCopy, GitError + from mxdev.vcs.git import GitError + from mxdev.vcs.git import GitWorkingCopy with patch("mxdev.vcs.common.which", return_value="/usr/bin/git"): source = { @@ -349,7 +351,8 @@ def test_git_checkout_existing_path(tmp_path): def test_git_checkout_clone_failure(): """Test git_checkout handles clone failure.""" - from mxdev.vcs.git import GitWorkingCopy, GitError + from mxdev.vcs.git import GitError + from mxdev.vcs.git import GitWorkingCopy with patch("mxdev.vcs.common.which", return_value="/usr/bin/git"): source = { @@ -442,9 +445,7 @@ def test_git_checkout_with_pushurl(): mock_process.communicate.return_value = ("", "") with patch.object(wc, "run_git", return_value=mock_process): - with patch.object( - wc, "git_set_pushurl", return_value=("", "") - ) as mock_pushurl: + with patch.object(wc, "git_set_pushurl", return_value=("", "")) as mock_pushurl: wc.git_checkout(submodules="never") # Verify git_set_pushurl was called mock_pushurl.assert_called_once() @@ -452,7 +453,8 @@ def test_git_checkout_with_pushurl(): def test_git_set_pushurl_failure(): """Test git_set_pushurl handles failure.""" - from mxdev.vcs.git import GitWorkingCopy, GitError + from mxdev.vcs.git import GitError + from mxdev.vcs.git import GitWorkingCopy with patch("mxdev.vcs.common.which", return_value="/usr/bin/git"): source = { @@ -475,7 +477,8 @@ def test_git_set_pushurl_failure(): def test_git_init_submodules_failure(): """Test git_init_submodules handles failure.""" - from mxdev.vcs.git import GitWorkingCopy, GitError + from mxdev.vcs.git import GitError + from mxdev.vcs.git import GitWorkingCopy with patch("mxdev.vcs.common.which", return_value="/usr/bin/git"): source = { @@ -522,7 +525,8 @@ def test_git_init_submodules_stderr_output(): def test_git_update_submodules_failure(): """Test git_update_submodules handles failure.""" - from mxdev.vcs.git import GitWorkingCopy, GitError + from mxdev.vcs.git import GitError + from mxdev.vcs.git import GitWorkingCopy with patch("mxdev.vcs.common.which", return_value="/usr/bin/git"): source = { @@ -606,9 +610,7 @@ def test_checkout_path_not_exist(): wc = GitWorkingCopy(source) - with patch.object( - wc, "git_checkout", return_value="checkout output" - ) as mock_checkout: + with patch.object(wc, "git_checkout", return_value="checkout output") as mock_checkout: result = wc.checkout(submodules="never") # Should call git_checkout @@ -631,9 +633,7 @@ def test_checkout_update_needed(): with patch("os.path.exists", return_value=True): with patch.object(wc, "should_update", return_value=True): - with patch.object( - wc, "update", return_value="update output" - ) as mock_update: + with patch.object(wc, "update", return_value="update output") as mock_update: result = wc.checkout() mock_update.assert_called_once() @@ -664,7 +664,8 @@ def test_checkout_no_update_doesnt_match(): def test_matches_failure(): """Test matches() handles git remote failure.""" - from mxdev.vcs.git import GitWorkingCopy, GitError + from mxdev.vcs.git import GitError + from mxdev.vcs.git import GitWorkingCopy with patch("mxdev.vcs.common.which", return_value="/usr/bin/git"): source = { @@ -699,9 +700,7 @@ def test_update_not_matching(): with patch.object(wc, "matches", return_value=False): with patch.object(wc, "status", return_value="clean"): - with patch.object( - wc, "git_update", return_value="updated" - ) as mock_git_update: + with patch.object(wc, "git_update", return_value="updated") as mock_git_update: result = wc.update() # Should still call git_update even if not matching @@ -710,7 +709,8 @@ def test_update_not_matching(): def test_update_dirty_no_force(): """Test update with dirty status and no force.""" - from mxdev.vcs.git import GitWorkingCopy, GitError + from mxdev.vcs.git import GitError + from mxdev.vcs.git import GitWorkingCopy with patch("mxdev.vcs.common.which", return_value="/usr/bin/git"): source = { @@ -742,9 +742,7 @@ def test_update_dirty_with_force(): with patch.object(wc, "matches", return_value=True): with patch.object(wc, "status", return_value="dirty"): - with patch.object( - wc, "git_update", return_value="updated" - ) as mock_git_update: + with patch.object(wc, "git_update", return_value="updated") as mock_git_update: result = wc.update(force=True) # Should call git_update when forced @@ -753,7 +751,8 @@ def test_update_dirty_with_force(): def test_git_update_fetch_failure(): """Test git_update handles fetch failure.""" - from mxdev.vcs.git import GitWorkingCopy, GitError + from mxdev.vcs.git import GitError + from mxdev.vcs.git import GitWorkingCopy with patch("mxdev.vcs.common.which", return_value="/usr/bin/git"): source = { @@ -865,7 +864,8 @@ def test_status_verbose(): def test_git_switch_branch_failure(): """Test git_switch_branch handles branch -a failure.""" - from mxdev.vcs.git import GitWorkingCopy, GitError + from mxdev.vcs.git import GitError + from mxdev.vcs.git import GitWorkingCopy with patch("mxdev.vcs.common.which", return_value="/usr/bin/git"): source = { @@ -936,7 +936,8 @@ def test_git_switch_branch_missing_accept(): def test_git_switch_branch_checkout_failure(): """Test git_switch_branch handles checkout failure.""" - from mxdev.vcs.git import GitWorkingCopy, GitError + from mxdev.vcs.git import GitError + from mxdev.vcs.git import GitWorkingCopy with patch("mxdev.vcs.common.which", return_value="/usr/bin/git"): source = { diff --git a/tests/test_git_submodules.py b/tests/test_git_submodules.py index 36386b1..a55a099 100644 --- a/tests/test_git_submodules.py +++ b/tests/test_git_submodules.py @@ -1,20 +1,15 @@ from unittest.mock import patch - -import os -import pytest - from utils import GitRepo from utils import vcs_checkout from utils import vcs_update +import os +import pytest -@pytest.mark.skipif( - condition=os.name == "nt", reason="submodules seem not to work on windows" -) + +@pytest.mark.skipif(condition=os.name == "nt", reason="submodules seem not to work on windows") def test_checkout_with_submodule(mkgitrepo, src, caplog, git_allow_file_protocol): - """ - Tests the checkout of a module 'egg' with a submodule 'submodule_a' in itith - """ + """Tests the checkout of a module 'egg' with a submodule 'submodule_a' in itith""" submodule_name = "submodule_a" submodule_a = mkgitrepo(submodule_name) @@ -48,12 +43,9 @@ def test_checkout_with_submodule(mkgitrepo, src, caplog, git_allow_file_protocol ) -@pytest.mark.skipif( - condition=os.name == "nt", reason="submodules seem not to work on windows" -) +@pytest.mark.skipif(condition=os.name == "nt", reason="submodules seem not to work on windows") def test_checkout_with_two_submodules(mkgitrepo, src, git_allow_file_protocol): - """ - Tests the checkout of a module 'egg' with a submodule 'submodule_a' + """Tests the checkout of a module 'egg' with a submodule 'submodule_a' and a submodule 'submodule_b' in it. """ @@ -100,14 +92,9 @@ def test_checkout_with_two_submodules(mkgitrepo, src, git_allow_file_protocol): ] -@pytest.mark.skipif( - condition=os.name == "nt", reason="submodules seem not to work on windows" -) -def test_checkout_with_two_submodules_recursive( - mkgitrepo, src, git_allow_file_protocol -): - """ - Tests the checkout of a module 'egg' with a submodule 'submodule_a' +@pytest.mark.skipif(condition=os.name == "nt", reason="submodules seem not to work on windows") +def test_checkout_with_two_submodules_recursive(mkgitrepo, src, git_allow_file_protocol): + """Tests the checkout of a module 'egg' with a submodule 'submodule_a' and a submodule 'submodule_b' in it. but this time we test it with the "recursive" option """ @@ -145,12 +132,9 @@ def test_checkout_with_two_submodules_recursive( ] -@pytest.mark.skipif( - condition=os.name == "nt", reason="submodules seem not to work on windows" -) +@pytest.mark.skipif(condition=os.name == "nt", reason="submodules seem not to work on windows") def test_update_with_submodule(mkgitrepo, src, git_allow_file_protocol): - """ - Tests the checkout of a module 'egg' with a submodule 'submodule_a' in it. + """Tests the checkout of a module 'egg' with a submodule 'submodule_a' in it. Add a new 'submodule_b' to 'egg' and check it succesfully initializes. """ submodule_name = "submodule_a" @@ -208,12 +192,9 @@ def test_update_with_submodule(mkgitrepo, src, git_allow_file_protocol): ] -@pytest.mark.skipif( - condition=os.name == "nt", reason="submodules seem not to work on windows" -) +@pytest.mark.skipif(condition=os.name == "nt", reason="submodules seem not to work on windows") def test_update_with_submodule_recursive(mkgitrepo, src, git_allow_file_protocol): - """ - Tests the checkout of a module 'egg' with a submodule 'submodule_a' in it. + """Tests the checkout of a module 'egg' with a submodule 'submodule_a' in it. Add a new 'submodule_b' to 'egg' and check it succesfully initializes. """ submodule_name = "submodule_a" @@ -256,8 +237,8 @@ def test_update_with_submodule_recursive(mkgitrepo, src, git_allow_file_protocol "foo_b", } assert log.method_calls == [ - ("info", (f"Updated 'egg' with git.",)), - ("info", (f"Switching to branch 'master'.",)), + ("info", ("Updated 'egg' with git.",)), + ("info", ("Switching to branch 'master'.",)), ( "info", (f"Initialized 'egg' submodule at '{submodule_b_name}' with git.",), @@ -265,12 +246,9 @@ def test_update_with_submodule_recursive(mkgitrepo, src, git_allow_file_protocol ] -@pytest.mark.skipif( - condition=os.name == "nt", reason="submodules seem not to work on windows" -) +@pytest.mark.skipif(condition=os.name == "nt", reason="submodules seem not to work on windows") def test_checkout_with_submodules_option_never(mkgitrepo, src, git_allow_file_protocol): - """ - Tests the checkout of a module 'egg' with a submodule 'submodule_a' in it + """Tests the checkout of a module 'egg' with a submodule 'submodule_a' in it without initializing the submodule, restricted by global 'never' """ @@ -292,19 +270,12 @@ def test_checkout_with_submodules_option_never(mkgitrepo, src, git_allow_file_pr ".gitmodules", } assert set(os.listdir(src / "egg" / submodule_name)) == set() - assert log.method_calls == [ - ("info", (f"Cloned 'egg' with git from '{egg.url}'.",), {}) - ] + assert log.method_calls == [("info", (f"Cloned 'egg' with git from '{egg.url}'.",), {})] -@pytest.mark.skipif( - condition=os.name == "nt", reason="submodules seem not to work on windows" -) -def test_checkout_with_submodules_option_never_source_always( - mkgitrepo, src, git_allow_file_protocol -): - """ - Tests the checkout of a module 'egg' with a submodule 'submodule_a' in it +@pytest.mark.skipif(condition=os.name == "nt", reason="submodules seem not to work on windows") +def test_checkout_with_submodules_option_never_source_always(mkgitrepo, src, git_allow_file_protocol): + """Tests the checkout of a module 'egg' with a submodule 'submodule_a' in it and a module 'egg2' with the same submodule, initializing only the submodule on egg that has the 'always' option """ @@ -331,9 +302,7 @@ def test_checkout_with_submodules_option_never_source_always( "egg2": dict(vcs="git", name="egg2", url=egg2.url, path=src / "egg2"), } with patch("mxdev.vcs.git.logger") as log: - vcs_checkout( - sources, ["egg", "egg2"], verbose=False, update_git_submodules="never" - ) + vcs_checkout(sources, ["egg", "egg2"], verbose=False, update_git_submodules="never") assert set(os.listdir(src / "egg")) == { "submodule_a", ".git", @@ -360,14 +329,9 @@ def test_checkout_with_submodules_option_never_source_always( ] -@pytest.mark.skipif( - condition=os.name == "nt", reason="submodules seem not to work on windows" -) -def test_checkout_with_submodules_option_always_source_never( - mkgitrepo, src, git_allow_file_protocol -): - """ - Tests the checkout of a module 'egg' with a submodule 'submodule_a' in it +@pytest.mark.skipif(condition=os.name == "nt", reason="submodules seem not to work on windows") +def test_checkout_with_submodules_option_always_source_never(mkgitrepo, src, git_allow_file_protocol): + """Tests the checkout of a module 'egg' with a submodule 'submodule_a' in it and a module 'egg2' with the same submodule, not initializing the submodule on egg2 that has the 'never' option @@ -422,12 +386,9 @@ def test_checkout_with_submodules_option_always_source_never( ] -@pytest.mark.skipif( - condition=os.name == "nt", reason="submodules seem not to work on windows" -) +@pytest.mark.skipif(condition=os.name == "nt", reason="submodules seem not to work on windows") def test_update_with_submodule_checkout(mkgitrepo, src, git_allow_file_protocol): - """ - Tests the checkout of a module 'egg' with a submodule 'submodule_a' in it. + """Tests the checkout of a module 'egg' with a submodule 'submodule_a' in it. Add a new 'submodule_b' to 'egg' and check it doesn't get initialized. """ @@ -486,14 +447,9 @@ def test_update_with_submodule_checkout(mkgitrepo, src, git_allow_file_protocol) ] -@pytest.mark.skipif( - condition=os.name == "nt", reason="submodules seem not to work on windows" -) -def test_update_with_submodule_dont_update_previous_submodules( - mkgitrepo, src, git_allow_file_protocol -): - """ - Tests the checkout of a module 'egg' with a submodule 'submodule_a' in it. +@pytest.mark.skipif(condition=os.name == "nt", reason="submodules seem not to work on windows") +def test_update_with_submodule_dont_update_previous_submodules(mkgitrepo, src, git_allow_file_protocol): + """Tests the checkout of a module 'egg' with a submodule 'submodule_a' in it. Commits changes in the detached submodule, and checks update didn't break the changes. """ diff --git a/tests/test_hooks.py b/tests/test_hooks.py index 422ceb8..6ce7856 100644 --- a/tests/test_hooks.py +++ b/tests/test_hooks.py @@ -1,5 +1,5 @@ -import pytest -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock +from unittest.mock import patch def test_hook_class_exists(): @@ -25,9 +25,10 @@ def test_hook_class_can_be_instantiated(): def test_hook_read_method(): """Test Hook class has read method.""" + from mxdev.config import Configuration from mxdev.hooks import Hook from mxdev.state import State - from mxdev.config import Configuration + import pathlib base = pathlib.Path(__file__).parent / "data" / "config_samples" @@ -42,9 +43,10 @@ def test_hook_read_method(): def test_hook_write_method(): """Test Hook class has write method.""" + from mxdev.config import Configuration from mxdev.hooks import Hook from mxdev.state import State - from mxdev.config import Configuration + import pathlib base = pathlib.Path(__file__).parent / "data" / "config_samples" @@ -100,9 +102,7 @@ def test_load_hooks_filters_by_name(): mock_ep_other = MagicMock() mock_ep_other.name = "other" - with patch( - "mxdev.hooks.load_eps_by_group", return_value=[mock_ep_hook, mock_ep_other] - ): + with patch("mxdev.hooks.load_eps_by_group", return_value=[mock_ep_hook, mock_ep_other]): hooks = load_hooks() assert len(hooks) == 1 assert hooks[0] == mock_hook_instance @@ -112,9 +112,10 @@ def test_load_hooks_filters_by_name(): def test_read_hooks(): """Test read_hooks calls read on all hooks.""" + from mxdev.config import Configuration from mxdev.hooks import read_hooks from mxdev.state import State - from mxdev.config import Configuration + import pathlib base = pathlib.Path(__file__).parent / "data" / "config_samples" @@ -133,9 +134,10 @@ def test_read_hooks(): def test_read_hooks_empty_list(): """Test read_hooks with empty hooks list.""" + from mxdev.config import Configuration from mxdev.hooks import read_hooks from mxdev.state import State - from mxdev.config import Configuration + import pathlib base = pathlib.Path(__file__).parent / "data" / "config_samples" @@ -148,9 +150,10 @@ def test_read_hooks_empty_list(): def test_write_hooks(): """Test write_hooks calls write on all hooks.""" + from mxdev.config import Configuration from mxdev.hooks import write_hooks from mxdev.state import State - from mxdev.config import Configuration + import pathlib base = pathlib.Path(__file__).parent / "data" / "config_samples" @@ -169,9 +172,10 @@ def test_write_hooks(): def test_write_hooks_empty_list(): """Test write_hooks with empty hooks list.""" + from mxdev.config import Configuration from mxdev.hooks import write_hooks from mxdev.state import State - from mxdev.config import Configuration + import pathlib base = pathlib.Path(__file__).parent / "data" / "config_samples" diff --git a/tests/test_logging.py b/tests/test_logging.py index fde23e7..4290c23 100644 --- a/tests/test_logging.py +++ b/tests/test_logging.py @@ -99,10 +99,7 @@ def test_setup_logger_no_formatter_for_info(): # The code only sets formatter for DEBUG if handler.formatter: # If a formatter exists, it shouldn't be the DEBUG formatter - assert ( - "%(asctime)s" not in handler.formatter._fmt - or handler.formatter._fmt is None - ) + assert "%(asctime)s" not in handler.formatter._fmt or handler.formatter._fmt is None def test_emoji_logging_with_cp1252_encoding(capsys, caplog): @@ -123,9 +120,7 @@ def test_emoji_logging_with_cp1252_encoding(capsys, caplog): # Create a stream with cp1252 encoding (simulating Windows console) # Use errors='strict' to ensure it raises on unencodable characters - stream = io.TextIOWrapper( - io.BytesIO(), encoding="cp1252", errors="strict", line_buffering=True - ) + stream = io.TextIOWrapper(io.BytesIO(), encoding="cp1252", errors="strict", line_buffering=True) # Set up handler with the cp1252 stream handler = logging.StreamHandler(stream) diff --git a/tests/test_main.py b/tests/test_main.py index 023d5f7..b72f114 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1,9 +1,5 @@ -import io -import pathlib -import pytest -import sys -from unittest.mock import patch, MagicMock -import logging +from unittest.mock import MagicMock +from unittest.mock import patch def test_parser_defaults(): diff --git a/tests/test_mercurial.py b/tests/test_mercurial.py index 438edf1..cf59ce6 100644 --- a/tests/test_mercurial.py +++ b/tests/test_mercurial.py @@ -1,10 +1,9 @@ from unittest.mock import patch +from utils import Process import os import pytest -from utils import Process - class TestMercurial: @pytest.mark.skip("Needs rewrite") @@ -71,7 +70,7 @@ def testUpdateWithRevisionPin(self, develop, src, tempdir): try: # XXX older version - rev = lines[0].split()[1].split(b(":"))[1] + rev = lines[0].split()[1].split(b":")[1] except Exception: rev = lines[0].split()[1] diff --git a/tests/test_processing.py b/tests/test_processing.py index 6c77989..b470302 100644 --- a/tests/test_processing.py +++ b/tests/test_processing.py @@ -1,7 +1,7 @@ +from io import StringIO + import os import pathlib -import pytest -from io import StringIO def test_process_line_plain(): @@ -159,6 +159,7 @@ def test_resolve_dependencies_file_not_found(): def test_resolve_dependencies_with_constraints(): """Test resolve_dependencies with -c constraint reference.""" from mxdev.processing import resolve_dependencies + import os base = pathlib.Path(__file__).parent / "data" / "requirements" @@ -184,6 +185,7 @@ def test_resolve_dependencies_with_constraints(): def test_resolve_dependencies_nested(): """Test resolve_dependencies with -r nested requirements.""" from mxdev.processing import resolve_dependencies + import os base = pathlib.Path(__file__).parent / "data" / "requirements" @@ -314,8 +316,8 @@ def test_write_dev_sources_mixed_modes(tmp_path): def test_write_dev_sources_empty(): """Test write_dev_sources with no packages.""" - from mxdev.processing import write_dev_sources from io import StringIO + from mxdev.processing import write_dev_sources fio = StringIO() write_dev_sources(fio, {}) @@ -376,8 +378,8 @@ def test_write_main_package(tmp_path): def test_write_main_package_not_set(): """Test write_main_package when main-package not set.""" - from mxdev.processing import write_main_package from io import StringIO + from mxdev.processing import write_main_package settings = {} fio = StringIO() @@ -389,9 +391,9 @@ def test_write_main_package_not_set(): def test_write(tmp_path): """Test write function creates output files correctly.""" + from mxdev.config import Configuration from mxdev.processing import write from mxdev.state import State - from mxdev.config import Configuration # Create a simple config config_file = tmp_path / "mx.ini" @@ -438,9 +440,9 @@ def test_write(tmp_path): def test_write_no_constraints(tmp_path): """Test write function when there are no constraints.""" + from mxdev.config import Configuration from mxdev.processing import write from mxdev.state import State - from mxdev.config import Configuration # Create a simple config without constraints config_file = tmp_path / "mx.ini" @@ -486,9 +488,10 @@ def test_relative_constraints_path_in_subdirectory(tmp_path): This reproduces issue #22: when requirements-out and constraints-out are in subdirectories, the constraints reference should be relative to the requirements file's directory. """ - from mxdev.processing import read, write - from mxdev.state import State from mxdev.config import Configuration + from mxdev.processing import read + from mxdev.processing import write + from mxdev.state import State old_cwd = os.getcwd() try: @@ -530,8 +533,7 @@ def test_relative_constraints_path_in_subdirectory(tmp_path): # Bug: Currently writes "-c requirements/constraints.txt" # Expected: Should write "-c constraints.txt" (relative to requirements file's directory) assert "-c constraints.txt\n" in req_content, ( - f"Expected '-c constraints.txt' (relative path), " - f"but got:\n{req_content}" + f"Expected '-c constraints.txt' (relative path), " f"but got:\n{req_content}" ) # Should NOT contain the full path from config file's perspective @@ -542,9 +544,10 @@ def test_relative_constraints_path_in_subdirectory(tmp_path): def test_relative_constraints_path_different_directories(tmp_path): """Test constraints path when requirements and constraints are in different directories.""" - from mxdev.processing import read, write - from mxdev.state import State from mxdev.config import Configuration + from mxdev.processing import read + from mxdev.processing import write + from mxdev.state import State old_cwd = os.getcwd() try: @@ -584,8 +587,7 @@ def test_relative_constraints_path_different_directories(tmp_path): # Should write path relative to reqs/ directory # From reqs/ to constraints/constraints.txt = ../constraints/constraints.txt assert "-c ../constraints/constraints.txt\n" in req_content, ( - f"Expected '-c ../constraints/constraints.txt' (relative path), " - f"but got:\n{req_content}" + f"Expected '-c ../constraints/constraints.txt' (relative path), " f"but got:\n{req_content}" ) finally: os.chdir(old_cwd) diff --git a/tests/test_svn.py b/tests/test_svn.py index 236fee3..3896772 100644 --- a/tests/test_svn.py +++ b/tests/test_svn.py @@ -1,10 +1,9 @@ from unittest.mock import patch +from utils import Process import os import pytest -from utils import Process - class TestSVN: @pytest.fixture(autouse=True) @@ -31,11 +30,7 @@ def testUpdateWithoutRevisionPin(self, develop, src, tempdir): bar.create_file("bar") process.check_call(f"svn add {bar}", echo=False) process.check_call(f"svn commit {bar} -m bar", echo=False) - develop.sources = { - "egg": dict( - kind="svn", name="egg", url=f"file://{repository}", path=src["egg"] - ) - } + develop.sources = {"egg": dict(kind="svn", name="egg", url=f"file://{repository}", path=src["egg"])} _log = patch("mxdev.vcs.svn.logger") log = _log.__enter__() try: @@ -68,11 +63,7 @@ def testUpdateWithRevisionPin(self, develop, src, tempdir): bar.create_file("bar") process.check_call(f"svn add {bar}", echo=False) process.check_call(f"svn commit {bar} -m bar", echo=False) - develop.sources = { - "egg": dict( - kind="svn", name="egg", url=f"file://{repository}@1", path=src["egg"] - ) - } + develop.sources = {"egg": dict(kind="svn", name="egg", url=f"file://{repository}@1", path=src["egg"])} CmdCheckout(develop)(develop.parser.parse_args(["co", "egg"])) assert set(os.listdir(src["egg"])) == {".svn", "foo"} CmdUpdate(develop)(develop.parser.parse_args(["up", "egg"])) diff --git a/tests/test_vcs_filesystem.py b/tests/test_vcs_filesystem.py index def5abd..58517b7 100644 --- a/tests/test_vcs_filesystem.py +++ b/tests/test_vcs_filesystem.py @@ -1,11 +1,10 @@ -import os import pytest def test_filesystem_error_exists(): """Test FilesystemError exception class exists.""" - from mxdev.vcs.filesystem import FilesystemError from mxdev.vcs.common import WCError + from mxdev.vcs.filesystem import FilesystemError # Should be a subclass of WCError assert issubclass(FilesystemError, WCError) @@ -13,8 +12,8 @@ def test_filesystem_error_exists(): def test_filesystem_working_copy_class_exists(): """Test FilesystemWorkingCopy class exists.""" - from mxdev.vcs.filesystem import FilesystemWorkingCopy from mxdev.vcs.common import BaseWorkingCopy + from mxdev.vcs.filesystem import FilesystemWorkingCopy # Should be a subclass of BaseWorkingCopy assert issubclass(FilesystemWorkingCopy, BaseWorkingCopy) @@ -43,7 +42,8 @@ def test_checkout_path_exists_and_matches(tmp_path): def test_checkout_path_exists_but_doesnt_match(tmp_path): """Test checkout when path exists but doesn't match expected name.""" - from mxdev.vcs.filesystem import FilesystemWorkingCopy, FilesystemError + from mxdev.vcs.filesystem import FilesystemError + from mxdev.vcs.filesystem import FilesystemWorkingCopy # Create a directory with different name than expected test_dir = tmp_path / "actual-name" @@ -57,15 +57,14 @@ def test_checkout_path_exists_but_doesnt_match(tmp_path): wc = FilesystemWorkingCopy(source) - with pytest.raises( - FilesystemError, match="Directory name for existing package .* differs" - ): + with pytest.raises(FilesystemError, match="Directory name for existing package .* differs"): wc.checkout() def test_checkout_path_doesnt_exist(tmp_path): """Test checkout when path doesn't exist.""" - from mxdev.vcs.filesystem import FilesystemWorkingCopy, FilesystemError + from mxdev.vcs.filesystem import FilesystemError + from mxdev.vcs.filesystem import FilesystemWorkingCopy # Don't create the directory test_dir = tmp_path / "nonexistent" @@ -78,9 +77,7 @@ def test_checkout_path_doesnt_exist(tmp_path): wc = FilesystemWorkingCopy(source) - with pytest.raises( - FilesystemError, match="Directory .* for package .* doesn't exist" - ): + with pytest.raises(FilesystemError, match="Directory .* for package .* doesn't exist"): wc.checkout() @@ -188,7 +185,8 @@ def test_update_when_matches(tmp_path): def test_update_when_doesnt_match(tmp_path): """Test update() when path doesn't match raises error.""" - from mxdev.vcs.filesystem import FilesystemWorkingCopy, FilesystemError + from mxdev.vcs.filesystem import FilesystemError + from mxdev.vcs.filesystem import FilesystemWorkingCopy test_dir = tmp_path / "actual-name" test_dir.mkdir() @@ -201,16 +199,14 @@ def test_update_when_doesnt_match(tmp_path): wc = FilesystemWorkingCopy(source) - with pytest.raises( - FilesystemError, match="Directory name for existing package .* differs" - ): + with pytest.raises(FilesystemError, match="Directory name for existing package .* differs"): wc.update() def test_logger_exists(): """Test that logger is imported from common.""" - from mxdev.vcs.filesystem import logger from mxdev.vcs.common import logger as common_logger + from mxdev.vcs.filesystem import logger # Should be the same logger instance assert logger is common_logger diff --git a/tests/utils.py b/tests/utils.py index 582c516..4fdf56b 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -1,8 +1,8 @@ +from collections.abc import Iterable from mxdev.vcs.common import WorkingCopies from subprocess import PIPE from subprocess import Popen from typing import Any -from collections.abc import Iterable import os import sys From bf550f69a2e883b1c4eba865e06909988b422069 Mon Sep 17 00:00:00 2001 From: "Jens W. Klein" Date: Wed, 22 Oct 2025 17:47:44 +0200 Subject: [PATCH 2/4] Update CHANGES.md for pre-commit modernization --- CHANGES.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGES.md b/CHANGES.md index 1145e79..16cc531 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -10,6 +10,8 @@ - Modernize type hints to use Python 3.10+ syntax (PEP 604: `X | Y` instead of `Union[X, Y]`) - Use built-in generic types (`list`, `dict`, `tuple`) instead of `typing.List`, `typing.Dict`, `typing.Tuple` [jensens] +- Replace black with ruff for faster linting and formatting. Configure ruff with line-length=120 and appropriate rule selections. Keep isort for import sorting with plone profile and force-alphabetical-sort. This modernizes the tooling stack for better Python 3.10+ support and faster CI runs. + [jensens] ## 4.1.2 (unreleased) From 5fbd69d5fdb69281ae4eb6177a05484eed397700 Mon Sep 17 00:00:00 2001 From: "Jens W. Klein" Date: Wed, 22 Oct 2025 17:48:30 +0200 Subject: [PATCH 3/4] update makefile with autodetect uv --- Makefile | 70 +++++++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 51 insertions(+), 19 deletions(-) diff --git a/Makefile b/Makefile index dbae116..c26a1fd 100644 --- a/Makefile +++ b/Makefile @@ -54,18 +54,19 @@ PRIMARY_PYTHON?=3.14 PYTHON_MIN_VERSION?=3.10 # Install packages using the given package installer method. -# Supported are `pip` and `uv`. If uv is used, its global availability is -# checked. Otherwise, it is installed, either in the virtual environment or -# using the `PRIMARY_PYTHON`, dependent on the `VENV_ENABLED` setting. If -# `VENV_ENABLED` and uv is selected, uv is used to create the virtual -# environment. +# Supported are `pip` and `uv`. When `uv` is selected, a global installation +# is auto-detected and used if available. Otherwise, uv is installed in the +# virtual environment or using `PRIMARY_PYTHON`, depending on the +# `VENV_ENABLED` setting. # Default: pip PYTHON_PACKAGE_INSTALLER?=uv -# Flag whether to use a global installed 'uv' or install -# it in the virtual environment. -# Default: false -MXENV_UV_GLOBAL?=true +# Python version for UV to install/use when creating virtual +# environments with global UV. Passed to `uv venv -p VALUE`. Supports version +# specs like `3.11`, `3.14`, `cpython@3.14`. Defaults to PRIMARY_PYTHON value +# for backward compatibility. +# Default: $(PRIMARY_PYTHON) +UV_PYTHON?=$(PRIMARY_PYTHON) # Flag whether to use virtual environment. If `false`, the # interpreter according to `PRIMARY_PYTHON` found in `PATH` is used. @@ -199,30 +200,57 @@ else MXENV_PYTHON=$(PRIMARY_PYTHON) endif -# Determine the package installer +# Determine the package installer with non-interactive flags ifeq ("$(PYTHON_PACKAGE_INSTALLER)","uv") -PYTHON_PACKAGE_COMMAND=uv pip +PYTHON_PACKAGE_COMMAND=uv pip --quiet --no-progress else PYTHON_PACKAGE_COMMAND=$(MXENV_PYTHON) -m pip endif +# Auto-detect global uv availability (simple existence check) +ifeq ("$(PYTHON_PACKAGE_INSTALLER)","uv") +UV_AVAILABLE:=$(shell command -v uv >/dev/null 2>&1 && echo "true" || echo "false") +else +UV_AVAILABLE:=false +endif + +# Determine installation strategy +USE_GLOBAL_UV:=$(shell [[ "$(PYTHON_PACKAGE_INSTALLER)" == "uv" && "$(UV_AVAILABLE)" == "true" ]] && echo "true" || echo "false") +USE_LOCAL_UV:=$(shell [[ "$(PYTHON_PACKAGE_INSTALLER)" == "uv" && "$(UV_AVAILABLE)" == "false" ]] && echo "true" || echo "false") + +# Check if global UV is outdated (non-blocking warning) +ifeq ("$(USE_GLOBAL_UV)","true") +UV_OUTDATED:=$(shell uv self update --dry-run 2>&1 | grep -q "Would update" && echo "true" || echo "false") +else +UV_OUTDATED:=false +endif + MXENV_TARGET:=$(SENTINEL_FOLDER)/mxenv.sentinel $(MXENV_TARGET): $(SENTINEL) -ifneq ("$(PYTHON_PACKAGE_INSTALLER)$(MXENV_UV_GLOBAL)","uvfalse") + # Validation: Check Python version if not using global uv +ifneq ("$(USE_GLOBAL_UV)","true") @$(PRIMARY_PYTHON) -c "import sys; vi = sys.version_info; sys.exit(1 if (int(vi[0]), int(vi[1])) >= tuple(map(int, '$(PYTHON_MIN_VERSION)'.split('.'))) else 0)" \ && echo "Need Python >= $(PYTHON_MIN_VERSION)" && exit 1 || : else - @echo "Use Python $(PYTHON_MIN_VERSION) over uv" + @echo "Using global uv for Python $(UV_PYTHON)" endif + # Validation: Check VENV_FOLDER is set if venv enabled @[[ "$(VENV_ENABLED)" == "true" && "$(VENV_FOLDER)" == "" ]] \ && echo "VENV_FOLDER must be configured if VENV_ENABLED is true" && exit 1 || : - @[[ "$(VENV_ENABLED)$(PYTHON_PACKAGE_INSTALLER)" == "falseuv" ]] \ + # Validation: Check uv not used with system Python + @[[ "$(VENV_ENABLED)" == "false" && "$(PYTHON_PACKAGE_INSTALLER)" == "uv" ]] \ && echo "Package installer uv does not work with a global Python interpreter." && exit 1 || : + # Warning: Notify if global UV is outdated +ifeq ("$(UV_OUTDATED)","true") + @echo "WARNING: A newer version of uv is available. Run 'uv self update' to upgrade." +endif + + # Create virtual environment ifeq ("$(VENV_ENABLED)", "true") ifeq ("$(VENV_CREATE)", "true") -ifeq ("$(PYTHON_PACKAGE_INSTALLER)$(MXENV_UV_GLOBAL)","uvtrue") - @echo "Setup Python Virtual Environment using package 'uv' at '$(VENV_FOLDER)'" - @uv venv -p $(PRIMARY_PYTHON) --seed $(VENV_FOLDER) +ifeq ("$(USE_GLOBAL_UV)","true") + @echo "Setup Python Virtual Environment using global uv at '$(VENV_FOLDER)'" + @uv venv --quiet --no-progress -p $(UV_PYTHON) --seed $(VENV_FOLDER) else @echo "Setup Python Virtual Environment using module 'venv' at '$(VENV_FOLDER)'" @$(PRIMARY_PYTHON) -m venv $(VENV_FOLDER) @@ -232,10 +260,14 @@ endif else @echo "Using system Python interpreter" endif -ifeq ("$(PYTHON_PACKAGE_INSTALLER)$(MXENV_UV_GLOBAL)","uvfalse") - @echo "Install uv" + + # Install uv locally if needed +ifeq ("$(USE_LOCAL_UV)","true") + @echo "Install uv in virtual environment" @$(MXENV_PYTHON) -m pip install uv endif + + # Install/upgrade core packages @$(PYTHON_PACKAGE_COMMAND) install -U pip setuptools wheel @echo "Install/Update MXStack Python packages" @$(PYTHON_PACKAGE_COMMAND) install -U $(MXDEV) $(MXMAKE) From 288c2b8e581d82de37ec10137b16212468d37a74 Mon Sep 17 00:00:00 2001 From: "Jens W. Klein" Date: Wed, 22 Oct 2025 17:55:15 +0200 Subject: [PATCH 4/4] Update CLAUDE.md with ruff and isort configuration - Replace black/flake8 references with ruff - Add ruff linting and formatting commands - Update Code Style section with ruff formatter details - Document isort with plone profile configuration - Add Python 3.10+ type hint syntax examples --- CLAUDE.md | 31 ++++++++++++++++++++++--------- 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index ab7577a..b753ae4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -117,14 +117,20 @@ Key settings: # Run all pre-commit hooks (using uvx with tox-uv) uvx --with tox-uv tox -e lint -# Run type checking -mypy src/mxdev +# Run ruff linter (with auto-fix) +uvx ruff check --fix src/mxdev tests + +# Run ruff formatter +uvx ruff format src/mxdev tests -# Run flake8 -flake8 src/mxdev +# Sort imports with isort +uvx isort src/mxdev tests + +# Run type checking +uvx mypy src/mxdev -# Sort imports -isort src/mxdev +# Run all pre-commit hooks manually +uvx pre-commit run --all-files ``` ### Testing Multiple Python Versions (using uvx tox with uv) @@ -401,9 +407,16 @@ myext-package_setting = value ## Code Style -- **Formatting**: Black-compatible (max line length: 120) -- **Import sorting**: isort with `force_alphabetical_sort = true`, `force_single_line = true` -- **Type hints**: Use throughout (Python 3.10+ compatible) +- **Formatting**: Ruff formatter (max line length: 120, target Python 3.10+) + - Configured in [pyproject.toml](pyproject.toml) under `[tool.ruff]` + - Rules: E, W, F, UP, D (with selective ignores for docstrings) + - Automatically enforced via pre-commit hooks +- **Import sorting**: isort with plone profile, `force_alphabetical_sort = true`, `force_single_line = true` + - Configured in [pyproject.toml](pyproject.toml) under `[tool.isort]` + - Runs after ruff in pre-commit pipeline +- **Type hints**: Use throughout (Python 3.10+ syntax) + - Use `X | Y` instead of `Union[X, Y]` + - Use `list[T]`, `dict[K, V]` instead of `List[T]`, `Dict[K, V]` - **Path handling**: Prefer `pathlib.Path` over `os.path` for path operations - Use `pathlib.Path().as_posix()` for cross-platform path comparison - Use `/` operator for path joining: `Path("dir") / "file.txt"`