qcload 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- qcload-0.1.0/PKG-INFO +86 -0
- qcload-0.1.0/README.md +61 -0
- qcload-0.1.0/pyproject.toml +44 -0
- qcload-0.1.0/src/qcload/__init__.py +153 -0
- qcload-0.1.0/src/qcload/config.py +135 -0
- qcload-0.1.0/src/qcload/exceptions.py +35 -0
- qcload-0.1.0/src/qcload/hunter.py +140 -0
- qcload-0.1.0/src/qcload/parsers.py +95 -0
- qcload-0.1.0/tests/__init__.py +0 -0
- qcload-0.1.0/tests/test_config.py +152 -0
- qcload-0.1.0/tests/test_hunter.py +208 -0
- qcload-0.1.0/tests/test_parsers.py +143 -0
qcload-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: qcload
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Priority-based config loader for qdata ecosystem — hunts config.yaml/.env from home dir upward through parent dirs
|
|
5
|
+
Project-URL: Homepage, https://github.com/fengl/qcload
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Keywords: config,configuration,dotenv,env,quantitative,yaml
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
18
|
+
Requires-Python: >=3.9
|
|
19
|
+
Requires-Dist: python-dotenv>=1.0
|
|
20
|
+
Requires-Dist: pyyaml>=6.0
|
|
21
|
+
Provides-Extra: dev
|
|
22
|
+
Requires-Dist: pytest-cov>=4.0; extra == 'dev'
|
|
23
|
+
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# qcload
|
|
27
|
+
|
|
28
|
+
Priority-based config loader for qdata ecosystem.
|
|
29
|
+
|
|
30
|
+
## Quick start
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
from qcload import config, load_config, load_from_path
|
|
34
|
+
|
|
35
|
+
# Auto-search (global singleton, lazy load)
|
|
36
|
+
db_url = config["DATABASE_URL"]
|
|
37
|
+
|
|
38
|
+
# Functional call (new instance each time)
|
|
39
|
+
cfg = load_config()
|
|
40
|
+
|
|
41
|
+
# Load from specific file path
|
|
42
|
+
cfg = load_from_path("/path/to/my/config.yaml")
|
|
43
|
+
|
|
44
|
+
# Reload the global singleton
|
|
45
|
+
config = reload_config()
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Search priority
|
|
49
|
+
|
|
50
|
+
`qcload` searches for config files in this order (first found wins):
|
|
51
|
+
|
|
52
|
+
1. `~/config.yaml` (user home)
|
|
53
|
+
2. `~/.env`
|
|
54
|
+
3. `./config.yaml` (current directory)
|
|
55
|
+
4. `./.env`
|
|
56
|
+
5. `../config.yaml` (parent)
|
|
57
|
+
6. `../.env`
|
|
58
|
+
7. `../../config.yaml` (grandparent)
|
|
59
|
+
8. `../../.env`
|
|
60
|
+
|
|
61
|
+
## Config object
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
cfg = load_config()
|
|
65
|
+
|
|
66
|
+
# Dict access
|
|
67
|
+
cfg["DATABASE_URL"]
|
|
68
|
+
|
|
69
|
+
# Attribute access (nested yaml)
|
|
70
|
+
cfg.database.host
|
|
71
|
+
|
|
72
|
+
# Type helpers
|
|
73
|
+
cfg.get_int("PORT", 8080)
|
|
74
|
+
cfg.get_bool("DEBUG", False)
|
|
75
|
+
cfg.get_float("RATE", 0.0)
|
|
76
|
+
cfg.get_list("HOSTS", [])
|
|
77
|
+
|
|
78
|
+
# Metadata
|
|
79
|
+
cfg.source # Path to the loaded file
|
|
80
|
+
cfg.file_type # "yaml" or "env"
|
|
81
|
+
cfg.is_empty # True if no keys loaded
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## License
|
|
85
|
+
|
|
86
|
+
MIT
|
qcload-0.1.0/README.md
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# qcload
|
|
2
|
+
|
|
3
|
+
Priority-based config loader for qdata ecosystem.
|
|
4
|
+
|
|
5
|
+
## Quick start
|
|
6
|
+
|
|
7
|
+
```python
|
|
8
|
+
from qcload import config, load_config, load_from_path
|
|
9
|
+
|
|
10
|
+
# Auto-search (global singleton, lazy load)
|
|
11
|
+
db_url = config["DATABASE_URL"]
|
|
12
|
+
|
|
13
|
+
# Functional call (new instance each time)
|
|
14
|
+
cfg = load_config()
|
|
15
|
+
|
|
16
|
+
# Load from specific file path
|
|
17
|
+
cfg = load_from_path("/path/to/my/config.yaml")
|
|
18
|
+
|
|
19
|
+
# Reload the global singleton
|
|
20
|
+
config = reload_config()
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Search priority
|
|
24
|
+
|
|
25
|
+
`qcload` searches for config files in this order (first found wins):
|
|
26
|
+
|
|
27
|
+
1. `~/config.yaml` (user home)
|
|
28
|
+
2. `~/.env`
|
|
29
|
+
3. `./config.yaml` (current directory)
|
|
30
|
+
4. `./.env`
|
|
31
|
+
5. `../config.yaml` (parent)
|
|
32
|
+
6. `../.env`
|
|
33
|
+
7. `../../config.yaml` (grandparent)
|
|
34
|
+
8. `../../.env`
|
|
35
|
+
|
|
36
|
+
## Config object
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
cfg = load_config()
|
|
40
|
+
|
|
41
|
+
# Dict access
|
|
42
|
+
cfg["DATABASE_URL"]
|
|
43
|
+
|
|
44
|
+
# Attribute access (nested yaml)
|
|
45
|
+
cfg.database.host
|
|
46
|
+
|
|
47
|
+
# Type helpers
|
|
48
|
+
cfg.get_int("PORT", 8080)
|
|
49
|
+
cfg.get_bool("DEBUG", False)
|
|
50
|
+
cfg.get_float("RATE", 0.0)
|
|
51
|
+
cfg.get_list("HOSTS", [])
|
|
52
|
+
|
|
53
|
+
# Metadata
|
|
54
|
+
cfg.source # Path to the loaded file
|
|
55
|
+
cfg.file_type # "yaml" or "env"
|
|
56
|
+
cfg.is_empty # True if no keys loaded
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## License
|
|
60
|
+
|
|
61
|
+
MIT
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "qcload"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Priority-based config loader for qdata ecosystem — hunts config.yaml/.env from home dir upward through parent dirs"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
keywords = ["config", "configuration", "yaml", "env", "dotenv", "quantitative"]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Development Status :: 3 - Alpha",
|
|
15
|
+
"Intended Audience :: Developers",
|
|
16
|
+
"License :: OSI Approved :: MIT License",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Programming Language :: Python :: 3.9",
|
|
19
|
+
"Programming Language :: Python :: 3.10",
|
|
20
|
+
"Programming Language :: Python :: 3.11",
|
|
21
|
+
"Programming Language :: Python :: 3.12",
|
|
22
|
+
"Programming Language :: Python :: 3.13",
|
|
23
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
24
|
+
]
|
|
25
|
+
dependencies = [
|
|
26
|
+
"pyyaml>=6.0",
|
|
27
|
+
"python-dotenv>=1.0",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[project.optional-dependencies]
|
|
31
|
+
dev = [
|
|
32
|
+
"pytest>=7.0",
|
|
33
|
+
"pytest-cov>=4.0",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
[project.urls]
|
|
37
|
+
Homepage = "https://github.com/fengl/qcload"
|
|
38
|
+
|
|
39
|
+
[tool.hatch.build.targets.wheel]
|
|
40
|
+
packages = ["src/qcload"]
|
|
41
|
+
|
|
42
|
+
[tool.pytest.ini_options]
|
|
43
|
+
testpaths = ["tests"]
|
|
44
|
+
pythonpath = ["src"]
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""qcload — Priority-based config loader for qdata ecosystem.
|
|
2
|
+
|
|
3
|
+
Quick start:
|
|
4
|
+
from qcload import config, load_config, load_from_path
|
|
5
|
+
|
|
6
|
+
# Auto-search (global singleton, lazy load)
|
|
7
|
+
db_url = config["DATABASE_URL"]
|
|
8
|
+
|
|
9
|
+
# Functional call (new instance each time)
|
|
10
|
+
cfg = load_config()
|
|
11
|
+
|
|
12
|
+
# Load from specific file path
|
|
13
|
+
cfg = load_from_path("/path/to/my/config.yaml")
|
|
14
|
+
|
|
15
|
+
# Reload the global singleton
|
|
16
|
+
config = reload_config()
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
from .config import Config
|
|
25
|
+
from .exceptions import ConfigNotFoundError, ConfigParseError, RequiredKeyError
|
|
26
|
+
from .hunter import hunt, load_from_path as _load_from_path
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"config",
|
|
30
|
+
"load_config",
|
|
31
|
+
"load_from_path",
|
|
32
|
+
"reload_config",
|
|
33
|
+
"Config",
|
|
34
|
+
"ConfigNotFoundError",
|
|
35
|
+
"ConfigParseError",
|
|
36
|
+
"RequiredKeyError",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class _GlobalConfig:
|
|
41
|
+
"""Lazy-loading global config singleton.
|
|
42
|
+
|
|
43
|
+
The actual hunt/search only runs on first access,
|
|
44
|
+
so importing qcload never triggers file I/O.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
def __init__(self) -> None:
|
|
48
|
+
self._config: Config | None = None
|
|
49
|
+
|
|
50
|
+
def _ensure_loaded(self) -> Config:
|
|
51
|
+
if self._config is None:
|
|
52
|
+
self._config = load_config()
|
|
53
|
+
return self._config
|
|
54
|
+
|
|
55
|
+
# Delegate everything to the underlying Config object
|
|
56
|
+
|
|
57
|
+
def __getitem__(self, key: str) -> Any:
|
|
58
|
+
return self._ensure_loaded()[key]
|
|
59
|
+
|
|
60
|
+
def __getattr__(self, name: str) -> Any:
|
|
61
|
+
if name.startswith("_"):
|
|
62
|
+
raise AttributeError(name)
|
|
63
|
+
return getattr(self._ensure_loaded(), name)
|
|
64
|
+
|
|
65
|
+
def __contains__(self, key: str) -> bool:
|
|
66
|
+
return key in self._ensure_loaded()
|
|
67
|
+
|
|
68
|
+
def __repr__(self) -> str:
|
|
69
|
+
if self._config is None:
|
|
70
|
+
return "Config(<not yet loaded>)"
|
|
71
|
+
return repr(self._config)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# The global singleton — lazy, import-safe
|
|
75
|
+
config = _GlobalConfig()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def load_config(
|
|
79
|
+
cwd: str | Path | None = None,
|
|
80
|
+
max_parent_depth: int = 2,
|
|
81
|
+
extra_paths: list[str | Path] | None = None,
|
|
82
|
+
required: list[str] | None = None,
|
|
83
|
+
) -> Config:
|
|
84
|
+
"""Search for and load the first available config file.
|
|
85
|
+
|
|
86
|
+
Priority order (first found wins):
|
|
87
|
+
1. extra_paths (if provided)
|
|
88
|
+
2. ~/config.yaml
|
|
89
|
+
3. ~/.env
|
|
90
|
+
4. ./config.yaml (CWD)
|
|
91
|
+
5. ./.env
|
|
92
|
+
6. ../config.yaml
|
|
93
|
+
7. ../.env
|
|
94
|
+
8. ../../config.yaml
|
|
95
|
+
9. ../../.env
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
cwd: Override working directory for search.
|
|
99
|
+
max_parent_depth: Parent traversal depth (default 2).
|
|
100
|
+
extra_paths: Additional file paths with highest priority.
|
|
101
|
+
required: Keys that must exist in loaded config.
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
Config object with dict/attribute access and type helpers.
|
|
105
|
+
|
|
106
|
+
Raises:
|
|
107
|
+
ConfigNotFoundError: No config file found anywhere.
|
|
108
|
+
ConfigParseError: Found file but failed to parse.
|
|
109
|
+
RequiredKeyError: Required keys missing.
|
|
110
|
+
"""
|
|
111
|
+
data, source, file_type = hunt(cwd, max_parent_depth, extra_paths, required)
|
|
112
|
+
return Config(data, source=source, file_type=file_type)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def load_from_path(
|
|
116
|
+
file_path: str | Path,
|
|
117
|
+
required: list[str] | None = None,
|
|
118
|
+
) -> Config:
|
|
119
|
+
"""Load config from a specific file path directly (no search).
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
file_path: Path to the config file (.yaml, .yml, or .env).
|
|
123
|
+
required: Keys that must exist in loaded config.
|
|
124
|
+
|
|
125
|
+
Returns:
|
|
126
|
+
Config object.
|
|
127
|
+
|
|
128
|
+
Raises:
|
|
129
|
+
FileNotFoundError: File does not exist.
|
|
130
|
+
ConfigParseError: Failed to parse.
|
|
131
|
+
RequiredKeyError: Required keys missing.
|
|
132
|
+
"""
|
|
133
|
+
data, source, file_type = _load_from_path(file_path, required)
|
|
134
|
+
return Config(data, source=source, file_type=file_type)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def reload_config(
|
|
138
|
+
cwd: str | Path | None = None,
|
|
139
|
+
max_parent_depth: int = 2,
|
|
140
|
+
extra_paths: list[str | Path] | None = None,
|
|
141
|
+
required: list[str] | None = None,
|
|
142
|
+
) -> Config:
|
|
143
|
+
"""Force re-search and reload the global config singleton.
|
|
144
|
+
|
|
145
|
+
Same arguments as load_config(). Useful after config files
|
|
146
|
+
are modified on disk.
|
|
147
|
+
|
|
148
|
+
Returns:
|
|
149
|
+
Freshly loaded Config object (also updates the global singleton).
|
|
150
|
+
"""
|
|
151
|
+
new_cfg = load_config(cwd, max_parent_depth, extra_paths, required)
|
|
152
|
+
config._config = new_cfg
|
|
153
|
+
return new_cfg
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Config object — dict-like access with attribute access and type helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Config:
|
|
10
|
+
"""Rich config wrapper around a plain dict.
|
|
11
|
+
|
|
12
|
+
Supports:
|
|
13
|
+
- Dict access: config["DATABASE_URL"]
|
|
14
|
+
- Attribute access: config.database.host (for nested yaml)
|
|
15
|
+
- .get() with default: config.get("KEY", "fallback")
|
|
16
|
+
- Type helpers: config.get_int("PORT", 8080)
|
|
17
|
+
config.get_bool("DEBUG", False)
|
|
18
|
+
config.get_list("HOSTS", [])
|
|
19
|
+
- Metadata: config.source, config.file_type, config.is_empty
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
data: dict[str, Any],
|
|
25
|
+
source: Path | str | None = None,
|
|
26
|
+
file_type: str = "unknown",
|
|
27
|
+
) -> None:
|
|
28
|
+
self._data = data
|
|
29
|
+
self.source = Path(source) if source else None
|
|
30
|
+
self.file_type = file_type
|
|
31
|
+
|
|
32
|
+
# --- dict-style access --------------------------------------------------
|
|
33
|
+
|
|
34
|
+
def __getitem__(self, key: str) -> Any:
|
|
35
|
+
return self._data[key]
|
|
36
|
+
|
|
37
|
+
def __contains__(self, key: str) -> bool:
|
|
38
|
+
return key in self._data
|
|
39
|
+
|
|
40
|
+
def __len__(self) -> int:
|
|
41
|
+
return len(self._data)
|
|
42
|
+
|
|
43
|
+
def __iter__(self) -> Any:
|
|
44
|
+
return iter(self._data)
|
|
45
|
+
|
|
46
|
+
def __repr__(self) -> str:
|
|
47
|
+
src = str(self.source) if self.source else "none"
|
|
48
|
+
return f"Config(source={src}, type={self.file_type}, keys={list(self._data.keys())})"
|
|
49
|
+
|
|
50
|
+
def get(self, key: str, default: Any = None) -> Any:
|
|
51
|
+
"""Get a value with a fallback default."""
|
|
52
|
+
return self._data.get(key, default)
|
|
53
|
+
|
|
54
|
+
# --- attribute-style access (for nested dicts) -------------------------
|
|
55
|
+
|
|
56
|
+
def __getattr__(self, name: str) -> Any:
|
|
57
|
+
if name.startswith("_"):
|
|
58
|
+
raise AttributeError(name)
|
|
59
|
+
try:
|
|
60
|
+
val = self._data[name]
|
|
61
|
+
except KeyError:
|
|
62
|
+
raise AttributeError(f"No config key: '{name}'")
|
|
63
|
+
# Wrap nested dicts so attribute access chains work
|
|
64
|
+
if isinstance(val, dict):
|
|
65
|
+
return Config(val, source=self.source, file_type=self.file_type)
|
|
66
|
+
return val
|
|
67
|
+
|
|
68
|
+
# --- type helpers -------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
def get_int(self, key: str, default: int = 0) -> int:
|
|
71
|
+
"""Get a value as int. Falls back to default if key missing or conversion fails."""
|
|
72
|
+
val = self._data.get(key)
|
|
73
|
+
if val is None:
|
|
74
|
+
return default
|
|
75
|
+
try:
|
|
76
|
+
return int(val)
|
|
77
|
+
except (ValueError, TypeError):
|
|
78
|
+
return default
|
|
79
|
+
|
|
80
|
+
def get_bool(self, key: str, default: bool = False) -> bool:
|
|
81
|
+
"""Get a value as bool. Recognizes common truthy/falsy string values."""
|
|
82
|
+
val = self._data.get(key)
|
|
83
|
+
if val is None:
|
|
84
|
+
return default
|
|
85
|
+
if isinstance(val, bool):
|
|
86
|
+
return val
|
|
87
|
+
if isinstance(val, str):
|
|
88
|
+
return val.lower() in ("true", "yes", "1", "on")
|
|
89
|
+
return bool(val)
|
|
90
|
+
|
|
91
|
+
def get_float(self, key: str, default: float = 0.0) -> float:
|
|
92
|
+
"""Get a value as float."""
|
|
93
|
+
val = self._data.get(key)
|
|
94
|
+
if val is None:
|
|
95
|
+
return default
|
|
96
|
+
try:
|
|
97
|
+
return float(val)
|
|
98
|
+
except (ValueError, TypeError):
|
|
99
|
+
return default
|
|
100
|
+
|
|
101
|
+
def get_list(self, key: str, default: list[Any] | None = None) -> list[Any]:
|
|
102
|
+
"""Get a value as list. If the value is a string, split by comma."""
|
|
103
|
+
if default is None:
|
|
104
|
+
default = []
|
|
105
|
+
val = self._data.get(key)
|
|
106
|
+
if val is None:
|
|
107
|
+
return default
|
|
108
|
+
if isinstance(val, list):
|
|
109
|
+
return val
|
|
110
|
+
if isinstance(val, str):
|
|
111
|
+
return [item.strip() for item in val.split(",") if item.strip()]
|
|
112
|
+
return default
|
|
113
|
+
|
|
114
|
+
# --- metadata -----------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
@property
|
|
117
|
+
def is_empty(self) -> bool:
|
|
118
|
+
"""True if no config keys were loaded."""
|
|
119
|
+
return len(self._data) == 0
|
|
120
|
+
|
|
121
|
+
def to_dict(self) -> dict[str, Any]:
|
|
122
|
+
"""Return the raw config data as a plain dict."""
|
|
123
|
+
return dict(self._data)
|
|
124
|
+
|
|
125
|
+
def keys(self) -> Any:
|
|
126
|
+
"""Return config keys."""
|
|
127
|
+
return self._data.keys()
|
|
128
|
+
|
|
129
|
+
def values(self) -> Any:
|
|
130
|
+
"""Return config values."""
|
|
131
|
+
return self._data.values()
|
|
132
|
+
|
|
133
|
+
def items(self) -> Any:
|
|
134
|
+
"""Return config items."""
|
|
135
|
+
return self._data.items()
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Custom exceptions for qcload."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class ConfigNotFoundError(Exception):
|
|
5
|
+
"""Raised when no config file is found in any search path."""
|
|
6
|
+
|
|
7
|
+
def __init__(self, search_paths: list[str] | None = None) -> None:
|
|
8
|
+
self.search_paths = search_paths or []
|
|
9
|
+
paths_str = "\n - ".join(self.search_paths) if self.search_paths else "(none)"
|
|
10
|
+
super().__init__(f"No config file found in search paths:\n - {paths_str}")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ConfigParseError(Exception):
|
|
14
|
+
"""Raised when a config file exists but cannot be parsed."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, file_path: str, reason: str = "") -> None:
|
|
17
|
+
self.file_path = file_path
|
|
18
|
+
self.reason = reason
|
|
19
|
+
msg = f"Failed to parse config file: {file_path}"
|
|
20
|
+
if reason:
|
|
21
|
+
msg += f" — {reason}"
|
|
22
|
+
super().__init__(msg)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class RequiredKeyError(Exception):
|
|
26
|
+
"""Raised when a required key is missing from the loaded config."""
|
|
27
|
+
|
|
28
|
+
def __init__(self, missing_keys: list[str], source: str = "") -> None:
|
|
29
|
+
self.missing_keys = missing_keys
|
|
30
|
+
self.source = source
|
|
31
|
+
keys_str = ", ".join(missing_keys)
|
|
32
|
+
msg = f"Required keys missing: {keys_str}"
|
|
33
|
+
if source:
|
|
34
|
+
msg += f" (source: {source})"
|
|
35
|
+
super().__init__(msg)
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Core config hunting logic — searches directories in priority order."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from .exceptions import ConfigNotFoundError, RequiredKeyError
|
|
9
|
+
from .parsers import detect_file_type, parse_file
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
# Default config file names to look for at each directory level
|
|
13
|
+
_DEFAULT_NAMES = ["config.yaml", ".env"]
|
|
14
|
+
|
|
15
|
+
# Maximum number of parent directories to traverse upward from cwd
|
|
16
|
+
_MAX_PARENT_DEPTH = 2
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def build_search_paths(
|
|
20
|
+
cwd: str | Path | None = None,
|
|
21
|
+
max_parent_depth: int = _MAX_PARENT_DEPTH,
|
|
22
|
+
extra_paths: list[str | Path] | None = None,
|
|
23
|
+
) -> list[Path]:
|
|
24
|
+
"""Build the ordered list of config file paths to search.
|
|
25
|
+
|
|
26
|
+
Priority order (first match wins):
|
|
27
|
+
1. User home dir: config.yaml, .env
|
|
28
|
+
2. CWD: config.yaml, .env
|
|
29
|
+
3. CWD/../: config.yaml, .env
|
|
30
|
+
4. CWD/../../: config.yaml, .env
|
|
31
|
+
5. User-specified extra_paths (if provided)
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
cwd: Current working directory. Defaults to os.getcwd().
|
|
35
|
+
max_parent_depth: How many parent dirs to traverse upward (default 2).
|
|
36
|
+
extra_paths: Additional file paths prepended with highest priority.
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
Ordered list of absolute Path objects to check.
|
|
40
|
+
"""
|
|
41
|
+
if cwd is None:
|
|
42
|
+
cwd = Path.cwd()
|
|
43
|
+
else:
|
|
44
|
+
cwd = Path(cwd).resolve()
|
|
45
|
+
|
|
46
|
+
paths: list[Path] = []
|
|
47
|
+
|
|
48
|
+
# User-specified paths have highest priority
|
|
49
|
+
if extra_paths:
|
|
50
|
+
for p in extra_paths:
|
|
51
|
+
paths.append(Path(p).resolve())
|
|
52
|
+
|
|
53
|
+
# Home directory
|
|
54
|
+
home = Path.home()
|
|
55
|
+
for name in _DEFAULT_NAMES:
|
|
56
|
+
paths.append(home / name)
|
|
57
|
+
|
|
58
|
+
# CWD and parent dirs upward
|
|
59
|
+
current = cwd
|
|
60
|
+
for _ in range(max_parent_depth + 1):
|
|
61
|
+
for name in _DEFAULT_NAMES:
|
|
62
|
+
paths.append(current / name)
|
|
63
|
+
parent = current.parent
|
|
64
|
+
if parent == current:
|
|
65
|
+
# Reached filesystem root, stop
|
|
66
|
+
break
|
|
67
|
+
current = parent
|
|
68
|
+
|
|
69
|
+
return paths
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def hunt(
|
|
73
|
+
cwd: str | Path | None = None,
|
|
74
|
+
max_parent_depth: int = _MAX_PARENT_DEPTH,
|
|
75
|
+
extra_paths: list[str | Path] | None = None,
|
|
76
|
+
required: list[str] | None = None,
|
|
77
|
+
) -> tuple[dict[str, Any], Path, str]:
|
|
78
|
+
"""Search for and load the first available config file.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
cwd: Working directory for search. Defaults to os.getcwd().
|
|
82
|
+
max_parent_depth: Parent traversal depth (default 2).
|
|
83
|
+
extra_paths: Additional config file paths (highest priority).
|
|
84
|
+
required: Keys that must exist in the loaded config.
|
|
85
|
+
Raises RequiredKeyError if any are missing.
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
Tuple of (config_dict, source_path, file_type).
|
|
89
|
+
|
|
90
|
+
Raises:
|
|
91
|
+
ConfigNotFoundError: No config file found in any search path.
|
|
92
|
+
ConfigParseError: Found file but failed to parse it.
|
|
93
|
+
RequiredKeyError: Required keys missing from loaded config.
|
|
94
|
+
"""
|
|
95
|
+
search_paths = build_search_paths(cwd, max_parent_depth, extra_paths)
|
|
96
|
+
|
|
97
|
+
for path in search_paths:
|
|
98
|
+
if path.is_file():
|
|
99
|
+
config = parse_file(path)
|
|
100
|
+
file_type = detect_file_type(path)
|
|
101
|
+
|
|
102
|
+
if required:
|
|
103
|
+
missing = [k for k in required if k not in config]
|
|
104
|
+
if missing:
|
|
105
|
+
raise RequiredKeyError(missing, str(path))
|
|
106
|
+
|
|
107
|
+
return config, path, file_type
|
|
108
|
+
|
|
109
|
+
raise ConfigNotFoundError([str(p) for p in search_paths])
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def load_from_path(file_path: str | Path, required: list[str] | None = None) -> tuple[dict[str, Any], Path, str]:
|
|
113
|
+
"""Load config from a specific file path directly (no search).
|
|
114
|
+
|
|
115
|
+
Args:
|
|
116
|
+
file_path: Absolute or relative path to the config file.
|
|
117
|
+
required: Keys that must exist in the loaded config.
|
|
118
|
+
|
|
119
|
+
Returns:
|
|
120
|
+
Tuple of (config_dict, source_path, file_type).
|
|
121
|
+
|
|
122
|
+
Raises:
|
|
123
|
+
FileNotFoundError: The specified file does not exist.
|
|
124
|
+
ConfigParseError: Found file but failed to parse it.
|
|
125
|
+
RequiredKeyError: Required keys missing from loaded config.
|
|
126
|
+
"""
|
|
127
|
+
path = Path(file_path).resolve()
|
|
128
|
+
|
|
129
|
+
if not path.is_file():
|
|
130
|
+
raise FileNotFoundError(f"Config file not found: {path}")
|
|
131
|
+
|
|
132
|
+
config = parse_file(path)
|
|
133
|
+
file_type = detect_file_type(path)
|
|
134
|
+
|
|
135
|
+
if required:
|
|
136
|
+
missing = [k for k in required if k not in config]
|
|
137
|
+
if missing:
|
|
138
|
+
raise RequiredKeyError(missing, str(path))
|
|
139
|
+
|
|
140
|
+
return config, path, file_type
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""File parsers for yaml and .env config formats."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import yaml
|
|
10
|
+
from dotenv import dotenv_values
|
|
11
|
+
|
|
12
|
+
from .exceptions import ConfigParseError
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def parse_yaml(file_path: str | Path) -> dict[str, Any]:
|
|
16
|
+
"""Parse a YAML config file and return a dict.
|
|
17
|
+
|
|
18
|
+
Raises ConfigParseError if the file cannot be read or parsed.
|
|
19
|
+
"""
|
|
20
|
+
path = Path(file_path)
|
|
21
|
+
try:
|
|
22
|
+
with open(path, encoding="utf-8") as f:
|
|
23
|
+
data = yaml.safe_load(f)
|
|
24
|
+
except yaml.YAMLError as e:
|
|
25
|
+
raise ConfigParseError(str(path), f"YAML syntax error: {e}") from e
|
|
26
|
+
except OSError as e:
|
|
27
|
+
raise ConfigParseError(str(path), f"IO error: {e}") from e
|
|
28
|
+
|
|
29
|
+
if data is None:
|
|
30
|
+
return {}
|
|
31
|
+
if not isinstance(data, dict):
|
|
32
|
+
raise ConfigParseError(str(path), f"Expected dict, got {type(data).__name__}")
|
|
33
|
+
return data
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def parse_env(file_path: str | Path) -> dict[str, str]:
|
|
37
|
+
"""Parse a .env file and return a flat dict of key-value strings.
|
|
38
|
+
|
|
39
|
+
Uses python-dotenv's dotenv_values which handles comments,
|
|
40
|
+
quotes, and multiline values.
|
|
41
|
+
|
|
42
|
+
Raises ConfigParseError if the file cannot be read.
|
|
43
|
+
"""
|
|
44
|
+
path = Path(file_path)
|
|
45
|
+
try:
|
|
46
|
+
values = dotenv_values(path)
|
|
47
|
+
except Exception as e:
|
|
48
|
+
raise ConfigParseError(str(path), f"Failed to read .env: {e}") from e
|
|
49
|
+
|
|
50
|
+
# dotenv_values returns None for empty/unset values
|
|
51
|
+
result: dict[str, str] = {}
|
|
52
|
+
for key, val in values.items():
|
|
53
|
+
if val is not None:
|
|
54
|
+
result[key] = val
|
|
55
|
+
|
|
56
|
+
return result
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _is_env_file(path: Path) -> bool:
|
|
60
|
+
"""Check if a path is a .env file.
|
|
61
|
+
|
|
62
|
+
Handles both '.env' (dotfile, suffix may be empty on Windows)
|
|
63
|
+
and 'custom.env' (regular file with .env extension).
|
|
64
|
+
"""
|
|
65
|
+
name = path.name.lower()
|
|
66
|
+
return name == ".env" or name.endswith(".env")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def parse_file(file_path: str | Path) -> dict[str, Any]:
|
|
70
|
+
"""Auto-detect file format and parse accordingly.
|
|
71
|
+
|
|
72
|
+
Dispatches to parse_yaml for .yaml/.yml extensions,
|
|
73
|
+
parse_env for .env files, and raises ConfigParseError
|
|
74
|
+
for unsupported formats.
|
|
75
|
+
"""
|
|
76
|
+
path = Path(file_path)
|
|
77
|
+
ext = path.suffix.lower()
|
|
78
|
+
|
|
79
|
+
if ext in (".yaml", ".yml"):
|
|
80
|
+
return parse_yaml(path)
|
|
81
|
+
elif _is_env_file(path):
|
|
82
|
+
return parse_env(path)
|
|
83
|
+
else:
|
|
84
|
+
raise ConfigParseError(str(path), f"Unsupported config format: '{ext}'")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def detect_file_type(file_path: str | Path) -> str:
|
|
88
|
+
"""Return 'yaml' or 'env' based on file name/extension."""
|
|
89
|
+
path = Path(file_path)
|
|
90
|
+
ext = path.suffix.lower()
|
|
91
|
+
if ext in (".yaml", ".yml"):
|
|
92
|
+
return "yaml"
|
|
93
|
+
elif _is_env_file(path):
|
|
94
|
+
return "env"
|
|
95
|
+
return "unknown"
|
|
File without changes
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Tests for qcload Config object."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from qcload.config import Config
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class TestConfigDictAccess:
|
|
13
|
+
"""Tests for dict-style access."""
|
|
14
|
+
|
|
15
|
+
def test_getitem(self) -> None:
|
|
16
|
+
cfg = Config({"KEY": "value"}, source="/test/config.yaml", file_type="yaml")
|
|
17
|
+
assert cfg["KEY"] == "value"
|
|
18
|
+
|
|
19
|
+
def test_getitem_missing_raises_keyerror(self) -> None:
|
|
20
|
+
cfg = Config({"KEY": "value"}, source="/test/config.yaml")
|
|
21
|
+
with pytest.raises(KeyError):
|
|
22
|
+
cfg["MISSING"]
|
|
23
|
+
|
|
24
|
+
def test_contains(self) -> None:
|
|
25
|
+
cfg = Config({"KEY": "value"})
|
|
26
|
+
assert "KEY" in cfg
|
|
27
|
+
assert "MISSING" not in cfg
|
|
28
|
+
|
|
29
|
+
def test_len(self) -> None:
|
|
30
|
+
cfg = Config({"A": 1, "B": 2, "C": 3})
|
|
31
|
+
assert len(cfg) == 3
|
|
32
|
+
|
|
33
|
+
def test_iter(self) -> None:
|
|
34
|
+
cfg = Config({"A": 1, "B": 2})
|
|
35
|
+
assert set(cfg) == {"A", "B"}
|
|
36
|
+
|
|
37
|
+
def test_get_with_default(self) -> None:
|
|
38
|
+
cfg = Config({"KEY": "value"})
|
|
39
|
+
assert cfg.get("KEY") == "value"
|
|
40
|
+
assert cfg.get("MISSING", "fallback") == "fallback"
|
|
41
|
+
assert cfg.get("MISSING") is None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class TestConfigAttributeAccess:
|
|
45
|
+
"""Tests for attribute-style access (nested yaml dicts)."""
|
|
46
|
+
|
|
47
|
+
def test_simple_attribute(self) -> None:
|
|
48
|
+
cfg = Config({"database": {"host": "localhost", "port": 5432}})
|
|
49
|
+
assert cfg.database.host == "localhost"
|
|
50
|
+
assert cfg.database.port == 5432
|
|
51
|
+
|
|
52
|
+
def test_attribute_returns_config_for_nested_dict(self) -> None:
|
|
53
|
+
cfg = Config({"database": {"host": "localhost"}})
|
|
54
|
+
nested = cfg.database
|
|
55
|
+
assert isinstance(nested, Config)
|
|
56
|
+
assert nested.host == "localhost"
|
|
57
|
+
|
|
58
|
+
def test_attribute_missing_raises_attributeerror(self) -> None:
|
|
59
|
+
cfg = Config({"KEY": "value"})
|
|
60
|
+
with pytest.raises(AttributeError, match="No config key"):
|
|
61
|
+
cfg.nonexistent
|
|
62
|
+
|
|
63
|
+
def test_private_attr_raises_attributeerror(self) -> None:
|
|
64
|
+
cfg = Config({})
|
|
65
|
+
with pytest.raises(AttributeError):
|
|
66
|
+
cfg._nonexistent
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class TestConfigTypeHelpers:
|
|
70
|
+
"""Tests for type conversion helpers."""
|
|
71
|
+
|
|
72
|
+
def test_get_int(self) -> None:
|
|
73
|
+
cfg = Config({"PORT": "8080", "ZERO": 0})
|
|
74
|
+
assert cfg.get_int("PORT") == 8080
|
|
75
|
+
assert cfg.get_int("ZERO") == 0
|
|
76
|
+
assert cfg.get_int("MISSING", 3000) == 3000
|
|
77
|
+
|
|
78
|
+
def test_get_int_invalid_returns_default(self) -> None:
|
|
79
|
+
cfg = Config({"PORT": "not_a_number"})
|
|
80
|
+
assert cfg.get_int("PORT", 8080) == 8080
|
|
81
|
+
|
|
82
|
+
def test_get_bool(self) -> None:
|
|
83
|
+
cfg = Config({
|
|
84
|
+
"DEBUG_TRUE": "true",
|
|
85
|
+
"DEBUG_YES": "yes",
|
|
86
|
+
"DEBUG_ON": "on",
|
|
87
|
+
"DEBUG_1": "1",
|
|
88
|
+
"DEBUG_FALSE": "false",
|
|
89
|
+
"BOOL_TYPE": True,
|
|
90
|
+
})
|
|
91
|
+
assert cfg.get_bool("DEBUG_TRUE") is True
|
|
92
|
+
assert cfg.get_bool("DEBUG_YES") is True
|
|
93
|
+
assert cfg.get_bool("DEBUG_ON") is True
|
|
94
|
+
assert cfg.get_bool("DEBUG_1") is True
|
|
95
|
+
assert cfg.get_bool("DEBUG_FALSE") is False
|
|
96
|
+
assert cfg.get_bool("BOOL_TYPE") is True
|
|
97
|
+
assert cfg.get_bool("MISSING", False) is False
|
|
98
|
+
|
|
99
|
+
def test_get_float(self) -> None:
|
|
100
|
+
cfg = Config({"RATE": "3.14"})
|
|
101
|
+
assert cfg.get_float("RATE") == 3.14
|
|
102
|
+
assert cfg.get_float("MISSING", 0.0) == 0.0
|
|
103
|
+
|
|
104
|
+
def test_get_list_from_yaml_list(self) -> None:
|
|
105
|
+
cfg = Config({"HOSTS": ["a", "b", "c"]})
|
|
106
|
+
assert cfg.get_list("HOSTS") == ["a", "b", "c"]
|
|
107
|
+
|
|
108
|
+
def test_get_list_from_comma_string(self) -> None:
|
|
109
|
+
cfg = Config({"HOSTS": "a, b, c"})
|
|
110
|
+
assert cfg.get_list("HOSTS") == ["a", "b", "c"]
|
|
111
|
+
|
|
112
|
+
def test_get_list_missing_returns_default(self) -> None:
|
|
113
|
+
cfg = Config({})
|
|
114
|
+
assert cfg.get_list("MISSING") == []
|
|
115
|
+
assert cfg.get_list("MISSING", ["default"]) == ["default"]
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class TestConfigMetadata:
|
|
119
|
+
"""Tests for metadata properties."""
|
|
120
|
+
|
|
121
|
+
def test_source(self) -> None:
|
|
122
|
+
cfg = Config({}, source="/test/config.yaml", file_type="yaml")
|
|
123
|
+
assert cfg.source == Path("/test/config.yaml")
|
|
124
|
+
|
|
125
|
+
def test_file_type(self) -> None:
|
|
126
|
+
cfg = Config({}, file_type="env")
|
|
127
|
+
assert cfg.file_type == "env"
|
|
128
|
+
|
|
129
|
+
def test_is_empty(self) -> None:
|
|
130
|
+
assert Config({}).is_empty is True
|
|
131
|
+
assert Config({"KEY": "val"}).is_empty is False
|
|
132
|
+
|
|
133
|
+
def test_repr(self) -> None:
|
|
134
|
+
cfg = Config({"A": 1}, source="/test/config.yaml", file_type="yaml")
|
|
135
|
+
assert "yaml" in repr(cfg)
|
|
136
|
+
assert "A" in repr(cfg)
|
|
137
|
+
|
|
138
|
+
def test_repr_not_loaded(self) -> None:
|
|
139
|
+
cfg = Config({})
|
|
140
|
+
# Just check repr doesn't crash
|
|
141
|
+
assert isinstance(repr(cfg), str)
|
|
142
|
+
|
|
143
|
+
def test_to_dict(self) -> None:
|
|
144
|
+
data = {"KEY": "value", "PORT": 8080}
|
|
145
|
+
cfg = Config(data)
|
|
146
|
+
assert cfg.to_dict() == data
|
|
147
|
+
|
|
148
|
+
def test_keys_values_items(self) -> None:
|
|
149
|
+
cfg = Config({"A": 1, "B": 2})
|
|
150
|
+
assert set(cfg.keys()) == {"A", "B"}
|
|
151
|
+
assert set(cfg.values()) == {1, 2}
|
|
152
|
+
assert set(cfg.items()) == {("A", 1), ("B", 2)}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""Tests for qcload hunter module — config search and loading logic."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import tempfile
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
from qcload.exceptions import ConfigNotFoundError, RequiredKeyError
|
|
12
|
+
from qcload.hunter import build_search_paths, hunt, load_from_path
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class TestBuildSearchPaths:
|
|
16
|
+
"""Tests for the search path builder."""
|
|
17
|
+
|
|
18
|
+
def test_default_search_order(self) -> None:
|
|
19
|
+
paths = build_search_paths(cwd="/project/qdata")
|
|
20
|
+
home = Path.home()
|
|
21
|
+
|
|
22
|
+
# Should start with home dir files
|
|
23
|
+
assert paths[0] == home / "config.yaml"
|
|
24
|
+
assert paths[1] == home / ".env"
|
|
25
|
+
|
|
26
|
+
# Then cwd files
|
|
27
|
+
cwd = Path("/project/qdata").resolve()
|
|
28
|
+
assert paths[2] == cwd / "config.yaml"
|
|
29
|
+
assert paths[3] == cwd / ".env"
|
|
30
|
+
|
|
31
|
+
# Then parent files
|
|
32
|
+
parent = cwd.parent
|
|
33
|
+
assert paths[4] == parent / "config.yaml"
|
|
34
|
+
assert paths[5] == parent / ".env"
|
|
35
|
+
|
|
36
|
+
# Then grandparent files
|
|
37
|
+
grandparent = parent.parent
|
|
38
|
+
assert paths[6] == grandparent / "config.yaml"
|
|
39
|
+
assert paths[7] == grandparent / ".env"
|
|
40
|
+
|
|
41
|
+
def test_extra_paths_have_highest_priority(self) -> None:
|
|
42
|
+
paths = build_search_paths(
|
|
43
|
+
cwd="/project",
|
|
44
|
+
extra_paths=["/custom/config.yaml"],
|
|
45
|
+
)
|
|
46
|
+
assert paths[0] == Path("/custom/config.yaml").resolve()
|
|
47
|
+
|
|
48
|
+
def test_max_parent_depth_zero(self) -> None:
|
|
49
|
+
paths = build_search_paths(cwd="/deep/project", max_parent_depth=0)
|
|
50
|
+
# Home + cwd only, no parent traversal
|
|
51
|
+
home = Path.home()
|
|
52
|
+
cwd = Path("/deep/project").resolve()
|
|
53
|
+
|
|
54
|
+
home_paths = [p for p in paths if p.parent == home]
|
|
55
|
+
cwd_paths = [p for p in paths if p.parent == cwd]
|
|
56
|
+
parent_paths = [p for p in paths if p.parent == cwd.parent]
|
|
57
|
+
|
|
58
|
+
assert len(home_paths) == 2
|
|
59
|
+
assert len(cwd_paths) == 2
|
|
60
|
+
assert len(parent_paths) == 0
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class TestHunt:
|
|
64
|
+
"""Tests for the hunt (search & load) function."""
|
|
65
|
+
|
|
66
|
+
def test_find_config_in_cwd(self, tmp_path: Path) -> None:
|
|
67
|
+
config_file = tmp_path / "config.yaml"
|
|
68
|
+
config_file.write_text("DB_HOST: localhost\nPORT: 5432\n", encoding="utf-8")
|
|
69
|
+
|
|
70
|
+
data, source, file_type = hunt(cwd=tmp_path)
|
|
71
|
+
assert data["DB_HOST"] == "localhost"
|
|
72
|
+
assert source == config_file
|
|
73
|
+
assert file_type == "yaml"
|
|
74
|
+
|
|
75
|
+
def test_find_env_in_cwd(self, tmp_path: Path) -> None:
|
|
76
|
+
env_file = tmp_path / ".env"
|
|
77
|
+
env_file.write_text("API_KEY=abc123\nDEBUG=true\n", encoding="utf-8")
|
|
78
|
+
|
|
79
|
+
data, source, file_type = hunt(cwd=tmp_path)
|
|
80
|
+
assert data["API_KEY"] == "abc123"
|
|
81
|
+
assert source == env_file
|
|
82
|
+
assert file_type == "env"
|
|
83
|
+
|
|
84
|
+
def test_yaml_priority_over_env_in_same_dir(self, tmp_path: Path) -> None:
|
|
85
|
+
yaml_file = tmp_path / "config.yaml"
|
|
86
|
+
yaml_file.write_text("KEY: from_yaml\n", encoding="utf-8")
|
|
87
|
+
|
|
88
|
+
env_file = tmp_path / ".env"
|
|
89
|
+
env_file.write_text("KEY=from_env\n", encoding="utf-8")
|
|
90
|
+
|
|
91
|
+
data, source, file_type = hunt(cwd=tmp_path)
|
|
92
|
+
assert data["KEY"] == "from_yaml"
|
|
93
|
+
assert file_type == "yaml"
|
|
94
|
+
|
|
95
|
+
def test_home_priority_over_cwd(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
96
|
+
# Create config in mock home
|
|
97
|
+
fake_home = tmp_path / "home"
|
|
98
|
+
fake_home.mkdir()
|
|
99
|
+
home_config = fake_home / "config.yaml"
|
|
100
|
+
home_config.write_text("KEY: from_home\n", encoding="utf-8")
|
|
101
|
+
|
|
102
|
+
# Create config in cwd (should NOT be loaded)
|
|
103
|
+
work_dir = tmp_path / "workspace"
|
|
104
|
+
work_dir.mkdir()
|
|
105
|
+
cwd_config = work_dir / "config.yaml"
|
|
106
|
+
cwd_config.write_text("KEY: from_cwd\n", encoding="utf-8")
|
|
107
|
+
|
|
108
|
+
monkeypatch.setattr(Path, "home", lambda: fake_home)
|
|
109
|
+
data, source, file_type = hunt(cwd=work_dir)
|
|
110
|
+
assert data["KEY"] == "from_home"
|
|
111
|
+
|
|
112
|
+
def test_cwd_priority_over_parent(self, tmp_path: Path) -> None:
|
|
113
|
+
# Parent has config
|
|
114
|
+
parent_config = tmp_path / "config.yaml"
|
|
115
|
+
parent_config.write_text("KEY: from_parent\n", encoding="utf-8")
|
|
116
|
+
|
|
117
|
+
# Child dir has config (higher priority)
|
|
118
|
+
child_dir = tmp_path / "child"
|
|
119
|
+
child_dir.mkdir()
|
|
120
|
+
child_config = child_dir / "config.yaml"
|
|
121
|
+
child_config.write_text("KEY: from_child\n", encoding="utf-8")
|
|
122
|
+
|
|
123
|
+
data, source, file_type = hunt(cwd=child_dir)
|
|
124
|
+
assert data["KEY"] == "from_child"
|
|
125
|
+
|
|
126
|
+
def test_no_config_found(self, tmp_path: Path) -> None:
|
|
127
|
+
empty_dir = tmp_path / "empty"
|
|
128
|
+
empty_dir.mkdir()
|
|
129
|
+
|
|
130
|
+
with pytest.raises(ConfigNotFoundError):
|
|
131
|
+
hunt(cwd=empty_dir)
|
|
132
|
+
|
|
133
|
+
def test_required_keys_present(self, tmp_path: Path) -> None:
|
|
134
|
+
config_file = tmp_path / "config.yaml"
|
|
135
|
+
config_file.write_text("API_KEY: abc\nDB_URL: postgresql://localhost\n", encoding="utf-8")
|
|
136
|
+
|
|
137
|
+
data, source, file_type = hunt(
|
|
138
|
+
cwd=tmp_path,
|
|
139
|
+
required=["API_KEY", "DB_URL"],
|
|
140
|
+
)
|
|
141
|
+
assert data["API_KEY"] == "abc"
|
|
142
|
+
|
|
143
|
+
def test_required_keys_missing(self, tmp_path: Path) -> None:
|
|
144
|
+
config_file = tmp_path / "config.yaml"
|
|
145
|
+
config_file.write_text("API_KEY: abc\n", encoding="utf-8")
|
|
146
|
+
|
|
147
|
+
with pytest.raises(RequiredKeyError, match="DB_URL"):
|
|
148
|
+
hunt(cwd=tmp_path, required=["API_KEY", "DB_URL"])
|
|
149
|
+
|
|
150
|
+
def test_extra_paths_highest_priority(self, tmp_path: Path) -> None:
|
|
151
|
+
# Cwd has a config
|
|
152
|
+
cwd_config = tmp_path / "config.yaml"
|
|
153
|
+
cwd_config.write_text("KEY: from_cwd\n", encoding="utf-8")
|
|
154
|
+
|
|
155
|
+
# Extra path has another config (should win)
|
|
156
|
+
extra_dir = tmp_path / "extra"
|
|
157
|
+
extra_dir.mkdir()
|
|
158
|
+
extra_config = extra_dir / "config.yaml"
|
|
159
|
+
extra_config.write_text("KEY: from_extra\n", encoding="utf-8")
|
|
160
|
+
|
|
161
|
+
data, source, file_type = hunt(
|
|
162
|
+
cwd=tmp_path,
|
|
163
|
+
extra_paths=[str(extra_config)],
|
|
164
|
+
)
|
|
165
|
+
assert data["KEY"] == "from_extra"
|
|
166
|
+
|
|
167
|
+
def test_fallback_to_parent(self, tmp_path: Path) -> None:
|
|
168
|
+
# Only parent has config
|
|
169
|
+
parent_config = tmp_path / "config.yaml"
|
|
170
|
+
parent_config.write_text("KEY: from_parent\n", encoding="utf-8")
|
|
171
|
+
|
|
172
|
+
# Child has no config
|
|
173
|
+
child_dir = tmp_path / "child"
|
|
174
|
+
child_dir.mkdir()
|
|
175
|
+
|
|
176
|
+
data, source, file_type = hunt(cwd=child_dir)
|
|
177
|
+
assert data["KEY"] == "from_parent"
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
class TestLoadFromPath:
|
|
181
|
+
"""Tests for direct path loading."""
|
|
182
|
+
|
|
183
|
+
def test_load_yaml_directly(self, tmp_path: Path) -> None:
|
|
184
|
+
config_file = tmp_path / "my_config.yaml"
|
|
185
|
+
config_file.write_text("KEY: value\n", encoding="utf-8")
|
|
186
|
+
|
|
187
|
+
data, source, file_type = load_from_path(config_file)
|
|
188
|
+
assert data["KEY"] == "value"
|
|
189
|
+
assert file_type == "yaml"
|
|
190
|
+
|
|
191
|
+
def test_load_env_directly(self, tmp_path: Path) -> None:
|
|
192
|
+
env_file = tmp_path / "custom.env"
|
|
193
|
+
env_file.write_text("KEY=value\n", encoding="utf-8")
|
|
194
|
+
|
|
195
|
+
data, source, file_type = load_from_path(env_file)
|
|
196
|
+
assert data["KEY"] == "value"
|
|
197
|
+
assert file_type == "env"
|
|
198
|
+
|
|
199
|
+
def test_file_not_found(self) -> None:
|
|
200
|
+
with pytest.raises(FileNotFoundError):
|
|
201
|
+
load_from_path("/nonexistent/config.yaml")
|
|
202
|
+
|
|
203
|
+
def test_required_keys_direct_load(self, tmp_path: Path) -> None:
|
|
204
|
+
config_file = tmp_path / "config.yaml"
|
|
205
|
+
config_file.write_text("KEY: value\n", encoding="utf-8")
|
|
206
|
+
|
|
207
|
+
with pytest.raises(RequiredKeyError, match="MISSING_KEY"):
|
|
208
|
+
load_from_path(config_file, required=["KEY", "MISSING_KEY"])
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Tests for qcload parsers module."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import tempfile
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
from qcload.exceptions import ConfigParseError
|
|
12
|
+
from qcload.parsers import detect_file_type, parse_env, parse_file, parse_yaml
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class TestParseYaml:
|
|
16
|
+
"""Tests for YAML config parsing."""
|
|
17
|
+
|
|
18
|
+
def test_parse_simple_yaml(self, tmp_path: Path) -> None:
|
|
19
|
+
yaml_file = tmp_path / "config.yaml"
|
|
20
|
+
yaml_file.write_text(
|
|
21
|
+
"database:\n host: localhost\n port: 5432\nAPI_KEY: secret123\n",
|
|
22
|
+
encoding="utf-8",
|
|
23
|
+
)
|
|
24
|
+
result = parse_yaml(yaml_file)
|
|
25
|
+
assert result["database"]["host"] == "localhost"
|
|
26
|
+
assert result["database"]["port"] == 5432
|
|
27
|
+
assert result["API_KEY"] == "secret123"
|
|
28
|
+
|
|
29
|
+
def test_parse_empty_yaml(self, tmp_path: Path) -> None:
|
|
30
|
+
yaml_file = tmp_path / "config.yaml"
|
|
31
|
+
yaml_file.write_text("", encoding="utf-8")
|
|
32
|
+
result = parse_yaml(yaml_file)
|
|
33
|
+
assert result == {}
|
|
34
|
+
|
|
35
|
+
def test_parse_yaml_with_lists(self, tmp_path: Path) -> None:
|
|
36
|
+
yaml_file = tmp_path / "config.yaml"
|
|
37
|
+
yaml_file.write_text(
|
|
38
|
+
"allowed_hosts:\n - localhost\n - 127.0.0.1\n - example.com\n",
|
|
39
|
+
encoding="utf-8",
|
|
40
|
+
)
|
|
41
|
+
result = parse_yaml(yaml_file)
|
|
42
|
+
assert result["allowed_hosts"] == ["localhost", "127.0.0.1", "example.com"]
|
|
43
|
+
|
|
44
|
+
def test_parse_yaml_syntax_error(self, tmp_path: Path) -> None:
|
|
45
|
+
yaml_file = tmp_path / "config.yaml"
|
|
46
|
+
yaml_file.write_text("key: [broken: yaml: here\n", encoding="utf-8")
|
|
47
|
+
with pytest.raises(ConfigParseError):
|
|
48
|
+
parse_yaml(yaml_file)
|
|
49
|
+
|
|
50
|
+
def test_parse_yaml_non_dict_top_level(self, tmp_path: Path) -> None:
|
|
51
|
+
yaml_file = tmp_path / "config.yaml"
|
|
52
|
+
yaml_file.write_text("just_a_string\n", encoding="utf-8")
|
|
53
|
+
with pytest.raises(ConfigParseError, match="Expected dict"):
|
|
54
|
+
parse_yaml(yaml_file)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class TestParseEnv:
|
|
58
|
+
"""Tests for .env config parsing."""
|
|
59
|
+
|
|
60
|
+
def test_parse_simple_env(self, tmp_path: Path) -> None:
|
|
61
|
+
env_file = tmp_path / ".env"
|
|
62
|
+
env_file.write_text(
|
|
63
|
+
"DATABASE_URL=postgresql://localhost/mydb\nAPI_KEY=abc123\nDEBUG=true\n",
|
|
64
|
+
encoding="utf-8",
|
|
65
|
+
)
|
|
66
|
+
result = parse_env(env_file)
|
|
67
|
+
assert result["DATABASE_URL"] == "postgresql://localhost/mydb"
|
|
68
|
+
assert result["API_KEY"] == "abc123"
|
|
69
|
+
assert result["DEBUG"] == "true"
|
|
70
|
+
|
|
71
|
+
def test_parse_env_with_comments(self, tmp_path: Path) -> None:
|
|
72
|
+
env_file = tmp_path / ".env"
|
|
73
|
+
env_file.write_text(
|
|
74
|
+
"# This is a comment\nKEY=value\n# Another comment\n",
|
|
75
|
+
encoding="utf-8",
|
|
76
|
+
)
|
|
77
|
+
result = parse_env(env_file)
|
|
78
|
+
assert "KEY" in result
|
|
79
|
+
assert result["KEY"] == "value"
|
|
80
|
+
|
|
81
|
+
def test_parse_env_with_quotes(self, tmp_path: Path) -> None:
|
|
82
|
+
env_file = tmp_path / ".env"
|
|
83
|
+
env_file.write_text(
|
|
84
|
+
'QUOTED_KEY="hello world"\n', encoding="utf-8",
|
|
85
|
+
)
|
|
86
|
+
result = parse_env(env_file)
|
|
87
|
+
assert result["QUOTED_KEY"] == "hello world"
|
|
88
|
+
|
|
89
|
+
def test_parse_empty_env(self, tmp_path: Path) -> None:
|
|
90
|
+
env_file = tmp_path / ".env"
|
|
91
|
+
env_file.write_text("", encoding="utf-8")
|
|
92
|
+
result = parse_env(env_file)
|
|
93
|
+
assert result == {}
|
|
94
|
+
|
|
95
|
+
def test_parse_env_skips_none_values(self, tmp_path: Path) -> None:
|
|
96
|
+
env_file = tmp_path / ".env"
|
|
97
|
+
env_file.write_text("KEY=value\nEMPTY=\n", encoding="utf-8")
|
|
98
|
+
result = parse_env(env_file)
|
|
99
|
+
assert "KEY" in result
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class TestParseFile:
|
|
103
|
+
"""Tests for the auto-detect dispatcher."""
|
|
104
|
+
|
|
105
|
+
def test_parse_yaml_by_extension(self, tmp_path: Path) -> None:
|
|
106
|
+
yaml_file = tmp_path / "config.yaml"
|
|
107
|
+
yaml_file.write_text("key: value\n", encoding="utf-8")
|
|
108
|
+
result = parse_file(yaml_file)
|
|
109
|
+
assert result["key"] == "value"
|
|
110
|
+
|
|
111
|
+
def test_parse_yml_extension(self, tmp_path: Path) -> None:
|
|
112
|
+
yml_file = tmp_path / "config.yml"
|
|
113
|
+
yml_file.write_text("key: value\n", encoding="utf-8")
|
|
114
|
+
result = parse_file(yml_file)
|
|
115
|
+
assert result["key"] == "value"
|
|
116
|
+
|
|
117
|
+
def test_parse_env_by_extension(self, tmp_path: Path) -> None:
|
|
118
|
+
env_file = tmp_path / ".env"
|
|
119
|
+
env_file.write_text("KEY=value\n", encoding="utf-8")
|
|
120
|
+
result = parse_file(env_file)
|
|
121
|
+
assert result["KEY"] == "value"
|
|
122
|
+
|
|
123
|
+
def test_parse_unsupported_extension(self, tmp_path: Path) -> None:
|
|
124
|
+
json_file = tmp_path / "config.json"
|
|
125
|
+
json_file.write_text("{}\n", encoding="utf-8")
|
|
126
|
+
with pytest.raises(ConfigParseError, match="Unsupported"):
|
|
127
|
+
parse_file(json_file)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class TestDetectFileType:
|
|
131
|
+
"""Tests for file type detection."""
|
|
132
|
+
|
|
133
|
+
def test_yaml(self) -> None:
|
|
134
|
+
assert detect_file_type("config.yaml") == "yaml"
|
|
135
|
+
|
|
136
|
+
def test_yml(self) -> None:
|
|
137
|
+
assert detect_file_type("config.yml") == "yaml"
|
|
138
|
+
|
|
139
|
+
def test_env(self) -> None:
|
|
140
|
+
assert detect_file_type(".env") == "env"
|
|
141
|
+
|
|
142
|
+
def test_unknown(self) -> None:
|
|
143
|
+
assert detect_file_type("config.json") == "unknown"
|