llm-conductor 1.0.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.
Files changed (41) hide show
  1. llm_conductor-1.0.0/LICENSE +21 -0
  2. llm_conductor-1.0.0/PKG-INFO +176 -0
  3. llm_conductor-1.0.0/README.md +135 -0
  4. llm_conductor-1.0.0/llm_conductor/__init__.py +4 -0
  5. llm_conductor-1.0.0/llm_conductor/__main__.py +7 -0
  6. llm_conductor-1.0.0/llm_conductor/cli/__init__.py +0 -0
  7. llm_conductor-1.0.0/llm_conductor/cli/exit_status.py +84 -0
  8. llm_conductor-1.0.0/llm_conductor/cli/glossary.py +94 -0
  9. llm_conductor-1.0.0/llm_conductor/cli/heartbeat.py +298 -0
  10. llm_conductor-1.0.0/llm_conductor/cli/init.py +156 -0
  11. llm_conductor-1.0.0/llm_conductor/cli/locations.py +80 -0
  12. llm_conductor-1.0.0/llm_conductor/cli/main.py +227 -0
  13. llm_conductor-1.0.0/llm_conductor/cli/runner.py +518 -0
  14. llm_conductor-1.0.0/llm_conductor/cli/targets.py +350 -0
  15. llm_conductor-1.0.0/llm_conductor/cli/validators.py +283 -0
  16. llm_conductor-1.0.0/llm_conductor/config.py +1395 -0
  17. llm_conductor-1.0.0/llm_conductor/constants.py +402 -0
  18. llm_conductor-1.0.0/llm_conductor/core/__init__.py +0 -0
  19. llm_conductor-1.0.0/llm_conductor/core/api_client.py +811 -0
  20. llm_conductor-1.0.0/llm_conductor/core/cache.py +924 -0
  21. llm_conductor-1.0.0/llm_conductor/core/circuit_breaker.py +64 -0
  22. llm_conductor-1.0.0/llm_conductor/core/io.py +484 -0
  23. llm_conductor-1.0.0/llm_conductor/core/locking.py +187 -0
  24. llm_conductor-1.0.0/llm_conductor/core/metrics.py +158 -0
  25. llm_conductor-1.0.0/llm_conductor/core/shutdown.py +159 -0
  26. llm_conductor-1.0.0/llm_conductor/exceptions.py +83 -0
  27. llm_conductor-1.0.0/llm_conductor/interfaces.py +357 -0
  28. llm_conductor-1.0.0/llm_conductor/l10n/__init__.py +0 -0
  29. llm_conductor-1.0.0/llm_conductor/l10n/context.py +771 -0
  30. llm_conductor-1.0.0/llm_conductor/l10n/processor.py +1111 -0
  31. llm_conductor-1.0.0/llm_conductor/l10n/validation.py +188 -0
  32. llm_conductor-1.0.0/llm_conductor/schema.py +563 -0
  33. llm_conductor-1.0.0/llm_conductor/utils.py +317 -0
  34. llm_conductor-1.0.0/llm_conductor.egg-info/PKG-INFO +176 -0
  35. llm_conductor-1.0.0/llm_conductor.egg-info/SOURCES.txt +39 -0
  36. llm_conductor-1.0.0/llm_conductor.egg-info/dependency_links.txt +1 -0
  37. llm_conductor-1.0.0/llm_conductor.egg-info/entry_points.txt +2 -0
  38. llm_conductor-1.0.0/llm_conductor.egg-info/requires.txt +13 -0
  39. llm_conductor-1.0.0/llm_conductor.egg-info/top_level.txt +1 -0
  40. llm_conductor-1.0.0/pyproject.toml +71 -0
  41. llm_conductor-1.0.0/setup.cfg +4 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Necrofox
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,176 @@
1
+ Metadata-Version: 2.4
2
+ Name: llm-conductor
3
+ Version: 1.0.0
4
+ Summary: CLI pipeline for localizing nested JSON with Ollama or OpenAI-compatible LLMs
5
+ Author: Necrofox
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/Necrofox/llm-conductor
8
+ Project-URL: Repository, https://github.com/Necrofox/llm-conductor
9
+ Project-URL: Issues, https://github.com/Necrofox/llm-conductor/issues
10
+ Project-URL: Changelog, https://github.com/Necrofox/llm-conductor/blob/main/CHANGELOG.md
11
+ Keywords: localization,i18n,l10n,llm,ollama,openai,json,translation-pipeline
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Topic :: Software Development :: Internationalization
22
+ Classifier: Topic :: Software Development :: Localization
23
+ Classifier: Topic :: Text Processing :: Linguistic
24
+ Classifier: Typing :: Typed
25
+ Requires-Python: >=3.11
26
+ Description-Content-Type: text/markdown
27
+ License-File: LICENSE
28
+ Requires-Dist: requests<3.0.0,>=2.31.0
29
+ Provides-Extra: dev
30
+ Requires-Dist: pyinstaller>=6.0.0; extra == "dev"
31
+ Requires-Dist: pytest>=8.0.0; extra == "dev"
32
+ Requires-Dist: pytest-cov>=5.0.0; extra == "dev"
33
+ Requires-Dist: pytest-mock>=3.14.0; extra == "dev"
34
+ Requires-Dist: responses>=0.25.0; extra == "dev"
35
+ Requires-Dist: hypothesis>=6.100.0; extra == "dev"
36
+ Requires-Dist: mypy>=1.10.0; extra == "dev"
37
+ Requires-Dist: types-requests>=2.31.0; extra == "dev"
38
+ Requires-Dist: ruff>=0.5.0; extra == "dev"
39
+ Requires-Dist: build>=1.2.0; extra == "dev"
40
+ Dynamic: license-file
41
+
42
+ # LLM-Conductor
43
+
44
+ **CLI pipeline** for localizing nested JSON string resources with language
45
+ models (Ollama or any OpenAI-compatible API). Optional second stage
46
+ proofreads with a separate endpoint.
47
+
48
+ This is **not** a local machine-translation product, offline dictionary, or
49
+ GUI translator. There is no built-in MT engine — you supply the model and the
50
+ prompts; quality follows them. Think “batch orchestration for LLM
51
+ localization,” not “install and translate offline.”
52
+
53
+ **Language pair = prompt only.** The engine does not hard-code source or
54
+ target languages. Name them in `llm.toml`. The default `init` profile is one
55
+ working example (often English→another language), not a product limit.
56
+
57
+ ## Features
58
+
59
+ - Recursive JSON of any depth; non-strings left untouched
60
+ - Per-string isolation (no batching unrelated keys into one prompt)
61
+ - Key-name linguistic hints (`_male` / `_female` / `_plural` / …)
62
+ - Glossary injection, formatting-tag masking, disk cache + in-run dedup
63
+ - Reply validation, consecutive-failure breaker, OS locks, progress heartbeat
64
+ - Independent transports for translation vs verification
65
+
66
+ ## Quick start
67
+
68
+ ### 1. Install or grab a binary
69
+
70
+ ```bash
71
+ pip install -e ".[dev]" # from a clone
72
+ # or download llm-conductor.exe from GitHub Releases (when published)
73
+ ```
74
+
75
+ PyPI upload is prepared (packaging metadata is in place) but may not be live
76
+ yet — prefer a Release `.exe`, `pip install` from a clone, or
77
+ `pip install git+https://github.com/Necrofox/llm-conductor.git` once the
78
+ repo is public.
79
+
80
+ ### 2. Configs next to the program
81
+
82
+ ```bash
83
+ llm-conductor init # conductor.toml + llm.toml
84
+ # portable checkout:
85
+ python llm-conductor.py init
86
+ ```
87
+
88
+ Configs resolve **beside the entry point** (exe / wrapper / installed shim),
89
+ not cwd. Edit `llm.toml` for your endpoint and prompts; set
90
+ `[validation].allowed_scripts` for the target writing system (e.g.
91
+ `["cyrillic"]`, `["latin"]`, `["cjk"]`). Prefer `key_env` over literal keys.
92
+
93
+ ### 3. Run
94
+
95
+ ```bash
96
+ llm-conductor run --target path/to/strings.json
97
+ llm-conductor run --target path/to/strings.json --stage translate --strict
98
+ llm-conductor run --target project/ui.list --result2target
99
+ ```
100
+
101
+ Results land under `result/<target-name>/` (`translated/`, `redacted/`,
102
+ `final/`) unless `--result2target`. See [doc/cli.md](doc/cli.md).
103
+
104
+ ### Ollama vs OpenAI-compatible
105
+
106
+ | | Ollama | OpenAI-compatible |
107
+ |---|---|---|
108
+ | `type` | `ollama` | `openai` |
109
+ | URL | `…/api/chat` | `…/v1/chat/completions` |
110
+ | Key | ignored | `key_env` / `key` required |
111
+
112
+ Point translation and verification at different hosts if you want.
113
+
114
+ ## Example
115
+
116
+ Input (see [examples/sample.json](examples/sample.json)):
117
+
118
+ ```json
119
+ {
120
+ "npc": {
121
+ "greeting_male": "Welcome, traveler.",
122
+ "greeting_female": "Welcome, traveler."
123
+ }
124
+ }
125
+ ```
126
+
127
+ Same English text, different key suffixes → separate requests and (usually)
128
+ different grammatical forms in the target language. Full prompt rules:
129
+ [doc/prompts.md](doc/prompts.md).
130
+
131
+ ## Key-name conventions
132
+
133
+ | Pattern | Hint |
134
+ |---|---|
135
+ | `*_male`, `*_female`, `*_neutral` | gender |
136
+ | `*_plural`, `*_singular`, `*_few`, `*_zero`, `count_*` | number |
137
+ | `*_formal`, `*_informal` | formality |
138
+
139
+ ## Documentation
140
+
141
+ | Doc | Topic |
142
+ |---|---|
143
+ | [doc/concepts.md](doc/concepts.md) | What it is / is not; isolation; language=prompt |
144
+ | [doc/configuration.md](doc/configuration.md) | TOML split, paths, secrets |
145
+ | [doc/cli.md](doc/cli.md) | Commands, results, exit codes |
146
+ | [doc/prompts.md](doc/prompts.md) | Authoring prompts |
147
+ | [doc/pipeline.md](doc/pipeline.md) | Cache, validation, resilience |
148
+ | [CONTRIBUTING.md](CONTRIBUTING.md) | Dev setup and conventions |
149
+ | [AGENTS.md](AGENTS.md) | Notes for AI coding agents |
150
+
151
+ ## Cache note
152
+
153
+ Rejected model replies are not cached. A warm-cache rerun that only hits
154
+ residual misses can exit with code `5` even if an earlier full run was `0`.
155
+ Fix prompts/model or remeasure with `--no-cache` ([doc/cli.md](doc/cli.md)).
156
+
157
+ ## Roadmap (high level)
158
+
159
+ - Richer input formats (e.g. subtitle adapters)
160
+ - Optional in-stage parallelism with single-flight on the full cache key
161
+ - Stage-local resume orthogonal to the LLM cache (concept)
162
+
163
+ ## License
164
+
165
+ MIT — see [LICENSE](LICENSE).
166
+
167
+ ---
168
+
169
+ ## Кратко (RU)
170
+
171
+ **LLM-Conductor** — CLI-конвейер: гоняет строковые листья JSON через LLM
172
+ (Ollama / OpenAI-совместимые API), опционально второй моделью вычитывает
173
+ перевод. Это **не** локальный переводчик и не движок MT — модель и промпты
174
+ ваши. Пара языков задаётся только текстом промпта в `llm.toml`, не схемой
175
+ движка. Быстрый старт: `init` → правки профиля → `run --target …`. Документация
176
+ на английском в `doc/`.
@@ -0,0 +1,135 @@
1
+ # LLM-Conductor
2
+
3
+ **CLI pipeline** for localizing nested JSON string resources with language
4
+ models (Ollama or any OpenAI-compatible API). Optional second stage
5
+ proofreads with a separate endpoint.
6
+
7
+ This is **not** a local machine-translation product, offline dictionary, or
8
+ GUI translator. There is no built-in MT engine — you supply the model and the
9
+ prompts; quality follows them. Think “batch orchestration for LLM
10
+ localization,” not “install and translate offline.”
11
+
12
+ **Language pair = prompt only.** The engine does not hard-code source or
13
+ target languages. Name them in `llm.toml`. The default `init` profile is one
14
+ working example (often English→another language), not a product limit.
15
+
16
+ ## Features
17
+
18
+ - Recursive JSON of any depth; non-strings left untouched
19
+ - Per-string isolation (no batching unrelated keys into one prompt)
20
+ - Key-name linguistic hints (`_male` / `_female` / `_plural` / …)
21
+ - Glossary injection, formatting-tag masking, disk cache + in-run dedup
22
+ - Reply validation, consecutive-failure breaker, OS locks, progress heartbeat
23
+ - Independent transports for translation vs verification
24
+
25
+ ## Quick start
26
+
27
+ ### 1. Install or grab a binary
28
+
29
+ ```bash
30
+ pip install -e ".[dev]" # from a clone
31
+ # or download llm-conductor.exe from GitHub Releases (when published)
32
+ ```
33
+
34
+ PyPI upload is prepared (packaging metadata is in place) but may not be live
35
+ yet — prefer a Release `.exe`, `pip install` from a clone, or
36
+ `pip install git+https://github.com/Necrofox/llm-conductor.git` once the
37
+ repo is public.
38
+
39
+ ### 2. Configs next to the program
40
+
41
+ ```bash
42
+ llm-conductor init # conductor.toml + llm.toml
43
+ # portable checkout:
44
+ python llm-conductor.py init
45
+ ```
46
+
47
+ Configs resolve **beside the entry point** (exe / wrapper / installed shim),
48
+ not cwd. Edit `llm.toml` for your endpoint and prompts; set
49
+ `[validation].allowed_scripts` for the target writing system (e.g.
50
+ `["cyrillic"]`, `["latin"]`, `["cjk"]`). Prefer `key_env` over literal keys.
51
+
52
+ ### 3. Run
53
+
54
+ ```bash
55
+ llm-conductor run --target path/to/strings.json
56
+ llm-conductor run --target path/to/strings.json --stage translate --strict
57
+ llm-conductor run --target project/ui.list --result2target
58
+ ```
59
+
60
+ Results land under `result/<target-name>/` (`translated/`, `redacted/`,
61
+ `final/`) unless `--result2target`. See [doc/cli.md](doc/cli.md).
62
+
63
+ ### Ollama vs OpenAI-compatible
64
+
65
+ | | Ollama | OpenAI-compatible |
66
+ |---|---|---|
67
+ | `type` | `ollama` | `openai` |
68
+ | URL | `…/api/chat` | `…/v1/chat/completions` |
69
+ | Key | ignored | `key_env` / `key` required |
70
+
71
+ Point translation and verification at different hosts if you want.
72
+
73
+ ## Example
74
+
75
+ Input (see [examples/sample.json](examples/sample.json)):
76
+
77
+ ```json
78
+ {
79
+ "npc": {
80
+ "greeting_male": "Welcome, traveler.",
81
+ "greeting_female": "Welcome, traveler."
82
+ }
83
+ }
84
+ ```
85
+
86
+ Same English text, different key suffixes → separate requests and (usually)
87
+ different grammatical forms in the target language. Full prompt rules:
88
+ [doc/prompts.md](doc/prompts.md).
89
+
90
+ ## Key-name conventions
91
+
92
+ | Pattern | Hint |
93
+ |---|---|
94
+ | `*_male`, `*_female`, `*_neutral` | gender |
95
+ | `*_plural`, `*_singular`, `*_few`, `*_zero`, `count_*` | number |
96
+ | `*_formal`, `*_informal` | formality |
97
+
98
+ ## Documentation
99
+
100
+ | Doc | Topic |
101
+ |---|---|
102
+ | [doc/concepts.md](doc/concepts.md) | What it is / is not; isolation; language=prompt |
103
+ | [doc/configuration.md](doc/configuration.md) | TOML split, paths, secrets |
104
+ | [doc/cli.md](doc/cli.md) | Commands, results, exit codes |
105
+ | [doc/prompts.md](doc/prompts.md) | Authoring prompts |
106
+ | [doc/pipeline.md](doc/pipeline.md) | Cache, validation, resilience |
107
+ | [CONTRIBUTING.md](CONTRIBUTING.md) | Dev setup and conventions |
108
+ | [AGENTS.md](AGENTS.md) | Notes for AI coding agents |
109
+
110
+ ## Cache note
111
+
112
+ Rejected model replies are not cached. A warm-cache rerun that only hits
113
+ residual misses can exit with code `5` even if an earlier full run was `0`.
114
+ Fix prompts/model or remeasure with `--no-cache` ([doc/cli.md](doc/cli.md)).
115
+
116
+ ## Roadmap (high level)
117
+
118
+ - Richer input formats (e.g. subtitle adapters)
119
+ - Optional in-stage parallelism with single-flight on the full cache key
120
+ - Stage-local resume orthogonal to the LLM cache (concept)
121
+
122
+ ## License
123
+
124
+ MIT — see [LICENSE](LICENSE).
125
+
126
+ ---
127
+
128
+ ## Кратко (RU)
129
+
130
+ **LLM-Conductor** — CLI-конвейер: гоняет строковые листья JSON через LLM
131
+ (Ollama / OpenAI-совместимые API), опционально второй моделью вычитывает
132
+ перевод. Это **не** локальный переводчик и не движок MT — модель и промпты
133
+ ваши. Пара языков задаётся только текстом промпта в `llm.toml`, не схемой
134
+ движка. Быстрый старт: `init` → правки профиля → `run --target …`. Документация
135
+ на английском в `doc/`.
@@ -0,0 +1,4 @@
1
+ # -*- coding: utf-8 -*-
2
+ """LLM-Conductor: JSON localization with language models."""
3
+
4
+ __version__ = "1.0.0"
@@ -0,0 +1,7 @@
1
+ # -*- coding: utf-8 -*-
2
+ """Allow ``python -m llm_conductor``."""
3
+
4
+ from llm_conductor.cli.main import main
5
+
6
+ if __name__ == "__main__":
7
+ main()
File without changes
@@ -0,0 +1,84 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ Exit code of a run that may hit several problems at once.
4
+
5
+ Every problem is recorded with its code; the most severe one by
6
+ EXIT_CODE_PRIORITY becomes the process exit code.
7
+ """
8
+ import threading
9
+ from typing import Iterable, List, NamedTuple
10
+
11
+ from llm_conductor.constants import EXIT_CODE_PRIORITY, EXIT_SUCCESS
12
+
13
+
14
+ def _checked(codes: Iterable[int]) -> List[int]:
15
+ """Reject codes outside the contract - an unknown code has no defined severity."""
16
+ checked = list(codes)
17
+ unknown = [code for code in checked if code not in EXIT_CODE_PRIORITY]
18
+ if unknown:
19
+ raise ValueError(f"exit codes {unknown} are not part of EXIT_CODE_PRIORITY")
20
+ return checked
21
+
22
+
23
+ def most_severe(codes: Iterable[int]) -> int:
24
+ """
25
+ The code that wins by EXIT_CODE_PRIORITY.
26
+
27
+ Args:
28
+ codes: Exit codes; each must be listed in EXIT_CODE_PRIORITY.
29
+
30
+ Returns:
31
+ The most severe code, EXIT_SUCCESS for no codes.
32
+
33
+ Raises:
34
+ ValueError: On a code missing from EXIT_CODE_PRIORITY.
35
+ """
36
+ ranks = [EXIT_CODE_PRIORITY.index(code) for code in _checked(codes)]
37
+ return EXIT_CODE_PRIORITY[min(ranks)] if ranks else EXIT_SUCCESS
38
+
39
+
40
+ class ExitRecord(NamedTuple):
41
+ """One recorded problem of a run."""
42
+
43
+ code: int
44
+ reason: str
45
+
46
+
47
+ class ExitStatus:
48
+ """
49
+ Thread-safe accumulator of the problems of one run.
50
+
51
+ record() may be called from any thread; code and records are consistent
52
+ snapshots taken under the same lock.
53
+ """
54
+
55
+ def __init__(self) -> None:
56
+ self._lock = threading.Lock()
57
+ self._records: List[ExitRecord] = []
58
+
59
+ def record(self, code: int, reason: str) -> None:
60
+ """
61
+ Record a problem.
62
+
63
+ Args:
64
+ code: Its exit code from EXIT_CODE_PRIORITY.
65
+ reason: Human-readable description for the final log line.
66
+
67
+ Raises:
68
+ ValueError: On a code missing from EXIT_CODE_PRIORITY.
69
+ """
70
+ _checked([code])
71
+ with self._lock:
72
+ self._records.append(ExitRecord(code, reason))
73
+
74
+ @property
75
+ def code(self) -> int:
76
+ """The exit code of the run: the most severe recorded one, EXIT_SUCCESS for none."""
77
+ with self._lock:
78
+ return most_severe(record.code for record in self._records)
79
+
80
+ @property
81
+ def records(self) -> List[ExitRecord]:
82
+ """A copy of the recorded problems, in recording order."""
83
+ with self._lock:
84
+ return list(self._records)
@@ -0,0 +1,94 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ Where the glossary of a run comes from.
4
+
5
+ The lookup chain has two levels: --glossary PATH (a missing file is an
6
+ error), then glossary.json next to the target (file or manifest), then no
7
+ glossary at all. A glossary is project data, so there is no shared fallback
8
+ next to the executable - it would leak one project's terms into another.
9
+ """
10
+ import os
11
+ from dataclasses import dataclass, field
12
+ from typing import Dict, Optional
13
+
14
+ from llm_conductor.cli.targets import RunTarget
15
+ from llm_conductor.constants import (
16
+ GLOSSARY_FILENAME,
17
+ GLOSSARY_ORIGIN_OPTION,
18
+ GLOSSARY_ORIGIN_TARGET,
19
+ )
20
+ from llm_conductor.core.io import safe_read_file
21
+ from llm_conductor.exceptions import FileOperationError, TargetError
22
+ from llm_conductor.interfaces import FileOperationsProtocol, Logger
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class GlossarySource:
27
+ """The glossary of a run and where it was found."""
28
+
29
+ terms: Dict[str, str] = field(default_factory=dict)
30
+ path: Optional[str] = None
31
+ origin: Optional[str] = None
32
+
33
+ def describe(self) -> str:
34
+ """One log line naming the source, its level of the chain and the number of terms."""
35
+ if self.path is None:
36
+ return "Glossary: none given and no glossary.json next to the target - working without terms"
37
+ return f"Glossary: {self.path} ({self.origin}), {len(self.terms)} term(s)"
38
+
39
+
40
+ class GlossaryResolver:
41
+ """Walks the lookup chain and loads the glossary it finds."""
42
+
43
+ def __init__(self, file_ops: FileOperationsProtocol, logger: Logger):
44
+ """
45
+ Args:
46
+ file_ops: File operations for reading the glossary.
47
+ logger: Logger of the run.
48
+ """
49
+ self._file_ops = file_ops
50
+ self._logger = logger
51
+
52
+ def resolve(self, explicit_path: Optional[str], target: RunTarget) -> GlossarySource:
53
+ """
54
+ Find and load the glossary of a run.
55
+
56
+ Args:
57
+ explicit_path: Value of --glossary, relative to the working directory.
58
+ target: The run target; its directory holds the implicit glossary.json.
59
+
60
+ Returns:
61
+ The loaded glossary, or an empty one when the chain finds nothing.
62
+
63
+ Raises:
64
+ TargetError: If the explicit glossary is missing, or a found
65
+ glossary is unreadable or not an object of strings.
66
+ """
67
+ if explicit_path:
68
+ path = os.path.abspath(explicit_path)
69
+ if not os.path.isfile(path):
70
+ raise TargetError(f"Glossary {path} not found")
71
+ return self._load(path, GLOSSARY_ORIGIN_OPTION)
72
+
73
+ candidate = os.path.join(target.directory, GLOSSARY_FILENAME)
74
+ if os.path.isfile(candidate):
75
+ return self._load(candidate, GLOSSARY_ORIGIN_TARGET)
76
+ return GlossarySource()
77
+
78
+ def _load(self, path: str, origin: str) -> GlossarySource:
79
+ """
80
+ Read and check one glossary file.
81
+
82
+ Raises:
83
+ TargetError: If it is unreadable or not a {term: translation} object of strings.
84
+ """
85
+ try:
86
+ data = safe_read_file(path, f"glossary {path}", self._file_ops, self._logger,
87
+ critical=True, as_text=False)
88
+ except FileOperationError as e:
89
+ raise TargetError(str(e)) from e
90
+
91
+ if not isinstance(data, dict) or not all(
92
+ isinstance(term, str) and isinstance(translation, str) for term, translation in data.items()):
93
+ raise TargetError(f"Glossary {path} must be a JSON object mapping terms to translation strings")
94
+ return GlossarySource(data, path, origin)