arian 0.5.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.
- arian/__init__.py +7 -0
- arian/__main__.py +5 -0
- arian/bootstrap/__init__.py +5 -0
- arian/bootstrap/logging.py +278 -0
- arian/controller/__init__.py +7 -0
- arian/controller/cli/__init__.py +7 -0
- arian/controller/cli/app.py +330 -0
- arian/domain/__init__.py +51 -0
- arian/domain/context/__init__.py +15 -0
- arian/domain/context/models.py +306 -0
- arian/domain/exceptions.py +54 -0
- arian/domain/protocols.py +104 -0
- arian/domain/repository/__init__.py +17 -0
- arian/domain/repository/models.py +115 -0
- arian/domain/shared/__init__.py +15 -0
- arian/domain/shared/enums.py +61 -0
- arian/infrastructure/__init__.py +17 -0
- arian/infrastructure/config.py +62 -0
- arian/infrastructure/git/__init__.py +7 -0
- arian/infrastructure/git/analyzer.py +75 -0
- arian/infrastructure/gitignore_filter.py +64 -0
- arian/infrastructure/ignore/__init__.py +1 -0
- arian/infrastructure/ignore/default_patterns.py +20 -0
- arian/infrastructure/language.py +85 -0
- arian/infrastructure/output_path_resolver.py +26 -0
- arian/infrastructure/sqlite/__init__.py +7 -0
- arian/infrastructure/sqlite/connection.py +22 -0
- arian/infrastructure/tokenizer/__init__.py +7 -0
- arian/infrastructure/tokenizer/tokenizer.py +43 -0
- arian/main.py +8 -0
- arian/py.typed +0 -0
- arian/renderer/__init__.py +7 -0
- arian/renderer/markdown/__init__.py +7 -0
- arian/renderer/markdown/renderer.py +184 -0
- arian/repository/__init__.py +16 -0
- arian/repository/filesystem/__init__.py +7 -0
- arian/repository/filesystem/collector.py +172 -0
- arian/repository/index/__init__.py +11 -0
- arian/repository/index/memory_repository.py +116 -0
- arian/repository/index/protocols.py +100 -0
- arian/repository/index/sqlite_repository.py +255 -0
- arian/service/__init__.py +17 -0
- arian/service/analyzer/__init__.py +7 -0
- arian/service/analyzer/python_analyzer.py +437 -0
- arian/service/builder/__init__.py +7 -0
- arian/service/builder/context_builder.py +200 -0
- arian/service/classifier/__init__.py +7 -0
- arian/service/classifier/file_classifier.py +180 -0
- arian/service/context/__init__.py +1 -0
- arian/service/context/materializer.py +216 -0
- arian/service/planner/__init__.py +7 -0
- arian/service/planner/context_planner.py +475 -0
- arian/service/summary/__init__.py +5 -0
- arian/service/summary/summary_service.py +114 -0
- arian/template/__init__.py +1 -0
- arian-0.5.0.dist-info/METADATA +186 -0
- arian-0.5.0.dist-info/RECORD +61 -0
- arian-0.5.0.dist-info/WHEEL +5 -0
- arian-0.5.0.dist-info/entry_points.txt +2 -0
- arian-0.5.0.dist-info/licenses/LICENSE +190 -0
- arian-0.5.0.dist-info/top_level.txt +1 -0
arian/__init__.py
ADDED
arian/__main__.py
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
"""Logging initialization — configures stdlib logging for Arian.
|
|
2
|
+
|
|
3
|
+
Project rule
|
|
4
|
+
------------
|
|
5
|
+
Never instantiate, attach, or reconfigure handlers outside this module.
|
|
6
|
+
Business code must only call ``logging.getLogger(__name__)`` and emit records.
|
|
7
|
+
All wiring (handlers, formatters, filters, QueueListener) lives here.
|
|
8
|
+
|
|
9
|
+
Invariants
|
|
10
|
+
----------
|
|
11
|
+
1. Only the **root** logger owns output handlers.
|
|
12
|
+
2. Named loggers set level, clear handlers, and **propagate** to root.
|
|
13
|
+
3. ``configure_logging()`` is called **once** per process at startup.
|
|
14
|
+
A second call with ``async_logging=True`` would start another
|
|
15
|
+
QueueListener without stopping the first.
|
|
16
|
+
|
|
17
|
+
Formatting policy
|
|
18
|
+
-----------------
|
|
19
|
+
Logging format is selected internally by severity:
|
|
20
|
+
|
|
21
|
+
Diagnostic (< INFO): LEVEL WHEN WHERE RESOURCE : MESSAGE
|
|
22
|
+
Operational (>= INFO): LEVEL WHEN RESOURCE : MESSAGE
|
|
23
|
+
|
|
24
|
+
WHERE = filename:lineno (diagnostic only)
|
|
25
|
+
WHEN = UTC ISO-8601 timestamp
|
|
26
|
+
RESOURCE = optional extra field (e.g. ``resource="model_id=bert"``)
|
|
27
|
+
|
|
28
|
+
File logging
|
|
29
|
+
------------
|
|
30
|
+
When ``LoggingConfig.log_dir`` is set, logs are written to:
|
|
31
|
+
<log_dir>/arian.log
|
|
32
|
+
|
|
33
|
+
Files rotate at ``max_bytes`` with ``backup_count`` backups.
|
|
34
|
+
Log directory is created automatically if it does not exist.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
from __future__ import annotations
|
|
38
|
+
|
|
39
|
+
from datetime import UTC
|
|
40
|
+
from datetime import datetime
|
|
41
|
+
import logging
|
|
42
|
+
import logging.config
|
|
43
|
+
import logging.handlers
|
|
44
|
+
from pathlib import Path
|
|
45
|
+
import queue
|
|
46
|
+
from typing import Any
|
|
47
|
+
|
|
48
|
+
from arian.infrastructure.config import LoggingConfig
|
|
49
|
+
|
|
50
|
+
APPLICATION_LOGGER_NAME = "arian"
|
|
51
|
+
|
|
52
|
+
_PROPAGATING_LOGGERS = (APPLICATION_LOGGER_NAME,)
|
|
53
|
+
|
|
54
|
+
_MODULE = "arian.bootstrap.logging"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _format_utc_timestamp(a_epoch_seconds: float) -> str:
|
|
58
|
+
"""Format Unix epoch seconds as canonical UTC ISO-8601 (microseconds + Z).
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
a_epoch_seconds: Unix epoch timestamp.
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
Formatted string like ``2026-07-18T23:45:12.345678Z``.
|
|
65
|
+
"""
|
|
66
|
+
dt: datetime = datetime.fromtimestamp(a_epoch_seconds, tz=UTC)
|
|
67
|
+
return dt.astimezone(UTC).isoformat(timespec="microseconds").replace("+00:00", "Z")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class IsoUtcFormatter(logging.Formatter):
|
|
71
|
+
"""UTC ISO-8601 timestamp formatter."""
|
|
72
|
+
|
|
73
|
+
def formatTime( # a-prefix-ignore: stdlib Formatter.signature # noqa: N802
|
|
74
|
+
self,
|
|
75
|
+
record: logging.LogRecord,
|
|
76
|
+
datefmt: str | None = None, # noqa: ARG002 — required by Formatter API
|
|
77
|
+
) -> str:
|
|
78
|
+
"""Format record creation time as canonical UTC ISO form.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
record: Log record to format.
|
|
82
|
+
datefmt: Unused, required by Formatter API.
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
UTC ISO-8601 timestamp string.
|
|
86
|
+
"""
|
|
87
|
+
return _format_utc_timestamp(record.created)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class ResourceFilter(logging.Filter):
|
|
91
|
+
"""Inject format-safe ``resource`` attribute; always admit the record."""
|
|
92
|
+
|
|
93
|
+
def filter(self, record: logging.LogRecord) -> bool: # a-prefix-ignore: stdlib Filter.signature
|
|
94
|
+
"""Attach a format-safe ``resource`` attribute; always admit the record.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
record: Log record to filter.
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
Always True.
|
|
101
|
+
"""
|
|
102
|
+
resource = getattr(record, "resource", None)
|
|
103
|
+
if resource:
|
|
104
|
+
record.resource = f" {resource}"
|
|
105
|
+
else:
|
|
106
|
+
record.resource = ""
|
|
107
|
+
return True
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class DiagnosticLevelFilter(logging.Filter):
|
|
111
|
+
"""Admit Diagnostic (< INFO) records only."""
|
|
112
|
+
|
|
113
|
+
def filter(self, record: logging.LogRecord) -> bool: # a-prefix-ignore: stdlib Filter.signature
|
|
114
|
+
"""Return True for Diagnostic (< INFO) levels only.
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
record: Log record to filter.
|
|
118
|
+
|
|
119
|
+
Returns:
|
|
120
|
+
True if record level is below INFO.
|
|
121
|
+
"""
|
|
122
|
+
return record.levelno < logging.INFO
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _build_logging_config(a_config: LoggingConfig) -> dict[str, Any]:
|
|
126
|
+
"""Build dictConfig: named loggers propagate to root; root owns handlers.
|
|
127
|
+
|
|
128
|
+
Handlers:
|
|
129
|
+
console_debug: diagnostic (< INFO) to stderr
|
|
130
|
+
console_ops: operational (>= INFO) to stderr
|
|
131
|
+
file: all levels to rotating file (if log_dir is set)
|
|
132
|
+
|
|
133
|
+
Args:
|
|
134
|
+
a_config: Validated logging configuration.
|
|
135
|
+
|
|
136
|
+
Returns:
|
|
137
|
+
Dictionary suitable for logging.config.dictConfig.
|
|
138
|
+
"""
|
|
139
|
+
level: str = a_config.level
|
|
140
|
+
|
|
141
|
+
handlers: dict[str, dict[str, Any]] = {
|
|
142
|
+
"console_debug": {
|
|
143
|
+
"class": "logging.StreamHandler",
|
|
144
|
+
"level": "NOTSET",
|
|
145
|
+
"formatter": "diagnostic",
|
|
146
|
+
"filters": ["diagnostic_level", "resource"],
|
|
147
|
+
"stream": "ext://sys.stderr",
|
|
148
|
+
},
|
|
149
|
+
"console_ops": {
|
|
150
|
+
"class": "logging.StreamHandler",
|
|
151
|
+
"level": "INFO",
|
|
152
|
+
"formatter": "operational",
|
|
153
|
+
"filters": ["resource"],
|
|
154
|
+
"stream": "ext://sys.stderr",
|
|
155
|
+
},
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
root_handlers: list[str] = ["console_debug", "console_ops"]
|
|
159
|
+
|
|
160
|
+
if a_config.log_dir is not None:
|
|
161
|
+
log_dir: Path = Path(a_config.log_dir).expanduser()
|
|
162
|
+
log_dir.mkdir(parents=True, exist_ok=True)
|
|
163
|
+
log_file: Path = log_dir / "arian.log"
|
|
164
|
+
|
|
165
|
+
handlers["file"] = {
|
|
166
|
+
"class": "logging.handlers.RotatingFileHandler",
|
|
167
|
+
"level": "NOTSET",
|
|
168
|
+
"formatter": "file",
|
|
169
|
+
"filters": ["resource"],
|
|
170
|
+
"filename": str(log_file),
|
|
171
|
+
"maxBytes": a_config.max_bytes,
|
|
172
|
+
"backupCount": a_config.backup_count,
|
|
173
|
+
"encoding": "utf-8",
|
|
174
|
+
}
|
|
175
|
+
root_handlers.append("file")
|
|
176
|
+
|
|
177
|
+
return {
|
|
178
|
+
"version": 1,
|
|
179
|
+
"disable_existing_loggers": False,
|
|
180
|
+
"filters": {
|
|
181
|
+
"resource": {
|
|
182
|
+
"()": f"{_MODULE}.ResourceFilter",
|
|
183
|
+
},
|
|
184
|
+
"diagnostic_level": {
|
|
185
|
+
"()": f"{_MODULE}.DiagnosticLevelFilter",
|
|
186
|
+
},
|
|
187
|
+
},
|
|
188
|
+
"formatters": {
|
|
189
|
+
"diagnostic": {
|
|
190
|
+
"()": f"{_MODULE}.IsoUtcFormatter",
|
|
191
|
+
"format": "%(levelname)s %(asctime)s %(filename)s:%(lineno)d%(resource)s : %(message)s",
|
|
192
|
+
},
|
|
193
|
+
"operational": {
|
|
194
|
+
"()": f"{_MODULE}.IsoUtcFormatter",
|
|
195
|
+
"format": "%(levelname)s %(asctime)s%(resource)s : %(message)s",
|
|
196
|
+
},
|
|
197
|
+
"file": {
|
|
198
|
+
"()": f"{_MODULE}.IsoUtcFormatter",
|
|
199
|
+
"format": "%(levelname)s %(asctime)s %(filename)s:%(lineno)d%(resource)s : %(message)s",
|
|
200
|
+
},
|
|
201
|
+
},
|
|
202
|
+
"handlers": handlers,
|
|
203
|
+
"loggers": {
|
|
204
|
+
name: {
|
|
205
|
+
"level": level,
|
|
206
|
+
"handlers": [],
|
|
207
|
+
"propagate": True,
|
|
208
|
+
}
|
|
209
|
+
for name in _PROPAGATING_LOGGERS
|
|
210
|
+
},
|
|
211
|
+
"root": {
|
|
212
|
+
"level": "WARNING",
|
|
213
|
+
"handlers": root_handlers,
|
|
214
|
+
},
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _replace_handlers(a_logger: logging.Logger, a_handler: logging.Handler) -> None:
|
|
219
|
+
"""Replace all handlers on *a_logger* with *a_handler*.
|
|
220
|
+
|
|
221
|
+
Args:
|
|
222
|
+
a_logger: Logger to modify.
|
|
223
|
+
a_handler: Handler to install.
|
|
224
|
+
"""
|
|
225
|
+
a_logger.handlers.clear()
|
|
226
|
+
a_logger.addHandler(a_handler)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _enable_async_logging() -> logging.handlers.QueueListener:
|
|
230
|
+
"""Route root handlers through QueueHandler → QueueListener for non-blocking I/O.
|
|
231
|
+
|
|
232
|
+
Returns:
|
|
233
|
+
Running QueueListener instance.
|
|
234
|
+
|
|
235
|
+
Raises:
|
|
236
|
+
RuntimeError: If root logger has no handlers to wrap.
|
|
237
|
+
"""
|
|
238
|
+
root: logging.Logger = logging.getLogger()
|
|
239
|
+
targets: list[logging.Handler] = [
|
|
240
|
+
handler for handler in root.handlers if not isinstance(handler, logging.handlers.QueueHandler)
|
|
241
|
+
]
|
|
242
|
+
if not targets:
|
|
243
|
+
msg = "Cannot enable async logging: root logger has no handlers"
|
|
244
|
+
raise RuntimeError(msg)
|
|
245
|
+
|
|
246
|
+
log_queue: queue.SimpleQueue[logging.LogRecord] = queue.SimpleQueue()
|
|
247
|
+
queue_handler: logging.handlers.QueueHandler = logging.handlers.QueueHandler(log_queue)
|
|
248
|
+
_replace_handlers(root, queue_handler)
|
|
249
|
+
|
|
250
|
+
listener: logging.handlers.QueueListener = logging.handlers.QueueListener(
|
|
251
|
+
log_queue,
|
|
252
|
+
*targets,
|
|
253
|
+
respect_handler_level=True,
|
|
254
|
+
)
|
|
255
|
+
listener.start()
|
|
256
|
+
return listener
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def configure_logging(a_config: LoggingConfig | None = None) -> logging.handlers.QueueListener | None:
|
|
260
|
+
"""Apply dictConfig + captureWarnings once at process startup.
|
|
261
|
+
|
|
262
|
+
Returns a running ``QueueListener`` when async logging is enabled, else ``None``.
|
|
263
|
+
|
|
264
|
+
Args:
|
|
265
|
+
a_config: Logging configuration. Uses defaults if None.
|
|
266
|
+
|
|
267
|
+
Returns:
|
|
268
|
+
Running QueueListener if async_logging is enabled, None otherwise.
|
|
269
|
+
"""
|
|
270
|
+
config: LoggingConfig = a_config or LoggingConfig()
|
|
271
|
+
|
|
272
|
+
logging.config.dictConfig(_build_logging_config(config))
|
|
273
|
+
logging.captureWarnings(True)
|
|
274
|
+
|
|
275
|
+
listener: logging.handlers.QueueListener | None = None
|
|
276
|
+
if config.async_logging:
|
|
277
|
+
listener = _enable_async_logging()
|
|
278
|
+
return listener
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
"""Typer CLI application for Arian."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
import logging.handlers
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
|
|
12
|
+
from arian.bootstrap.logging import configure_logging
|
|
13
|
+
from arian.domain.context.models import ContextPlan
|
|
14
|
+
from arian.domain.context.models import ContextTask
|
|
15
|
+
from arian.domain.shared.enums import TokenBudget
|
|
16
|
+
from arian.infrastructure.config import LoggingConfig
|
|
17
|
+
from arian.infrastructure.ignore.default_patterns import DEFAULT_EXCLUDES
|
|
18
|
+
from arian.infrastructure.output_path_resolver import resolve_output_path
|
|
19
|
+
from arian.renderer.markdown.renderer import MarkdownRenderer
|
|
20
|
+
from arian.repository.filesystem.collector import FileCollector
|
|
21
|
+
from arian.repository.index.memory_repository import MemoryRepositoryIndex
|
|
22
|
+
from arian.service.analyzer.python_analyzer import PythonAnalyzer
|
|
23
|
+
from arian.service.builder.context_builder import ContextBuilder
|
|
24
|
+
from arian.service.classifier.file_classifier import FileClassifier
|
|
25
|
+
from arian.service.context.materializer import ContextMaterializer
|
|
26
|
+
from arian.service.planner.context_planner import ContextPlanner
|
|
27
|
+
|
|
28
|
+
app: typer.Typer = typer.Typer(help="Repository intelligence and context planning engine.", add_completion=False)
|
|
29
|
+
|
|
30
|
+
logger: logging.Logger = logging.getLogger(__name__)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _parse_budget(a_value: str | None) -> int | None:
|
|
34
|
+
"""Parse budget string into token count or None for unlimited.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
a_value: Raw budget string from CLI. None, "none", or a positive integer.
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
Parsed budget as int, or None for unlimited.
|
|
41
|
+
|
|
42
|
+
Raises:
|
|
43
|
+
typer.Exit: If value is not a valid number or is non-positive.
|
|
44
|
+
"""
|
|
45
|
+
budget_value: int | None = None
|
|
46
|
+
if a_value is not None:
|
|
47
|
+
if a_value.lower() == "none":
|
|
48
|
+
budget_value = None
|
|
49
|
+
else:
|
|
50
|
+
try:
|
|
51
|
+
budget_value = int(a_value)
|
|
52
|
+
except ValueError:
|
|
53
|
+
logger.exception("Invalid budget: %s. Must be a number or 'none'.", a_value)
|
|
54
|
+
raise typer.Exit(code=1) from None
|
|
55
|
+
if budget_value <= 0:
|
|
56
|
+
logger.error("Budget must be > 0, got: %d", budget_value)
|
|
57
|
+
raise typer.Exit(code=1) from None
|
|
58
|
+
return budget_value
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
_DEFAULT_EXTENSIONS: frozenset[str] = frozenset({".py", ".md", ".txt", ".rst", ".toml", ".yaml", ".yml", ".json"})
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _run_separate(
|
|
65
|
+
a_builder: ContextBuilder,
|
|
66
|
+
a_input_paths: list[Path],
|
|
67
|
+
a_task: ContextTask,
|
|
68
|
+
a_budget: TokenBudget,
|
|
69
|
+
a_query: str | None,
|
|
70
|
+
a_root: Path,
|
|
71
|
+
a_output_path: Path,
|
|
72
|
+
a_scope: str,
|
|
73
|
+
) -> None:
|
|
74
|
+
"""Build and write separate context files for each path.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
a_builder: Context builder instance.
|
|
78
|
+
a_input_paths: Paths to process individually.
|
|
79
|
+
a_task: Task type.
|
|
80
|
+
a_budget: Token budget.
|
|
81
|
+
a_query: Optional query.
|
|
82
|
+
a_root: Repository root.
|
|
83
|
+
a_output_path: Base output path.
|
|
84
|
+
a_scope: Scope mode string.
|
|
85
|
+
"""
|
|
86
|
+
for input_path in a_input_paths:
|
|
87
|
+
if not input_path.exists():
|
|
88
|
+
logger.error("Path does not exist: %s", input_path)
|
|
89
|
+
raise typer.Exit(code=1) from None
|
|
90
|
+
plan = asyncio.run(
|
|
91
|
+
a_builder.build(a_path=input_path, a_task=a_task, a_budget=a_budget, a_query=a_query, a_root=a_root)
|
|
92
|
+
)
|
|
93
|
+
input_name: str = str(input_path.relative_to(a_root)) if input_path != a_root else "."
|
|
94
|
+
plan = ContextPlan(
|
|
95
|
+
chunks=plan.chunks,
|
|
96
|
+
total_tokens=plan.total_tokens,
|
|
97
|
+
total_files=plan.total_files,
|
|
98
|
+
task=plan.task,
|
|
99
|
+
query=plan.query,
|
|
100
|
+
metadata={
|
|
101
|
+
"repository": a_root.name,
|
|
102
|
+
"paths": [input_name],
|
|
103
|
+
"budget": {"max": a_budget.max_tokens},
|
|
104
|
+
"scope": a_scope,
|
|
105
|
+
},
|
|
106
|
+
repository_files=plan.repository_files,
|
|
107
|
+
)
|
|
108
|
+
content_map = asyncio.run(a_builder.load_content(a_plan=plan, a_root=a_root))
|
|
109
|
+
materialized = a_builder.materialize(plan, content_map)
|
|
110
|
+
renderer: MarkdownRenderer = MarkdownRenderer()
|
|
111
|
+
rendered: str = renderer.render(materialized, plan)
|
|
112
|
+
if input_path == a_root:
|
|
113
|
+
sep_output = a_output_path.parent / "root_context.md"
|
|
114
|
+
else:
|
|
115
|
+
rel_name: Path = input_path.relative_to(a_root)
|
|
116
|
+
sep_output = a_output_path.parent / f"{rel_name}_context.md"
|
|
117
|
+
sep_output.parent.mkdir(parents=True, exist_ok=True)
|
|
118
|
+
sep_output.write_text(rendered, encoding="utf-8")
|
|
119
|
+
logger.info("Output: %s", sep_output)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _run_merged(
|
|
123
|
+
a_builder: ContextBuilder,
|
|
124
|
+
a_input_paths: list[Path],
|
|
125
|
+
a_task: ContextTask,
|
|
126
|
+
a_budget: TokenBudget,
|
|
127
|
+
a_query: str | None,
|
|
128
|
+
a_root: Path,
|
|
129
|
+
a_output_path: Path,
|
|
130
|
+
a_scope: str,
|
|
131
|
+
a_paths_provided: bool,
|
|
132
|
+
) -> None:
|
|
133
|
+
"""Build and write a single merged context file.
|
|
134
|
+
|
|
135
|
+
Args:
|
|
136
|
+
a_builder: Context builder instance.
|
|
137
|
+
a_input_paths: All input paths.
|
|
138
|
+
a_task: Task type.
|
|
139
|
+
a_budget: Token budget.
|
|
140
|
+
a_query: Optional query.
|
|
141
|
+
a_root: Repository root.
|
|
142
|
+
a_output_path: Output file path.
|
|
143
|
+
a_scope: Scope mode string.
|
|
144
|
+
a_paths_provided: Whether user provided explicit paths.
|
|
145
|
+
"""
|
|
146
|
+
plan = asyncio.run(
|
|
147
|
+
a_builder.build(
|
|
148
|
+
a_path=a_root,
|
|
149
|
+
a_task=a_task,
|
|
150
|
+
a_budget=a_budget,
|
|
151
|
+
a_query=a_query,
|
|
152
|
+
a_root=a_root,
|
|
153
|
+
a_input_paths=a_input_paths if a_paths_provided else None,
|
|
154
|
+
)
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
input_names = [str(p.relative_to(a_root)) if p != a_root else "." for p in a_input_paths]
|
|
158
|
+
plan = ContextPlan(
|
|
159
|
+
chunks=plan.chunks,
|
|
160
|
+
total_tokens=plan.total_tokens,
|
|
161
|
+
total_files=plan.total_files,
|
|
162
|
+
task=plan.task,
|
|
163
|
+
query=plan.query,
|
|
164
|
+
metadata={
|
|
165
|
+
"repository": a_root.name,
|
|
166
|
+
"paths": input_names,
|
|
167
|
+
"budget": {"max": a_budget.max_tokens},
|
|
168
|
+
"scope": a_scope,
|
|
169
|
+
},
|
|
170
|
+
repository_files=plan.repository_files,
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
content_map = asyncio.run(a_builder.load_content(a_plan=plan, a_root=a_root))
|
|
174
|
+
materialized = a_builder.materialize(plan, content_map)
|
|
175
|
+
renderer_final: MarkdownRenderer = MarkdownRenderer()
|
|
176
|
+
rendered_final: str = renderer_final.render(materialized, plan)
|
|
177
|
+
a_output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
178
|
+
a_output_path.write_text(rendered_final, encoding="utf-8")
|
|
179
|
+
|
|
180
|
+
logger.info(
|
|
181
|
+
"Context generated: %d files, %d tokens, %d chunks",
|
|
182
|
+
plan.total_files,
|
|
183
|
+
plan.total_tokens,
|
|
184
|
+
len(plan.chunks),
|
|
185
|
+
)
|
|
186
|
+
logger.info("Output: %s", a_output_path)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _run_group(
|
|
190
|
+
a_builder: ContextBuilder,
|
|
191
|
+
a_group_paths: list[Path],
|
|
192
|
+
a_task: ContextTask,
|
|
193
|
+
a_budget: TokenBudget,
|
|
194
|
+
a_query: str | None,
|
|
195
|
+
a_root: Path,
|
|
196
|
+
a_output_path: Path,
|
|
197
|
+
) -> None:
|
|
198
|
+
"""Build and write context for a single group of paths.
|
|
199
|
+
|
|
200
|
+
Args:
|
|
201
|
+
a_builder: Context builder instance.
|
|
202
|
+
a_group_paths: Paths belonging to this group.
|
|
203
|
+
a_task: Task type.
|
|
204
|
+
a_budget: Token budget.
|
|
205
|
+
a_query: Optional query.
|
|
206
|
+
a_root: Repository root.
|
|
207
|
+
a_output_path: Base output path.
|
|
208
|
+
"""
|
|
209
|
+
plan = asyncio.run(
|
|
210
|
+
a_builder.build(
|
|
211
|
+
a_path=a_root,
|
|
212
|
+
a_task=a_task,
|
|
213
|
+
a_budget=a_budget,
|
|
214
|
+
a_query=a_query,
|
|
215
|
+
a_root=a_root,
|
|
216
|
+
a_input_paths=a_group_paths,
|
|
217
|
+
)
|
|
218
|
+
)
|
|
219
|
+
group_names: list[str] = [p.name for p in a_group_paths]
|
|
220
|
+
group_label: str = "_".join(group_names) if len(group_names) > 1 else group_names[0]
|
|
221
|
+
group_output = a_output_path.parent / f"{group_label}_context.md"
|
|
222
|
+
group_output.parent.mkdir(parents=True, exist_ok=True)
|
|
223
|
+
|
|
224
|
+
input_names = [str(p.relative_to(a_root)) for p in a_group_paths]
|
|
225
|
+
plan = ContextPlan(
|
|
226
|
+
chunks=plan.chunks,
|
|
227
|
+
total_tokens=plan.total_tokens,
|
|
228
|
+
total_files=plan.total_files,
|
|
229
|
+
task=plan.task,
|
|
230
|
+
query=plan.query,
|
|
231
|
+
metadata={
|
|
232
|
+
"repository": a_root.name,
|
|
233
|
+
"paths": input_names,
|
|
234
|
+
"budget": {"max": a_budget.max_tokens},
|
|
235
|
+
"scope": "group",
|
|
236
|
+
},
|
|
237
|
+
repository_files=plan.repository_files,
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
content_map = asyncio.run(a_builder.load_content(a_plan=plan, a_root=a_root))
|
|
241
|
+
materialized = a_builder.materialize(plan, content_map)
|
|
242
|
+
renderer: MarkdownRenderer = MarkdownRenderer()
|
|
243
|
+
rendered: str = renderer.render(materialized, plan)
|
|
244
|
+
group_output.write_text(rendered, encoding="utf-8")
|
|
245
|
+
logger.info("Output: %s", group_output)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
@app.command() # a-prefix-ignore: Typer CLI public names
|
|
249
|
+
def context( # a-prefix-ignore: Typer CLI public names
|
|
250
|
+
task: str = typer.Option(
|
|
251
|
+
"general",
|
|
252
|
+
"--task",
|
|
253
|
+
case_sensitive=False,
|
|
254
|
+
help="Task type: bug_fix, feature, review, onboarding, refactor, document, general",
|
|
255
|
+
),
|
|
256
|
+
query: str | None = typer.Option(
|
|
257
|
+
None, "--query", "-q", help="Query for relevance matching (reserved, not yet implemented)"
|
|
258
|
+
),
|
|
259
|
+
output: str = typer.Option("~/.arian/output/context.md", "-o", "--output", help="Output file path"),
|
|
260
|
+
budget: str | None = typer.Option(None, "--budget", help="Maximum tokens for context (default: unlimited)"),
|
|
261
|
+
scope: str = typer.Option("merged", "--scope", help="Scope mode: merged (default) or separate"),
|
|
262
|
+
group: list[str] | None = typer.Option(
|
|
263
|
+
None,
|
|
264
|
+
"--group",
|
|
265
|
+
help="Group paths into one context file. Comma-separated. Repeatable: --group src/,lib/ --group docs/",
|
|
266
|
+
),
|
|
267
|
+
verbose: bool = typer.Option(False, "-v", "--verbose", help="Enable debug logging"),
|
|
268
|
+
paths: list[str] = typer.Argument(default=None, help="Directories or files to include (default: cwd)"),
|
|
269
|
+
) -> None:
|
|
270
|
+
"""Generate task-aware context from a repository."""
|
|
271
|
+
listener: logging.handlers.QueueListener | None = configure_logging(
|
|
272
|
+
LoggingConfig(level="DEBUG" if verbose else "INFO")
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
try:
|
|
276
|
+
logger.info("Generating context for task=%s", task)
|
|
277
|
+
|
|
278
|
+
try:
|
|
279
|
+
task_enum: ContextTask = ContextTask(task.lower())
|
|
280
|
+
except ValueError:
|
|
281
|
+
msg = f"Invalid task: {task}. Valid tasks: {', '.join(t.value for t in ContextTask)}"
|
|
282
|
+
logger.exception(msg)
|
|
283
|
+
raise typer.Exit(code=1) from None
|
|
284
|
+
|
|
285
|
+
if scope not in ("merged", "separate"):
|
|
286
|
+
msg = f"Invalid scope: {scope}. Valid scopes: merged, separate"
|
|
287
|
+
logger.error(msg)
|
|
288
|
+
raise typer.Exit(code=1) from None
|
|
289
|
+
|
|
290
|
+
token_budget: TokenBudget = TokenBudget(max_tokens=_parse_budget(budget))
|
|
291
|
+
root: Path = Path.cwd()
|
|
292
|
+
output_path: Path = resolve_output_path(output)
|
|
293
|
+
|
|
294
|
+
input_paths: list[Path] = [root / p for p in paths] if paths else [root]
|
|
295
|
+
|
|
296
|
+
classifier: FileClassifier = FileClassifier()
|
|
297
|
+
collector: FileCollector = FileCollector(
|
|
298
|
+
a_extensions=_DEFAULT_EXTENSIONS,
|
|
299
|
+
a_exclude=DEFAULT_EXCLUDES,
|
|
300
|
+
a_classifier=classifier,
|
|
301
|
+
)
|
|
302
|
+
index: MemoryRepositoryIndex = MemoryRepositoryIndex()
|
|
303
|
+
analyzer: PythonAnalyzer = PythonAnalyzer()
|
|
304
|
+
planner: ContextPlanner = ContextPlanner(a_classifier=classifier)
|
|
305
|
+
materializer: ContextMaterializer = ContextMaterializer(a_analyzer=analyzer)
|
|
306
|
+
builder: ContextBuilder = ContextBuilder(
|
|
307
|
+
a_collector=collector,
|
|
308
|
+
a_index=index,
|
|
309
|
+
a_planner=planner,
|
|
310
|
+
a_materializer=materializer,
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
if group:
|
|
314
|
+
if scope != "merged":
|
|
315
|
+
logger.warning("--scope is ignored when --group is used")
|
|
316
|
+
for group_spec in group:
|
|
317
|
+
group_paths: list[Path] = [root / p.strip() for p in group_spec.split(",")]
|
|
318
|
+
for gp in group_paths:
|
|
319
|
+
if not gp.exists():
|
|
320
|
+
logger.error("Path does not exist: %s", gp)
|
|
321
|
+
raise typer.Exit(code=1) from None
|
|
322
|
+
_run_group(builder, group_paths, task_enum, token_budget, query, root, output_path)
|
|
323
|
+
|
|
324
|
+
elif scope == "separate":
|
|
325
|
+
_run_separate(builder, input_paths, task_enum, token_budget, query, root, output_path, scope)
|
|
326
|
+
else:
|
|
327
|
+
_run_merged(builder, input_paths, task_enum, token_budget, query, root, output_path, scope, bool(paths))
|
|
328
|
+
finally:
|
|
329
|
+
if listener is not None:
|
|
330
|
+
listener.stop()
|
arian/domain/__init__.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Domain layer for Arian.
|
|
2
|
+
|
|
3
|
+
Provides domain entities, enums, and exceptions.
|
|
4
|
+
Zero external dependencies — only stdlib imports.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from arian.domain.context import ContextChunk
|
|
8
|
+
from arian.domain.context import ContextPlan
|
|
9
|
+
from arian.domain.context import ContextResult
|
|
10
|
+
from arian.domain.context import ContextTask
|
|
11
|
+
from arian.domain.context import PlannedFile
|
|
12
|
+
from arian.domain.exceptions import ContextBuilderError
|
|
13
|
+
from arian.domain.exceptions import InputNotFoundError
|
|
14
|
+
from arian.domain.exceptions import NoDocumentsError
|
|
15
|
+
from arian.domain.exceptions import ProjectBaseError
|
|
16
|
+
from arian.domain.protocols import LanguageAnalyzerProtocol
|
|
17
|
+
from arian.domain.repository import Dependency
|
|
18
|
+
from arian.domain.repository import FileContent
|
|
19
|
+
from arian.domain.repository import Module
|
|
20
|
+
from arian.domain.repository import Repository
|
|
21
|
+
from arian.domain.repository import RepositoryFile
|
|
22
|
+
from arian.domain.repository import Symbol
|
|
23
|
+
from arian.domain.shared import CompressionLevel
|
|
24
|
+
from arian.domain.shared import DependencyKind
|
|
25
|
+
from arian.domain.shared import FileRole
|
|
26
|
+
from arian.domain.shared import SymbolKind
|
|
27
|
+
from arian.domain.shared import TokenBudget
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"CompressionLevel",
|
|
31
|
+
"ContextBuilderError",
|
|
32
|
+
"ContextChunk",
|
|
33
|
+
"ContextPlan",
|
|
34
|
+
"ContextResult",
|
|
35
|
+
"ContextTask",
|
|
36
|
+
"Dependency",
|
|
37
|
+
"DependencyKind",
|
|
38
|
+
"FileContent",
|
|
39
|
+
"FileRole",
|
|
40
|
+
"InputNotFoundError",
|
|
41
|
+
"LanguageAnalyzerProtocol",
|
|
42
|
+
"Module",
|
|
43
|
+
"NoDocumentsError",
|
|
44
|
+
"PlannedFile",
|
|
45
|
+
"ProjectBaseError",
|
|
46
|
+
"Repository",
|
|
47
|
+
"RepositoryFile",
|
|
48
|
+
"Symbol",
|
|
49
|
+
"SymbolKind",
|
|
50
|
+
"TokenBudget",
|
|
51
|
+
]
|