Skip to content

Commit 511bf0e

Browse files
committed
Add support for pyproject.toml configuration
1 parent 5ca7f2d commit 511bf0e

9 files changed

Lines changed: 372 additions & 5 deletions

File tree

CHANGES.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
## Changes
22

3+
## 5.2.0 (unreleased)
4+
5+
- Feature: Added support for `pyproject.toml` as a configuration source. mxdev will now automatically look for `[tool.mxdev]` configuration in `pyproject.toml` if `mx.ini` is missing. Users can also explicitly specify it with `-c pyproject.toml`. [erral]
6+
37
## 5.1.0
48

59
- Feature: Git repositories can now specify multiple push URLs using multiline syntax in the `pushurl` configuration option. This enables pushing to multiple remotes (e.g., GitHub + GitLab mirrors) automatically. Syntax follows the same multiline pattern as `version-overrides` and `ignores`. Example: `pushurl =` followed by indented URLs on separate lines. When `git push` is run in the checked-out repository, it will push to all configured pushurls sequentially, mirroring Git's native multi-pushurl behavior. Backward compatible with single pushurl strings.

README.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,35 @@ For more examples see the [example/](https://github.com/mxstack/mxdev/tree/main/
6666

6767
Configuration is done in an INI file (default: `mx.ini`) using [configparser.ExtendedInterpolation](https://docs.python.org/3/library/configparser.html#configparser.ExtendedInterpolation) syntax.
6868

69+
### pyproject.toml support
70+
71+
Starting with version 5.2.0, mxdev supports configuration directly in `pyproject.toml`. This is useful for simple projects that don't need complex features like recursive includes or variable interpolation.
72+
73+
#### Auto-discovery
74+
75+
If no configuration file is explicitly specified via `-c`, mxdev will look for files in this order:
76+
1. `mx.ini`
77+
2. `pyproject.toml` (if it contains a `[tool.mxdev]` section)
78+
79+
#### Structure
80+
81+
The TOML configuration mirrors the INI structure but uses nesting:
82+
83+
```toml
84+
[tool.mxdev.settings]
85+
requirements-in = "requirements.txt"
86+
threads = 8
87+
88+
[tool.mxdev.packages.package1]
89+
url = "https://github.com/org/package1.git"
90+
branch = "main"
91+
92+
[tool.mxdev.hooks.myhook]
93+
some-setting = "value"
94+
```
95+
96+
**Note**: TOML configuration does NOT support variable interpolation (no `${settings:var}`) or the `include` directive. If you need these features, please use `mx.ini`.
97+
6998
### Settings Section `[settings]`
7099

71100
The **main section** must be called `[settings]`, even if kept empty.
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
# Design: pyproject.toml Support for mxdev
2+
3+
**Date**: 2026-05-20
4+
**Status**: Approved
5+
6+
## Goal
7+
Add support for `pyproject.toml` as a configuration source for `mxdev` to align with modern Python tooling standards (PEP 518).
8+
9+
## Brutally Honest Findings & Critique
10+
11+
1. **The "Standardization" Trap**: By adding TOML support, we are satisfying a superficial desire for "modernity" while losing the features that actually make `mxdev` unique: recursive includes and variable interpolation. Users will likely start in TOML because it's "cleaner," only to hit a wall the moment they need to share configurations across repositories.
12+
2. **Architectural Debt**: We are implementing a "translation layer" because the core of `mxdev` is so heavily coupled to `ConfigParser` that a proper refactor to a format-agnostic state model would be a major undertaking. We are essentially faking an INI file from a TOML file.
13+
3. **Fragmented Ecosystem**: We are creating two classes of `mxdev` projects: "Simple" (TOML) and "Advanced" (INI). This adds cognitive load for maintainers who now have to troubleshoot two different configuration schemas.
14+
4. **Auto-discovery Hazards**: While auto-discovery is convenient, it can lead to "ghost configurations" where `mxdev` behaves unexpectedly because it found settings in a `pyproject.toml` that the user didn't explicitly point it to.
15+
16+
## Design
17+
18+
### 1. Configuration Structure
19+
Configuration will live in the `[tool.mxdev]` namespace:
20+
21+
```toml
22+
[tool.mxdev.settings]
23+
requirements-in = "requirements.txt"
24+
threads = 8
25+
26+
[tool.mxdev.packages.package1]
27+
url = "https://github.com/org/package1.git"
28+
29+
[tool.mxdev.hooks.myhook]
30+
setting = "value"
31+
```
32+
33+
### 2. Auto-Discovery Logic
34+
Priority order:
35+
1. Explicit `-c / --configuration` flag.
36+
2. `mx.ini` (default).
37+
3. `pyproject.toml` (if `mx.ini` is missing and `[tool.mxdev]` is present).
38+
39+
### 3. Implementation Details
40+
- **Parser**: Use `tomllib` (Python 3.11+) or `tomli` (Python 3.10).
41+
- **Dependency**: Add `toml` extra to `pyproject.toml`.
42+
- **Normalization**: The TOML loader will return a dictionary structured like `ConfigParser._sections` to ensure compatibility with the existing `Configuration` class.
43+
44+
### 4. Constraints
45+
- No interpolation (`${var}`).
46+
- No `include` directive.
47+
- Static manifests only.
48+
49+
## Testing Strategy
50+
51+
Brutally honest: Testing this logic requires mocking the filesystem and Python version environments, which is always more fragile than it looks. We need to ensure that the "Priority Order" isn't just a suggestion, but a strictly enforced rule.
52+
53+
### 1. Unit Tests (`tests/test_toml.py`)
54+
- **Structure Parsing**: Verify `tool.mxdev.settings`, `tool.mxdev.packages`, and `tool.mxdev.hooks` correctly map to the internal dictionary format.
55+
- **Dependency Handling**: Mock `ImportError` for both `tomllib` and `tomli` to verify the helpful error message.
56+
- **Normalization**: Ensure all values are converted to strings (as `ConfigParser` would do) to prevent type errors in the rest of the pipeline.
57+
58+
### 2. Integration Tests (`tests/test_config_discovery.py`)
59+
- **Case: Only mx.ini**: Verify it loads `mx.ini`.
60+
- **Case: Only pyproject.toml**: Verify it auto-discovers and loads `pyproject.toml`.
61+
- **Case: Both exist**: Verify it picks `mx.ini` and ignores `pyproject.toml`.
62+
- **Case: Both exist + Explicit flag**: `mxdev -c pyproject.toml` must ignore `mx.ini`.
63+
- **Case: pyproject.toml without tool.mxdev**: Verify it does NOT load it and errors out if `mx.ini` is also missing.
64+
- **Case: Missing all**: Verify it retains its original error behavior (looking for `mx.ini`).
65+
66+
### 3. Regression Tests
67+
- Ensure existing `tests/test_config.py` still passes without modifications, confirming we haven't broken the legacy INI path.
68+
69+
## Success Criteria
70+
- `mxdev` runs successfully using only a `pyproject.toml` file.
71+
- `mxdev -c pyproject.toml` works as expected.
72+
- Clear error message when `tomli` is missing on Python 3.10.
73+
- Existing `mx.ini` projects are unaffected.

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ dependencies = ["packaging"]
2525

2626
[project.optional-dependencies]
2727
mypy = []
28+
toml = ["tomli; python_version < '3.11'"]
2829
test = [
2930
"pytest",
3031
"pytest-cov",

src/mxdev/config.py

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from .including import read_with_included
22
from .logging import logger
33
from packaging.requirements import Requirement
4+
from pathlib import Path
45

56
import os
67
import typing
@@ -10,6 +11,7 @@
1011
from .hooks import Hook
1112

1213

14+
1315
def to_bool(value):
1416
if not isinstance(value, str):
1517
return bool(value)
@@ -36,6 +38,71 @@ def parse_multiline_list(value: str) -> list[str]:
3638
return [item for item in items if item]
3739

3840

41+
class TomlSection:
42+
def __init__(self, data: dict):
43+
self._data = data
44+
45+
def items(self):
46+
return self._data.items()
47+
48+
def get(self, key, default=None):
49+
return self._data.get(key, default)
50+
51+
def __getitem__(self, key):
52+
return self._data[key]
53+
54+
def keys(self):
55+
return self._data.keys()
56+
57+
58+
class TomlWrapper:
59+
def __init__(self, data: dict):
60+
self._data = {k: TomlSection(v) for k, v in data.items()}
61+
self._sections = self._data
62+
63+
def __getitem__(self, key):
64+
return self._data[key]
65+
66+
def sections(self):
67+
return [k for k in self._data if k != "settings"]
68+
69+
def __contains__(self, key):
70+
return key in self._data
71+
72+
73+
def read_toml(path: str | Path) -> TomlWrapper:
74+
try:
75+
import tomllib
76+
except ImportError:
77+
try:
78+
import tomli as tomllib
79+
except ImportError:
80+
raise ImportError(
81+
"TOML support requires Python 3.11+ or the 'tomli' package. "
82+
"Install it with 'pip install mxdev[toml]'."
83+
)
84+
85+
with open(path, "rb") as f:
86+
data = tomllib.load(f)
87+
88+
if "tool" not in data or "mxdev" not in data["tool"]:
89+
return TomlWrapper({"settings": {}})
90+
91+
mxdev = data["tool"]["mxdev"]
92+
normalized: dict[str, dict[str, str]] = {}
93+
normalized["settings"] = {k: str(v) for k, v in mxdev.get("settings", {}).items()}
94+
95+
# Packages
96+
for name, pkg_data in mxdev.get("packages", {}).items():
97+
normalized[name] = {k: str(v) for k, v in pkg_data.items()}
98+
99+
# Hooks
100+
for name, hook_data in mxdev.get("hooks", {}).items():
101+
normalized[name] = {k: str(v) for k, v in hook_data.items()}
102+
103+
return TomlWrapper(normalized)
104+
105+
39106
class Configuration:
40107
settings: dict[str, str]
41108
overrides: dict[str, str]
@@ -50,7 +117,10 @@ def __init__(
50117
hooks: list["Hook"] = [],
51118
) -> None:
52119
logger.debug("Read configuration")
53-
data = read_with_included(mxini)
120+
if mxini.endswith(".toml"):
121+
data = read_toml(mxini)
122+
else:
123+
data = read_with_included(mxini)
54124

55125
settings = self.settings = dict(data["settings"].items())
56126

src/mxdev/main.py

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818

1919
import argparse
2020
import logging
21+
import os
2122
import sys
2223

2324

@@ -29,9 +30,9 @@
2930
parser.add_argument(
3031
"-c",
3132
"--configuration",
32-
help="configuration file in INI format",
33+
help="configuration file (INI or pyproject.toml)",
3334
type=str,
34-
default="mx.ini",
35+
default=None,
3536
)
3637
parser.add_argument(
3738
"-n",
@@ -89,13 +90,24 @@ def main() -> None:
8990
logger.info("#" * 79)
9091
hooks = load_hooks()
9192
logger.info("# Load configuration")
93+
94+
# Configuration discovery
95+
config_file = args.configuration
96+
if config_file is None:
97+
if os.path.exists("mx.ini"):
98+
config_file = "mx.ini"
99+
elif os.path.exists("pyproject.toml"):
100+
config_file = "pyproject.toml"
101+
else:
102+
config_file = "mx.ini"
103+
92104
override_args = {}
93105
if args.offline:
94106
override_args["offline"] = True
95107
if args.threads:
96108
override_args["threads"] = args.threads
97109
configuration = Configuration(
98-
mxini=args.configuration,
110+
mxini=config_file,
99111
override_args=override_args,
100112
hooks=hooks,
101113
)

tests/test_config_discovery.py

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import os
2+
from unittest.mock import patch
3+
from mxdev.main import main
4+
import pytest
5+
6+
def test_discovery_mx_ini_exists(tmp_path, monkeypatch):
7+
"""If mx.ini exists, it should be preferred over pyproject.toml."""
8+
mxini = tmp_path / "mx.ini"
9+
mxini.write_text("[settings]\nrequirements-in = mx-reqs.txt", encoding="utf-8")
10+
11+
pyproject = tmp_path / "pyproject.toml"
12+
pyproject.write_text("[tool.mxdev.settings]\nrequirements-in = toml-reqs.txt", encoding="utf-8")
13+
14+
monkeypatch.chdir(tmp_path)
15+
16+
import sys
17+
main_module = sys.modules["mxdev.main"]
18+
19+
with (
20+
patch("sys.argv", ["mxdev"]),
21+
patch.object(main_module, "load_hooks", return_value=[]),
22+
patch.object(main_module, "Configuration") as mock_config,
23+
patch.object(main_module, "read"),
24+
patch.object(main_module, "write"),
25+
patch.object(main_module, "setup_logger"),
26+
):
27+
main()
28+
# Should pick mx.ini
29+
mock_config.assert_called_once()
30+
assert mock_config.call_args[1]["mxini"] == "mx.ini"
31+
32+
def test_discovery_pyproject_fallback(tmp_path, monkeypatch):
33+
"""If mx.ini is missing, it should fallback to pyproject.toml."""
34+
pyproject = tmp_path / "pyproject.toml"
35+
pyproject.write_text("[tool.mxdev.settings]\nrequirements-in = toml-reqs.txt", encoding="utf-8")
36+
37+
monkeypatch.chdir(tmp_path)
38+
39+
import sys
40+
main_module = sys.modules["mxdev.main"]
41+
42+
with (
43+
patch("sys.argv", ["mxdev"]),
44+
patch.object(main_module, "load_hooks", return_value=[]),
45+
patch.object(main_module, "Configuration") as mock_config,
46+
patch.object(main_module, "read"),
47+
patch.object(main_module, "write"),
48+
patch.object(main_module, "setup_logger"),
49+
):
50+
main()
51+
# Should pick pyproject.toml
52+
mock_config.assert_called_once()
53+
assert mock_config.call_args[1]["mxini"] == "pyproject.toml"
54+
55+
def test_discovery_explicit_flag(tmp_path, monkeypatch):
56+
"""Explicit flag should override discovery."""
57+
mxini = tmp_path / "mx.ini"
58+
mxini.write_text("[settings]", encoding="utf-8")
59+
60+
custom = tmp_path / "custom.toml"
61+
custom.write_text("[tool.mxdev.settings]", encoding="utf-8")
62+
63+
monkeypatch.chdir(tmp_path)
64+
65+
import sys
66+
main_module = sys.modules["mxdev.main"]
67+
68+
with (
69+
patch("sys.argv", ["mxdev", "-c", "custom.toml"]),
70+
patch.object(main_module, "load_hooks", return_value=[]),
71+
patch.object(main_module, "Configuration") as mock_config,
72+
patch.object(main_module, "read"),
73+
patch.object(main_module, "write"),
74+
patch.object(main_module, "setup_logger"),
75+
):
76+
main()
77+
# Should pick custom.toml
78+
mock_config.assert_called_once()
79+
assert mock_config.call_args[1]["mxini"] == "custom.toml"
80+
81+
def test_discovery_no_config(tmp_path, monkeypatch):
82+
"""If no config found, fallback to mx.ini default (to trigger existing error behavior)."""
83+
monkeypatch.chdir(tmp_path)
84+
85+
import sys
86+
main_module = sys.modules["mxdev.main"]
87+
88+
with (
89+
patch("sys.argv", ["mxdev"]),
90+
patch.object(main_module, "load_hooks", return_value=[]),
91+
patch.object(main_module, "Configuration") as mock_config,
92+
patch.object(main_module, "read"),
93+
patch.object(main_module, "write"),
94+
patch.object(main_module, "setup_logger"),
95+
):
96+
main()
97+
mock_config.assert_called_once()
98+
assert mock_config.call_args[1]["mxini"] == "mx.ini"

tests/test_main.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ def test_parser_defaults():
77
from mxdev.main import parser
88

99
args = parser.parse_args([])
10-
assert args.configuration == "mx.ini"
10+
assert args.configuration is None
1111
assert args.no_fetch is False
1212
assert args.fetch_only is False
1313
assert args.offline is False

0 commit comments

Comments
 (0)