Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]

Expand Down
11 changes: 11 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |

Expand Down Expand Up @@ -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`<br>`skip`: Only clone, don't install | `default-install-mode` |
| `install-mode` | `editable`: Install with `-e` (development mode)<br>`fixed`: Install without `-e` (production/Docker)<br>`skip`: Only clone, don't install<br>⚠️ `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
Expand Down
35 changes: 30 additions & 5 deletions src/mxdev/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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}")
Expand Down
10 changes: 7 additions & 3 deletions src/mxdev/processing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")


Expand Down
5 changes: 5 additions & 0 deletions tests/data/config_samples/config_deprecated_direct.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
[settings]
default-install-mode = direct

[example.package]
url = git+https://github.com/example/package.git
5 changes: 5 additions & 0 deletions tests/data/config_samples/config_editable_mode.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
[settings]
default-install-mode = editable

[example.package]
url = git+https://github.com/example/package.git
5 changes: 5 additions & 0 deletions tests/data/config_samples/config_fixed_mode.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
[settings]
default-install-mode = fixed

[example.package]
url = git+https://github.com/example/package.git
6 changes: 6 additions & 0 deletions tests/data/config_samples/config_package_direct.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
[settings]
default-install-mode = editable

[example.package]
url = git+https://github.com/example/package.git
install-mode = direct
69 changes: 66 additions & 3 deletions tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"))


Expand All @@ -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"))


Expand Down Expand Up @@ -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

Expand Down
74 changes: 72 additions & 2 deletions tests/test_processing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
},
}

Expand All @@ -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
Expand Down