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 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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any