ferrum-cli 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.
ferrum/cli.py ADDED
@@ -0,0 +1,479 @@
1
+ """ferrum command line: inspect a directory, diagnose a task, or fix one."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import logging
7
+ import os
8
+ import shutil
9
+ import sys
10
+ from pathlib import Path
11
+
12
+ from ferrum import __version__
13
+ from ferrum.agent import Agent
14
+ from ferrum.config import (
15
+ CLOUD_PRESET_URL,
16
+ INT_FIELDS,
17
+ LOCAL_PRESET_URL,
18
+ STR_FIELDS,
19
+ Config,
20
+ ConfigError,
21
+ config_file_path,
22
+ load_config,
23
+ mask_secret,
24
+ read_config_file,
25
+ setting_source,
26
+ write_config_value,
27
+ write_config_values,
28
+ )
29
+ from ferrum.context import discover, find_project_root
30
+ from ferrum.model import (
31
+ OpenAICompatibleProvider,
32
+ ProviderError,
33
+ list_models,
34
+ probe_endpoint,
35
+ )
36
+ from ferrum.tools import default_tools
37
+ from ferrum.verifier import Verifier
38
+
39
+ PROG = "ferrum"
40
+ DESCRIPTION = "Ferrum — a coding harness for low-level and systems programming."
41
+
42
+ EXIT_OK = 0
43
+ EXIT_ERROR = 1
44
+
45
+ EPILOG = """examples:
46
+ ferrum . inspect a project
47
+ ferrum "task" diagnose with the model (read-only)
48
+ ferrum fix "task" patch, build, and verify
49
+ ferrum fix --dry-run "t" show the proposed patch, write nothing
50
+ ferrum doctor check config, endpoint, and toolchain
51
+ ferrum config show base_url / model / api_key
52
+ ferrum config local switch to Ollama on localhost
53
+ ferrum config cloud switch to the cloud endpoint
54
+ ferrum config models list models at the current endpoint
55
+ """
56
+
57
+ CONFIG_USAGE = """usage:
58
+ ferrum config show current settings
59
+ ferrum config get <key> print one setting
60
+ ferrum config set <key> <value> save one setting ('' clears it)
61
+ ferrum config models list models at the current endpoint
62
+ ferrum config local use Ollama at http://localhost:11434/v1
63
+ ferrum config cloud [url] use the cloud endpoint
64
+ keys: base_url, model, api_key, """ + ", ".join(sorted(INT_FIELDS))
65
+
66
+
67
+ def build_parser() -> argparse.ArgumentParser:
68
+ parser = argparse.ArgumentParser(
69
+ prog=PROG,
70
+ description=DESCRIPTION,
71
+ epilog=EPILOG,
72
+ formatter_class=argparse.RawDescriptionHelpFormatter,
73
+ )
74
+ parser.add_argument(
75
+ "-V",
76
+ "--version",
77
+ action="version",
78
+ version=f"{PROG} {__version__}",
79
+ )
80
+ parser.add_argument(
81
+ "-v",
82
+ "--verbose",
83
+ action="store_true",
84
+ help="enable debug logging on stderr",
85
+ )
86
+ parser.add_argument(
87
+ "--dry-run",
88
+ action="store_true",
89
+ help="with fix: show the proposed patch but never write files",
90
+ )
91
+ parser.add_argument(
92
+ "target",
93
+ nargs="?",
94
+ metavar="TARGET",
95
+ help="directory to inspect, or a task for the model",
96
+ )
97
+ parser.add_argument(
98
+ "extra",
99
+ nargs="*",
100
+ help=argparse.SUPPRESS,
101
+ )
102
+ return parser
103
+
104
+
105
+ def setup_logging(verbose: bool = False) -> None:
106
+ """Quiet by default; -v turns on debug output."""
107
+ logging.basicConfig(
108
+ level=logging.DEBUG if verbose else logging.WARNING,
109
+ format="%(levelname)s %(name)s: %(message)s",
110
+ stream=sys.stderr,
111
+ force=True,
112
+ )
113
+
114
+
115
+ def _force_utf8_streams() -> None:
116
+ # Without this, the box-drawing line in the summary dies when output is piped.
117
+ for stream in (sys.stdout, sys.stderr):
118
+ try:
119
+ stream.reconfigure(encoding="utf-8", errors="replace")
120
+ except (AttributeError, ValueError, OSError):
121
+ pass
122
+
123
+
124
+ def _inspect(target: str) -> int:
125
+ path = Path(target)
126
+ if not path.is_dir():
127
+ print(
128
+ f"error: not a directory: {target} (pass a directory to inspect)",
129
+ file=sys.stderr,
130
+ )
131
+ return EXIT_ERROR
132
+ context = discover(find_project_root(path), load_config())
133
+ print(context.summary())
134
+ return EXIT_OK
135
+
136
+
137
+ def _env_override_note(key: str) -> str | None:
138
+ var = STR_FIELDS.get(key) or INT_FIELDS.get(key)
139
+ if var and os.environ.get(var):
140
+ return f"note: {var} is set in the environment and overrides the file"
141
+ return None
142
+
143
+
144
+ def _config_show() -> int:
145
+ try:
146
+ config = load_config()
147
+ path = config_file_path()
148
+ sources = {key: setting_source(key) for key in STR_FIELDS}
149
+ except ConfigError as exc:
150
+ print(f"error: {exc}", file=sys.stderr)
151
+ return EXIT_ERROR
152
+ for key in ("base_url", "model", "api_key"):
153
+ value = getattr(config, key)
154
+ if key == "api_key":
155
+ display = mask_secret(value)
156
+ else:
157
+ display = value or "(not set)"
158
+ print(f"{key:<9} = {display} [{sources[key]}]")
159
+ suffix = "" if path.exists() else " (not created yet)"
160
+ print(f"config file: {path}{suffix}")
161
+ return EXIT_OK
162
+
163
+
164
+ def _config_get(key: str) -> int:
165
+ known = {**STR_FIELDS, **INT_FIELDS}
166
+ if key not in known:
167
+ print(
168
+ f"error: unknown setting {key!r}; choose from: "
169
+ + ", ".join(sorted(known)),
170
+ file=sys.stderr,
171
+ )
172
+ return EXIT_ERROR
173
+ try:
174
+ value = getattr(load_config(), key)
175
+ except ConfigError as exc:
176
+ print(f"error: {exc}", file=sys.stderr)
177
+ return EXIT_ERROR
178
+ if value == "" or value is None:
179
+ print(f"{key} is not set.", file=sys.stderr)
180
+ return EXIT_ERROR
181
+ print(value)
182
+ return EXIT_OK
183
+
184
+
185
+ def _config_set(key: str, value: str) -> int:
186
+ try:
187
+ path = write_config_value(key, value)
188
+ except ConfigError as exc:
189
+ print(f"error: {exc}", file=sys.stderr)
190
+ return EXIT_ERROR
191
+ if value == "":
192
+ print(f"{key} cleared ({path})")
193
+ else:
194
+ display = mask_secret(value) if key == "api_key" else value
195
+ print(f"{key} = {display} [saved to {path}]")
196
+ note = _env_override_note(key)
197
+ if note:
198
+ print(note, file=sys.stderr)
199
+ return EXIT_OK
200
+
201
+
202
+ def _config_models() -> int:
203
+ try:
204
+ config = load_config()
205
+ except ConfigError as exc:
206
+ print(f"error: {exc}", file=sys.stderr)
207
+ return EXIT_ERROR
208
+ try:
209
+ available = list_models(config.base_url, timeout=15, api_key=config.api_key)
210
+ except ProviderError as exc:
211
+ print(f"error: {exc}", file=sys.stderr)
212
+ return EXIT_ERROR
213
+ if not available:
214
+ print(
215
+ f"no models listed at {config.base_url}"
216
+ " (the server may not support GET /models,"
217
+ " or the api_key is wrong)",
218
+ file=sys.stderr,
219
+ )
220
+ return EXIT_ERROR
221
+ for name in available:
222
+ print(name)
223
+ print(f"\nset one: ferrum config set model {available[0]}", file=sys.stderr)
224
+ return EXIT_OK
225
+
226
+
227
+ def _config_preset(kind: str, rest: list[str]) -> int:
228
+ # The api_key is never touched: local servers ignore it, and clearing it
229
+ # would force re-entering the cloud key on every switch back.
230
+ if kind == "local":
231
+ url = LOCAL_PRESET_URL
232
+ else:
233
+ url = rest[0] if rest else CLOUD_PRESET_URL
234
+ try:
235
+ path = write_config_values({"base_url": url})
236
+ except ConfigError as exc:
237
+ print(f"error: {exc}", file=sys.stderr)
238
+ return EXIT_ERROR
239
+ print(f"base_url = {url} [saved to {path}]")
240
+ note = _env_override_note("base_url")
241
+ if note:
242
+ print(note, file=sys.stderr)
243
+ if kind == "cloud" and not os.environ.get("FERRUM_API_KEY") and not _file_api_key():
244
+ print(
245
+ "warning: api_key is not set —"
246
+ " ferrum config set api_key <key>",
247
+ file=sys.stderr,
248
+ )
249
+ print("pick a model: ferrum config models"
250
+ " then ferrum config set model <name>", file=sys.stderr)
251
+ return EXIT_OK
252
+
253
+
254
+ def _file_api_key() -> str:
255
+ value = read_config_file().get("api_key", "")
256
+ return value if isinstance(value, str) else ""
257
+
258
+
259
+ def _config_command(words: list[str]) -> int:
260
+ if not words:
261
+ return _config_show()
262
+ cmd = words[0]
263
+ if cmd == "get":
264
+ if len(words) != 2:
265
+ print("error: usage: ferrum config get <key>", file=sys.stderr)
266
+ return EXIT_ERROR
267
+ return _config_get(words[1])
268
+ if cmd == "set":
269
+ if len(words) < 3:
270
+ print("error: usage: ferrum config set <key> <value>", file=sys.stderr)
271
+ return EXIT_ERROR
272
+ return _config_set(words[1], " ".join(words[2:]))
273
+ if cmd == "models":
274
+ return _config_models()
275
+ if cmd in {"local", "cloud"}:
276
+ return _config_preset(cmd, words[1:])
277
+ print(f"error: unknown config command: {cmd}", file=sys.stderr)
278
+ print(CONFIG_USAGE, file=sys.stderr)
279
+ return EXIT_ERROR
280
+
281
+
282
+ def _doctor() -> int:
283
+ """One screen that answers: can ferrum run a task here, and if not, why?"""
284
+ problems: list[str] = []
285
+ warnings: list[str] = []
286
+
287
+ print(f"{'python':<12}{sys.version.split()[0]} (need >= 3.10)")
288
+ keys = ("base_url", "model", "api_key")
289
+ try:
290
+ config = load_config()
291
+ sources = {key: setting_source(key) for key in keys}
292
+ except ConfigError as exc:
293
+ print(f"{'config':<12}broken — {exc}")
294
+ problems.append("fix or delete the config file")
295
+ config = Config() # defaults, so the rest of the report still runs
296
+ sources = {key: "default" for key in keys}
297
+
298
+ path = config_file_path()
299
+ print(f"{'config file':<12}{path}{' ' if path.exists() else ' (not created yet)'}")
300
+ for key in keys:
301
+ value = getattr(config, key)
302
+ display = mask_secret(value) if key == "api_key" else (value or "(not set)")
303
+ print(f"{key:<12}{display} [{sources[key]}]")
304
+ if key == "model" and not value:
305
+ problems.append("model is not set — ferrum config set model <name>")
306
+ if not config.api_key and not config.base_url.startswith("http://localhost"):
307
+ warnings.append("cloud endpoints usually need an api_key")
308
+
309
+ models, error = probe_endpoint(config.base_url, timeout=10, api_key=config.api_key)
310
+ if error:
311
+ print(f"{'endpoint':<12}unreachable — {error}")
312
+ problems.append(f"cannot reach {config.base_url} (is the server running?)")
313
+ elif models:
314
+ print(f"{'endpoint':<12}ok — {len(models)} models listed")
315
+ else:
316
+ print(f"{'endpoint':<12}answered, but listed no models")
317
+ warnings.append("the server listed no models; chat may still work")
318
+
319
+ found = [tool for tool in ("clang", "gcc", "make", "cargo", "cmake")
320
+ if shutil.which(tool)]
321
+ print(f"{'toolchain':<12}{', '.join(found) if found else 'nothing found'}")
322
+ if not {"clang", "gcc"} & set(found):
323
+ warnings.append("no C compiler — fix mode cannot verify C builds")
324
+ if not found:
325
+ warnings.append("nothing to verify builds with; run commands are unavailable")
326
+
327
+ print()
328
+ for line in problems:
329
+ print(f"problem: {line}")
330
+ for line in warnings:
331
+ print(f"warning: {line}")
332
+ if problems:
333
+ print("verdict: not ready")
334
+ return EXIT_ERROR
335
+ print("verdict: ready")
336
+ return EXIT_OK
337
+
338
+
339
+ def _confirm(prompt: str) -> bool:
340
+ try:
341
+ answer = input(prompt)
342
+ except (EOFError, KeyboardInterrupt):
343
+ print()
344
+ return False
345
+ return answer.strip().lower() in {"y", "yes"}
346
+
347
+
348
+ def _agent_task(task: str, *, edit: bool, dry_run: bool = False) -> int:
349
+ try:
350
+ config = load_config()
351
+ except ConfigError as exc:
352
+ print(f"error: {exc}", file=sys.stderr)
353
+ return EXIT_ERROR
354
+ missing = config.missing()
355
+ if missing:
356
+ print(f"error: {', '.join(missing)} is not set.", file=sys.stderr)
357
+ print(
358
+ f" point it at a model served by {config.base_url}:",
359
+ file=sys.stderr,
360
+ )
361
+ print(" ferrum config models", file=sys.stderr)
362
+ print(" ferrum config set model <name>", file=sys.stderr)
363
+ print(
364
+ f" (or export {', '.join(missing)}=... for this shell)",
365
+ file=sys.stderr,
366
+ )
367
+ try:
368
+ available = list_models(
369
+ config.base_url, timeout=15, api_key=config.api_key
370
+ )
371
+ except ProviderError:
372
+ available = []
373
+ if available:
374
+ print(f" available: {', '.join(available)}", file=sys.stderr)
375
+ else:
376
+ print(f" ferrum cannot see any model at {config.base_url}", file=sys.stderr)
377
+ return EXIT_ERROR
378
+
379
+ root = find_project_root(Path.cwd())
380
+ context = discover(root, config)
381
+ provider = OpenAICompatibleProvider(
382
+ config.base_url,
383
+ config.model,
384
+ config.api_key,
385
+ config.request_timeout,
386
+ config.max_tokens,
387
+ )
388
+ agent = Agent(
389
+ config,
390
+ provider,
391
+ default_tools(root, config),
392
+ printer=print,
393
+ verifier=Verifier(root, timeout=config.command_timeout),
394
+ )
395
+ try:
396
+ result = agent.run(task, context, edit=edit, confirm=_confirm, dry_run=dry_run)
397
+ except ProviderError as exc:
398
+ print(f"error: {exc}", file=sys.stderr)
399
+ return EXIT_ERROR
400
+
401
+ if result.text:
402
+ print()
403
+ print(result.text)
404
+ if result.patched:
405
+ print()
406
+ if result.verified:
407
+ print("✓ Fix verified")
408
+ elif result.verified is False:
409
+ print("✗ Fix not verified — build or tests did not pass")
410
+ if dry_run:
411
+ print()
412
+ print("(dry run — no files were changed)")
413
+ if result.capped:
414
+ return EXIT_ERROR
415
+ if result.patched and result.verified is False:
416
+ return EXIT_ERROR
417
+ return EXIT_OK
418
+
419
+
420
+ def main(argv: list[str] | None = None) -> int:
421
+ # Plain white text, always: Python 3.14+ colours argparse help and
422
+ # tracebacks on a terminal otherwise.
423
+ os.environ["PYTHON_COLORS"] = "0"
424
+ _force_utf8_streams()
425
+ args_list = list(sys.argv[1:] if argv is None else argv)
426
+
427
+ parser = build_parser()
428
+ args = parser.parse_args(args_list)
429
+ setup_logging(args.verbose)
430
+
431
+ if args.target is None:
432
+ if args.dry_run:
433
+ print(
434
+ 'error: usage: ferrum fix --dry-run "<task>"',
435
+ file=sys.stderr,
436
+ )
437
+ return EXIT_ERROR
438
+ parser.print_help()
439
+ return EXIT_OK
440
+
441
+ if args.target == "fix":
442
+ task = " ".join(args.extra).strip()
443
+ if not task:
444
+ print('error: usage: ferrum fix "<task>"', file=sys.stderr)
445
+ return EXIT_ERROR
446
+ return _agent_task(task, edit=True, dry_run=args.dry_run)
447
+
448
+ if args.dry_run:
449
+ print(
450
+ 'error: --dry-run only applies to: ferrum fix --dry-run "<task>"',
451
+ file=sys.stderr,
452
+ )
453
+ return EXIT_ERROR
454
+
455
+ if args.target == "config":
456
+ return _config_command(args.extra)
457
+
458
+ if args.target == "doctor":
459
+ if args.extra:
460
+ print("error: usage: ferrum doctor", file=sys.stderr)
461
+ return EXIT_ERROR
462
+ return _doctor()
463
+
464
+ target = args.target
465
+ path = Path(target)
466
+ if path.is_dir():
467
+ return _inspect(target)
468
+ if path.exists():
469
+ return _inspect(target) # prints the not-a-directory error
470
+ if "/" in target or "\\" in target or target.startswith("."):
471
+ print(f"error: no such directory: {target}", file=sys.stderr)
472
+ return EXIT_ERROR
473
+
474
+ task = " ".join([target, *args.extra])
475
+ return _agent_task(task, edit=False)
476
+
477
+
478
+ if __name__ == "__main__":
479
+ sys.exit(main())
ferrum/config.py ADDED
@@ -0,0 +1,178 @@
1
+ """Harness settings: model endpoint plus the limits that keep Ferrum small.
2
+
3
+ Settings resolve in layers: environment variable > config file > default.
4
+ The file lives outside the project (Windows: %APPDATA%\\ferrum\\config.json,
5
+ else $XDG_CONFIG_HOME/ferrum/config.json) so switching between a local
6
+ Ollama server and a cloud endpoint is one ``ferrum config`` command away.
7
+ Local models need no API key; every secret is optional.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import os
14
+ import sys
15
+ from dataclasses import dataclass
16
+ from pathlib import Path
17
+
18
+ LOCAL_PRESET_URL = "http://localhost:11434/v1"
19
+ CLOUD_PRESET_URL = "https://api.routeway.ai/v1"
20
+
21
+
22
+ class ConfigError(Exception):
23
+ pass
24
+
25
+
26
+ INT_FIELDS = {
27
+ "max_file_bytes": "FERRUM_MAX_FILE_BYTES",
28
+ "max_context_bytes": "FERRUM_MAX_CONTEXT_BYTES",
29
+ "max_files": "FERRUM_MAX_FILES",
30
+ "max_search_results": "FERRUM_MAX_SEARCH_RESULTS",
31
+ "max_iterations": "FERRUM_MAX_ITERATIONS",
32
+ "max_tokens": "FERRUM_MAX_TOKENS",
33
+ "request_timeout": "FERRUM_TIMEOUT",
34
+ "command_timeout": "FERRUM_COMMAND_TIMEOUT",
35
+ }
36
+ STR_FIELDS = {
37
+ "base_url": "FERRUM_BASE_URL",
38
+ "model": "FERRUM_MODEL",
39
+ "api_key": "FERRUM_API_KEY",
40
+ }
41
+
42
+
43
+ def config_file_path() -> Path:
44
+ if sys.platform == "win32":
45
+ base = os.environ.get("APPDATA")
46
+ root = Path(base) if base else Path.home() / ".config"
47
+ else:
48
+ root = Path(os.environ.get("XDG_CONFIG_HOME")) if os.environ.get(
49
+ "XDG_CONFIG_HOME"
50
+ ) else Path.home() / ".config"
51
+ return root / "ferrum" / "config.json"
52
+
53
+
54
+ def read_config_file() -> dict[str, object]:
55
+ path = config_file_path()
56
+ if not path.exists():
57
+ return {}
58
+ try:
59
+ data = json.loads(path.read_text(encoding="utf-8"))
60
+ except (OSError, ValueError) as exc:
61
+ raise ConfigError(f"cannot read {path}: {exc}") from exc
62
+ if not isinstance(data, dict):
63
+ raise ConfigError(f"{path} must contain a JSON object")
64
+ return data
65
+
66
+
67
+ def write_config_values(values: dict[str, str]) -> Path:
68
+ """Merge settings into the config file; an empty value removes the key."""
69
+ known = {**STR_FIELDS, **INT_FIELDS}
70
+ for key in values:
71
+ if key not in known:
72
+ raise ConfigError(
73
+ f"unknown setting {key!r}; choose from: "
74
+ + ", ".join(sorted(known))
75
+ )
76
+ path = config_file_path()
77
+ data = read_config_file()
78
+ for key, value in values.items():
79
+ if value == "":
80
+ data.pop(key, None)
81
+ elif key in INT_FIELDS:
82
+ try:
83
+ data[key] = int(value)
84
+ except ValueError as exc:
85
+ raise ConfigError(
86
+ f"{key} must be an integer, got {value!r}"
87
+ ) from exc
88
+ else:
89
+ data[key] = value
90
+ path.parent.mkdir(parents=True, exist_ok=True)
91
+ path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
92
+ return path
93
+
94
+
95
+ def write_config_value(key: str, value: str) -> Path:
96
+ return write_config_values({key: value})
97
+
98
+
99
+ def mask_secret(value: str) -> str:
100
+ if not value:
101
+ return "(not set)"
102
+ if len(value) <= 8:
103
+ return "*" * len(value)
104
+ return value[:6] + "..." + value[-4:]
105
+
106
+
107
+ @dataclass(frozen=True)
108
+ class Config:
109
+ max_file_bytes: int = 256 * 1024 # one file
110
+ max_context_bytes: int = 400_000 # whole listing
111
+ max_files: int = 500
112
+ max_search_results: int = 200
113
+ max_iterations: int = 25 # agent loop cap
114
+ max_tokens: int = 2048 # per model response
115
+ request_timeout: int = 120 # per model request (CPU-served models are slow)
116
+ command_timeout: int = 300 # per build/test command
117
+
118
+ base_url: str = LOCAL_PRESET_URL
119
+ model: str = ""
120
+ api_key: str = ""
121
+
122
+ def __post_init__(self) -> None:
123
+ for name in INT_FIELDS:
124
+ value = getattr(self, name)
125
+ if not isinstance(value, int) or isinstance(value, bool) or value <= 0:
126
+ raise ConfigError(f"{name} must be a positive integer, got {value!r}")
127
+ if not isinstance(self.base_url, str) or not self.base_url.strip():
128
+ raise ConfigError(f"base_url must be a non-empty string, got {self.base_url!r}")
129
+
130
+ def missing(self) -> list[str]:
131
+ """Env/file keys that must be set before the model can be used."""
132
+ missing = []
133
+ if not self.model:
134
+ missing.append(STR_FIELDS["model"])
135
+ return missing
136
+
137
+
138
+ def setting_source(name: str) -> str:
139
+ """Where a setting's effective value comes from: env, file, or default."""
140
+ var = STR_FIELDS.get(name) or INT_FIELDS.get(name)
141
+ if var and os.environ.get(var):
142
+ return f"env {var}"
143
+ if name in read_config_file():
144
+ return "file"
145
+ return "default"
146
+
147
+
148
+ def load_config(**overrides: object) -> Config:
149
+ values: dict[str, object] = {}
150
+ path = config_file_path()
151
+ for name, raw in read_config_file().items():
152
+ if name in STR_FIELDS:
153
+ if not isinstance(raw, str):
154
+ raise ConfigError(f"{path}: {name} must be a string")
155
+ if raw:
156
+ values[name] = raw
157
+ elif name in INT_FIELDS:
158
+ if not isinstance(raw, int) or isinstance(raw, bool):
159
+ raise ConfigError(f"{path}: {name} must be an integer")
160
+ values[name] = raw
161
+ else:
162
+ raise ConfigError(f"{path}: unknown setting {name!r}")
163
+ for name, var in STR_FIELDS.items():
164
+ raw = os.environ.get(var)
165
+ if raw:
166
+ values[name] = raw
167
+ for name, var in INT_FIELDS.items():
168
+ raw = os.environ.get(var)
169
+ if raw:
170
+ try:
171
+ values[name] = int(raw)
172
+ except ValueError as exc:
173
+ raise ConfigError(f"{var} must be an integer, got {raw!r}") from exc
174
+ values.update(overrides)
175
+ try:
176
+ return Config(**values)
177
+ except TypeError as exc:
178
+ raise ConfigError(str(exc)) from exc