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 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"