qcload 0.1.0__py3-none-any.whl
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/__init__.py +153 -0
- qcload/config.py +135 -0
- qcload/exceptions.py +35 -0
- qcload/hunter.py +140 -0
- qcload/parsers.py +95 -0
- qcload-0.1.0.dist-info/METADATA +86 -0
- qcload-0.1.0.dist-info/RECORD +8 -0
- qcload-0.1.0.dist-info/WHEEL +4 -0
qcload/__init__.py
ADDED
|
@@ -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
|
qcload/config.py
ADDED
|
@@ -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()
|
qcload/exceptions.py
ADDED
|
@@ -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)
|
qcload/hunter.py
ADDED
|
@@ -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
|
qcload/parsers.py
ADDED
|
@@ -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"
|
|
@@ -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
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
qcload/__init__.py,sha256=2F_LqRmYYg_KaQBrmvUdtro3rJbUNp3kpUvzb5RGiII,4242
|
|
2
|
+
qcload/config.py,sha256=ltG-X_kQKELTfwNH9dhBI8NGeBC0PVchAFcpmpOKeC8,4408
|
|
3
|
+
qcload/exceptions.py,sha256=NAK431ieCLyUKKdLFVPjnv46m_wnhnpNoHOU20yR8Y4,1248
|
|
4
|
+
qcload/hunter.py,sha256=Iqjm7Arf2MaxmU4R2SNLkcP1WsQimPW-TkVtTZU9Ps8,4388
|
|
5
|
+
qcload/parsers.py,sha256=5lgxnmNeMFzzyh8WFun0KcEo7MALWUUtWQNfJh1oAIw,2720
|
|
6
|
+
qcload-0.1.0.dist-info/METADATA,sha256=S3B_iH_G8dGLcvYVSpoWqKsWUF0w9nl5eiDUvE2NVJU,2179
|
|
7
|
+
qcload-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
8
|
+
qcload-0.1.0.dist-info/RECORD,,
|