Skip to content

Commit a7eb181

Browse files
thetcodex
andcommitted
Add include-optional directive.
- Add ``include-optional`` for INI configuration files that may be absent, skipping missing local files and HTTP 404 responses while preserving mandatory includes. This allows projects to define an optional include for client-specific customizations. - Fix HTTP error logging during INI inclusion and preserve the original exception. - Fix relative INI includes from URLs with a directory path. Co-authored-by: Codex <noreply@openai.com>
1 parent e797060 commit a7eb181

4 files changed

Lines changed: 170 additions & 32 deletions

File tree

‎CHANGES.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,12 @@
44

55
<!-- Add future changes here -->
66

7+
- Add `include-optional` for INI configuration files that may be absent, skipping
8+
missing local files and HTTP 404 responses while preserving mandatory includes.
9+
This allows projects to define an optional include for client-specific
10+
customizations.
11+
- Fix HTTP error logging during INI inclusion and preserve the original exception.
12+
- Fix relative INI includes from URLs with a directory path.
713

814
## 5.4.1 (2026-08-04)
915

‎README.md‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,32 @@ If an included file is an HTTP-URL, it is loaded from there.
198198

199199
If the included file is a relative path, it is loaded relative to the parent's directory or URL.
200200

201+
Missing files and HTTP errors stop configuration loading. Use `include-optional` for files that may be absent.
202+
203+
Default: empty
204+
205+
##### `include-optional`
206+
207+
Optional includes allow projects to define a client-specific configuration file
208+
via a file name defined by a convention - e.g. to make customer- or
209+
developer-specific adaptions which should not be checked into a repository.
210+
211+
Include one or more optional INI files, one per line:
212+
213+
```ini
214+
[settings]
215+
include-optional =
216+
mx-custom.ini
217+
```
218+
219+
Missing local files and HTTP 404 responses are ignored. Other errors, such as HTTP 403/500,
220+
connection failures, or invalid INI content, still stop configuration loading.
221+
Mandatory `include` entries inside an existing optional file remain mandatory.
222+
223+
Paths and URLs are resolved in the same way as `include`, and optional files may include other files.
224+
For each file, mandatory includes are read first, then optional includes in their listed order,
225+
then the file itself. Later settings override earlier settings, so the main file takes precedence.
226+
201227
Default: empty
202228

203229
##### `directory`

‎src/mxdev/including.py‎

Lines changed: 56 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1+
from .logging import logger
12
from configparser import ConfigParser
23
from configparser import ExtendedInterpolation
34
from pathlib import Path
45
from urllib import parse
56
from urllib import request
7+
from urllib.error import HTTPError
68

79
import os
810
import tempfile
@@ -12,14 +14,14 @@ def resolve_dependencies(
1214
file_or_url: str | Path,
1315
tmpdir: str,
1416
http_parent=None,
17+
optional: bool = False,
1518
) -> list[Path]:
1619
"""Resolve dependencies of a file or url
1720
18-
The result is a list of Path objects, starting with the
19-
given file_or_url and followed by all file_or_urls referenced from it.
21+
Return included paths before their parent, so parent settings take precedence.
2022
21-
The file_or_url is assumed to be a ini file or url to such, with an option key "include"
22-
under the "[settings]" section.
23+
Follow "include" and "include-optional" under the "[settings]" section.
24+
If optional, skip a missing file or HTTP 404 for this input only.
2325
"""
2426
if isinstance(file_or_url, str):
2527
if http_parent:
@@ -29,40 +31,59 @@ def resolve_dependencies(
2931
# Windows drive letters are single characters, URL schemes are longer
3032
is_url = parsed.scheme and len(parsed.scheme) > 1
3133
if is_url:
32-
with request.urlopen(str(file_or_url)) as fio:
33-
tf = tempfile.NamedTemporaryFile(
34-
suffix=".ini",
35-
dir=str(tmpdir),
36-
delete=False,
37-
)
38-
tf.write(fio.read())
39-
tf.flush()
40-
file = Path(tf.name)
41-
parts = list(parsed)
42-
parts[2] = str(Path(parts[2]).parent)
43-
http_parent = parse.urlunparse(parts)
34+
try:
35+
with request.urlopen(str(file_or_url)) as fio:
36+
with tempfile.NamedTemporaryFile(
37+
suffix=".ini",
38+
dir=str(tmpdir),
39+
delete=False,
40+
) as tf:
41+
tf.write(fio.read())
42+
file = Path(tf.name)
43+
except HTTPError as e:
44+
if optional and e.code == 404:
45+
logger.info("Skipping missing optional include: %s", file_or_url)
46+
return []
47+
logger.error("Error %s for URL: %s", e.code, e.url)
48+
raise
49+
http_parent = parse.urljoin(str(file_or_url), ".")
4450
else:
4551
file = Path(file_or_url)
4652
else:
4753
file = file_or_url
48-
if not file.exists():
49-
raise FileNotFoundError(file)
5054
cfg = ConfigParser()
51-
cfg.read(file)
52-
if not ("settings" in cfg and "include" in cfg["settings"]):
55+
try:
56+
with file.open() as fio:
57+
cfg.read_file(fio)
58+
except FileNotFoundError:
59+
if not optional:
60+
raise
61+
logger.info("Skipping missing optional include: %s", file_or_url)
62+
return []
63+
if "settings" not in cfg:
5364
return [file]
5465
file_list = []
55-
for include in cfg["settings"]["include"].split("\n"):
56-
include = include.strip()
57-
if not include:
58-
continue
59-
# Check if it's a real URL scheme (not a Windows drive letter)
60-
parsed_include = parse.urlparse(include)
61-
is_include_url = parsed_include.scheme and len(parsed_include.scheme) > 1
62-
if http_parent or is_include_url:
63-
file_list += resolve_dependencies(include, tmpdir, http_parent)
64-
else:
65-
file_list += resolve_dependencies(file.parent / include, tmpdir)
66+
for directive in ("include", "include-optional"):
67+
for include in cfg["settings"].get(directive, "").splitlines():
68+
include = include.strip()
69+
if not include:
70+
continue
71+
# Check if it's a real URL scheme (not a Windows drive letter)
72+
parsed_include = parse.urlparse(include)
73+
is_include_url = parsed_include.scheme and len(parsed_include.scheme) > 1
74+
if http_parent or is_include_url:
75+
file_list += resolve_dependencies(
76+
file_or_url=include,
77+
tmpdir=tmpdir,
78+
http_parent=http_parent,
79+
optional=directive == "include-optional",
80+
)
81+
else:
82+
file_list += resolve_dependencies(
83+
file_or_url=file.parent / include,
84+
tmpdir=tmpdir,
85+
optional=directive == "include-optional",
86+
)
6687

6788
file_list.append(file)
6889
return file_list
@@ -80,6 +101,9 @@ def read_with_included(file_or_url: str | Path) -> ConfigParser:
80101
cfg.optionxform = str # type: ignore
81102
cfg["settings"]["directory"] = os.getcwd()
82103
with tempfile.TemporaryDirectory() as tmpdir:
83-
resolved = resolve_dependencies(file_or_url, tmpdir)
104+
resolved = resolve_dependencies(
105+
file_or_url=file_or_url,
106+
tmpdir=tmpdir,
107+
)
84108
cfg.read(resolved)
85109
return cfg

‎tests/test_including.py‎

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,88 @@
22
import pytest
33

44

5+
def test_optional_includes_and_precedence(tmp_path):
6+
from mxdev.including import read_with_included
7+
8+
(tmp_path / "required.ini").write_text("[settings]\nvalue = required\n")
9+
(tmp_path / "nested.ini").write_text("[settings]\nnested = yes\n")
10+
(tmp_path / "custom.ini").write_text(
11+
"[settings]\ninclude = nested.ini\ninclude-optional = absent.ini\nvalue = optional\nmain = optional\n"
12+
)
13+
main = tmp_path / "mx.ini"
14+
main.write_text(
15+
"[settings]\ninclude = required.ini\ninclude-optional =\n missing.ini\n custom.ini\nmain = main\n"
16+
)
17+
cfg = read_with_included(main)
18+
assert cfg["settings"]["value"] == "optional"
19+
assert cfg["settings"]["main"] == "main"
20+
assert cfg["settings"]["nested"] == "yes"
21+
22+
23+
def test_optional_include_preserves_required_nested_failure(tmp_path):
24+
from mxdev.including import read_with_included
25+
26+
main = tmp_path / "mx.ini"
27+
main.write_text("[settings]\ninclude-optional = custom.ini\n")
28+
(tmp_path / "custom.ini").write_text("[settings]\ninclude = missing.ini\n")
29+
with pytest.raises(FileNotFoundError):
30+
read_with_included(main)
31+
32+
33+
@pytest.mark.parametrize("directive", ["include", "include-optional"])
34+
@pytest.mark.parametrize("status", [404, 403, 500])
35+
def test_include_http_errors(tmp_path, httpretty, directive, status):
36+
from mxdev.including import read_with_included
37+
from urllib.error import HTTPError
38+
39+
url = "http://example.com/custom.ini"
40+
httpretty.register_uri(httpretty.GET, url, status=status)
41+
main = tmp_path / "mx.ini"
42+
main.write_text(f"[settings]\n{directive} = {url}\nvalue = main\n")
43+
if directive == "include-optional" and status == 404:
44+
assert read_with_included(main)["settings"]["value"] == "main"
45+
else:
46+
with pytest.raises(HTTPError) as exc:
47+
read_with_included(main)
48+
assert exc.value.code == status
49+
50+
51+
@pytest.mark.parametrize("directive", ["include", "include-optional"])
52+
def test_http_relative_include(tmp_path, httpretty, directive):
53+
from mxdev.including import read_with_included
54+
55+
url = "http://example.com/config/mx.ini"
56+
httpretty.register_uri(httpretty.GET, url, body=f"[settings]\n{directive} = custom.ini\n")
57+
httpretty.register_uri(httpretty.GET, "http://example.com/config/custom.ini", body="[settings]\ncustom = yes\n")
58+
assert read_with_included(url)["settings"]["custom"] == "yes"
59+
60+
61+
def test_optional_include_connection_error(tmp_path, monkeypatch):
62+
from mxdev.including import read_with_included
63+
from urllib.error import URLError
64+
65+
main = tmp_path / "mx.ini"
66+
main.write_text("[settings]\ninclude-optional = https://example.com/custom.ini\n")
67+
68+
def fail(url):
69+
raise URLError("Connection failed")
70+
71+
monkeypatch.setattr("mxdev.including.request.urlopen", fail)
72+
with pytest.raises(URLError, match="Connection failed"):
73+
read_with_included(main)
74+
75+
76+
def test_optional_include_invalid_ini(tmp_path):
77+
from configparser import MissingSectionHeaderError
78+
from mxdev.including import read_with_included
79+
80+
main = tmp_path / "mx.ini"
81+
main.write_text("[settings]\ninclude-optional = custom.ini\n")
82+
(tmp_path / "custom.ini").write_text("invalid ini\n")
83+
with pytest.raises(MissingSectionHeaderError):
84+
read_with_included(main)
85+
86+
587
def test_resolve_dependencies_files():
688
from mxdev.including import resolve_dependencies
789

0 commit comments

Comments
 (0)