diff --git a/CHANGES.md b/CHANGES.md index cefa88b..c595840 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -2,6 +2,9 @@ ## 4.1.2 (unreleased) +- Fix #54: Add `fixed` install mode for non-editable installations to support production and Docker deployments. The new `editable` mode replaces `direct` as the default (same behavior, clearer naming). The `direct` mode is now deprecated but still works with a warning. Install modes: `editable` (with `-e`, for development), `fixed` (without `-e`, for production/Docker), `skip` (clone only). + [jensens] + - Fix #35: Add `smart-threading` configuration option to prevent overlapping credential prompts when using HTTPS URLs. When enabled (default), HTTPS packages are processed serially first to ensure clean credential prompts, then other packages are processed in parallel for speed. Can be disabled with `smart-threading = false` if you have credential helpers configured. [jensens] diff --git a/CLAUDE.md b/CLAUDE.md index 357fac6..fc7b0b5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -287,13 +287,24 @@ main-package = -e .[test] url = git+https://github.com/org/package1.git branch = feature-branch extras = test +install-mode = editable [package2] url = git+https://github.com/org/package2.git branch = main +install-mode = fixed + +[package3] +url = git+https://github.com/org/package3.git install-mode = skip ``` +**Install mode options:** +- `editable` (default): Installs with `-e` prefix for development +- `fixed`: Installs without `-e` prefix for production/Docker deployments +- `skip`: Only clones, doesn't install +- `direct`: Deprecated alias for `editable` (logs warning) + **Using includes for shared configurations:** ```ini [settings] diff --git a/README.md b/README.md index 9caaa2c..e333a45 100644 --- a/README.md +++ b/README.md @@ -86,7 +86,7 @@ The **main section** must be called `[settings]`, even if kept empty. | `threads` | Number of parallel threads for fetching sources | `4` | | `smart-threading` | Process HTTPS packages serially to avoid overlapping credential prompts (see below) | `True` | | `offline` | Skip all VCS fetch operations (handy for offline work) | `False` | -| `default-install-mode` | Default `install-mode` for packages: `direct` or `skip` | `direct` | +| `default-install-mode` | Default `install-mode` for packages: `editable`, `fixed`, or `skip` (see below) | `editable` | | `default-update` | Default update behavior: `yes` or `no` | `yes` | | `default-use` | Default use behavior (when false, sources not checked out) | `True` | @@ -220,7 +220,7 @@ For package sources, the section name is the package name: `[PACKAGENAME]` | Option | Description | Default | |--------|-------------|---------| -| `install-mode` | `direct`: Install with `pip -e PACKAGEPATH`
`skip`: Only clone, don't install | `default-install-mode` | +| `install-mode` | `editable`: Install with `-e` (development mode)
`fixed`: Install without `-e` (production/Docker)
`skip`: Only clone, don't install
⚠️ `direct` is deprecated, use `editable` | `default-install-mode` | | `use` | When `false`, source is not checked out and version not overridden | `default-use` | #### Git-Specific Options diff --git a/src/mxdev/config.py b/src/mxdev/config.py index a719cc4..ffab3f3 100644 --- a/src/mxdev/config.py +++ b/src/mxdev/config.py @@ -50,9 +50,22 @@ def __init__( # overlapping credential prompts) settings.setdefault("smart-threading", "true") - mode = settings.get("default-install-mode", "direct") - if mode not in ["direct", "skip"]: - raise ValueError("default-install-mode must be one of 'direct' or 'skip'") + mode = settings.get("default-install-mode", "editable") + + # 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" + settings["default-install-mode"] = "editable" + + 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')" + ) default_use = to_bool(settings.get("default-use", True)) raw_overrides = settings.get("version-overrides", "").strip() @@ -108,9 +121,21 @@ def is_ns_member(name) -> bool: package.setdefault("path", os.path.join(package["target"], name)) if not package.get("url"): raise ValueError(f"Section {name} has no URL set!") - if package.get("install-mode") not in ["direct", "skip"]: + + # Handle deprecated "direct" mode for per-package install-mode + pkg_mode = package.get("install-mode") + if pkg_mode == "direct": + logger.warning( + f"install-mode 'direct' is deprecated and will be removed in a future version. " + f"Please use 'editable' instead (package: {name})." + ) + package["install-mode"] = "editable" + pkg_mode = "editable" + + if pkg_mode not in ["editable", "fixed", "skip"]: raise ValueError( - f"install-mode in [{name}] must be one of 'direct' or 'skip'" + f"install-mode in [{name}] must be one of 'editable', 'fixed', or 'skip' " + f"('direct' is deprecated, use 'editable')" ) # repo_dir = os.path.abspath(f"{package['target']}/{name}") diff --git a/src/mxdev/processing.py b/src/mxdev/processing.py index b11e0bb..a34a7c9 100644 --- a/src/mxdev/processing.py +++ b/src/mxdev/processing.py @@ -220,9 +220,13 @@ def write_dev_sources(fio, packages: typing.Dict[str, typing.Dict[str, typing.An continue extras = f"[{package['extras']}]" if package["extras"] else "" subdir = f"/{package['subdirectory']}" if package["subdirectory"] else "" - editable = f"""-e ./{package['target']}/{name}{subdir}{extras}\n""" - logger.debug(f"-> {editable.strip()}") - fio.write(editable) + + # 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") diff --git a/tests/data/config_samples/config_deprecated_direct.ini b/tests/data/config_samples/config_deprecated_direct.ini new file mode 100644 index 0000000..b712b70 --- /dev/null +++ b/tests/data/config_samples/config_deprecated_direct.ini @@ -0,0 +1,5 @@ +[settings] +default-install-mode = direct + +[example.package] +url = git+https://github.com/example/package.git diff --git a/tests/data/config_samples/config_editable_mode.ini b/tests/data/config_samples/config_editable_mode.ini new file mode 100644 index 0000000..31d8d75 --- /dev/null +++ b/tests/data/config_samples/config_editable_mode.ini @@ -0,0 +1,5 @@ +[settings] +default-install-mode = editable + +[example.package] +url = git+https://github.com/example/package.git diff --git a/tests/data/config_samples/config_fixed_mode.ini b/tests/data/config_samples/config_fixed_mode.ini new file mode 100644 index 0000000..cb7fc7a --- /dev/null +++ b/tests/data/config_samples/config_fixed_mode.ini @@ -0,0 +1,5 @@ +[settings] +default-install-mode = fixed + +[example.package] +url = git+https://github.com/example/package.git diff --git a/tests/data/config_samples/config_package_direct.ini b/tests/data/config_samples/config_package_direct.ini new file mode 100644 index 0000000..7180e9a --- /dev/null +++ b/tests/data/config_samples/config_package_direct.ini @@ -0,0 +1,6 @@ +[settings] +default-install-mode = editable + +[example.package] +url = git+https://github.com/example/package.git +install-mode = direct diff --git a/tests/test_config.py b/tests/test_config.py index 746ca14..e643b12 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -89,12 +89,72 @@ def test_configuration_with_ignores(): assert "another.ignored" in config.ignore_keys +def test_configuration_editable_install_mode(): + """Test Configuration with editable install mode.""" + from mxdev.config import Configuration + + base = pathlib.Path(__file__).parent / "data" / "config_samples" + config = Configuration(str(base / "config_editable_mode.ini")) + + assert config.settings["default-install-mode"] == "editable" + assert config.packages["example.package"]["install-mode"] == "editable" + + +def test_configuration_fixed_install_mode(): + """Test Configuration with fixed install mode.""" + from mxdev.config import Configuration + + base = pathlib.Path(__file__).parent / "data" / "config_samples" + config = Configuration(str(base / "config_fixed_mode.ini")) + + assert config.settings["default-install-mode"] == "fixed" + assert config.packages["example.package"]["install-mode"] == "fixed" + + +def test_configuration_direct_mode_deprecated(caplog): + """Test Configuration with deprecated 'direct' mode logs warning.""" + from mxdev.config import Configuration + + base = pathlib.Path(__file__).parent / "data" / "config_samples" + config = Configuration(str(base / "config_deprecated_direct.ini")) + + # Mode should be treated as 'editable' internally + assert config.settings["default-install-mode"] == "editable" + 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 + ) + + +def test_configuration_package_direct_mode_deprecated(caplog): + """Test per-package 'direct' mode logs deprecation warning.""" + from mxdev.config import Configuration + + base = pathlib.Path(__file__).parent / "data" / "config_samples" + config = Configuration(str(base / "config_package_direct.ini")) + + # Package mode should be treated as 'editable' internally + 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 + ) + + 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"): + with pytest.raises( + ValueError, + match=r"default-install-mode must be one of 'editable', 'fixed', or 'skip'", + ): Configuration(str(base / "config_invalid_mode.ini")) @@ -103,7 +163,10 @@ def test_configuration_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"): + with pytest.raises( + ValueError, + match=r"install-mode in .* must be one of 'editable', 'fixed', or 'skip'", + ): Configuration(str(base / "config_package_invalid_mode.ini")) @@ -182,7 +245,7 @@ def test_configuration_package_defaults(): assert pkg["extras"] == "" assert pkg["subdirectory"] == "" assert pkg["target"] == "sources" # default-target not set, should be "sources" - assert pkg["install-mode"] == "direct" # default mode + assert pkg["install-mode"] == "editable" # default mode changed from 'direct' assert pkg["vcs"] == "git" assert "path" in pkg diff --git a/tests/test_processing.py b/tests/test_processing.py index ea3662f..21be3ee 100644 --- a/tests/test_processing.py +++ b/tests/test_processing.py @@ -215,7 +215,7 @@ def test_write_dev_sources(tmp_path): "target": "sources", "extras": "", "subdirectory": "", - "install-mode": "direct", + "install-mode": "editable", }, "skip.package": { "target": "sources", @@ -227,7 +227,7 @@ def test_write_dev_sources(tmp_path): "target": "sources", "extras": "test,docs", "subdirectory": "packages/core", - "install-mode": "direct", + "install-mode": "editable", }, } @@ -241,6 +241,76 @@ def test_write_dev_sources(tmp_path): assert "-e ./sources/extras.package/packages/core[test,docs]" in content +def test_write_dev_sources_fixed_mode(tmp_path): + """Test write_dev_sources with fixed install mode (no -e prefix).""" + from mxdev.processing import write_dev_sources + + packages = { + "fixed.package": { + "target": "sources", + "extras": "", + "subdirectory": "", + "install-mode": "fixed", + }, + "fixed.with.extras": { + "target": "sources", + "extras": "test", + "subdirectory": "packages/core", + "install-mode": "fixed", + }, + } + + outfile = tmp_path / "requirements.txt" + with open(outfile, "w") as fio: + write_dev_sources(fio, packages) + + content = outfile.read_text() + # Fixed mode should NOT have -e prefix + assert "./sources/fixed.package" in content + assert "-e ./sources/fixed.package" not in content + assert "./sources/fixed.with.extras/packages/core[test]" in content + assert "-e ./sources/fixed.with.extras/packages/core[test]" not in content + + +def test_write_dev_sources_mixed_modes(tmp_path): + """Test write_dev_sources with mixed install modes.""" + from mxdev.processing import write_dev_sources + + packages = { + "editable.package": { + "target": "sources", + "extras": "", + "subdirectory": "", + "install-mode": "editable", + }, + "fixed.package": { + "target": "sources", + "extras": "", + "subdirectory": "", + "install-mode": "fixed", + }, + "skip.package": { + "target": "sources", + "extras": "", + "subdirectory": "", + "install-mode": "skip", + }, + } + + outfile = tmp_path / "requirements.txt" + with open(outfile, "w") as fio: + write_dev_sources(fio, packages) + + content = outfile.read_text() + # Editable should have -e prefix + assert "-e ./sources/editable.package" in content + # Fixed should NOT have -e prefix + assert "./sources/fixed.package" in content + assert "-e ./sources/fixed.package" not in content + # Skip should not appear at all + assert "skip.package" not in content + + def test_write_dev_sources_empty(): """Test write_dev_sources with no packages.""" from mxdev.processing import write_dev_sources