reducio 0.1.1__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.
reducio/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """reducio — semantic code compression engine."""
2
+
3
+ __version__ = "0.1.1"
reducio/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from reducio.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
@@ -0,0 +1,17 @@
1
+ """
2
+ Agent implementations for code analysis and refactoring.
3
+ """
4
+
5
+ from reducio.agents.analyzer import AnalyzerAgent
6
+ from reducio.agents.deduplicator import DeduplicatorAgent
7
+ from reducio.agents.idiomatizer import IdiomatizerAgent
8
+ from reducio.agents.pattern import PatternAgent
9
+ from reducio.agents.quality_checker import QualityCheckerAgent
10
+
11
+ __all__ = [
12
+ "AnalyzerAgent",
13
+ "DeduplicatorAgent",
14
+ "IdiomatizerAgent",
15
+ "PatternAgent",
16
+ "QualityCheckerAgent",
17
+ ]
@@ -0,0 +1,17 @@
1
+ """Analyzer agent using the shared, versioned source metrics."""
2
+
3
+ from reducio.analysis import analyze_files
4
+ from reducio.models import AnalyzeRequest, AnalyzeResult
5
+ from reducio.workspace import Workspace
6
+
7
+
8
+ class AnalyzerAgent:
9
+ def __init__(self, workspace: Workspace):
10
+ self.workspace = workspace
11
+
12
+ async def analyze(self, request: AnalyzeRequest) -> AnalyzeResult:
13
+ return analyze_files(
14
+ request.files or self.workspace.list_files(),
15
+ self.workspace.cfg,
16
+ request.path,
17
+ )
reducio/agents/base.py ADDED
@@ -0,0 +1,151 @@
1
+ """Base agent class."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import uuid
6
+ from typing import TYPE_CHECKING
7
+
8
+ from reducio.models import FileChange, PlanDiagnostic, PlanningProvenance, RefactorPlan
9
+ from reducio.plan_review import validate_plan
10
+ from reducio.session import SessionStore
11
+ from reducio.utils.code_utils import strip_code_fence
12
+ from reducio.workspace import Workspace
13
+
14
+ if TYPE_CHECKING:
15
+ from reducio.llm.router import LLMClient
16
+
17
+
18
+ class ModelRewriteError(RuntimeError):
19
+ pass
20
+
21
+
22
+ class BaseAgent:
23
+ def __init__(
24
+ self,
25
+ workspace: Workspace | None = None,
26
+ llm_router: LLMClient | None = None,
27
+ session_store: SessionStore | None = None,
28
+ ):
29
+ self.workspace = workspace
30
+ self.llm = llm_router
31
+ self.session_store = session_store or SessionStore(
32
+ str(workspace.root / ".reducio" / "sessions") if workspace else ".reducio/sessions"
33
+ )
34
+ self._begin_plan()
35
+
36
+ def _begin_plan(self, allow_fallback: bool = False) -> None:
37
+ self.allow_fallback = allow_fallback
38
+ self.diagnostics: list[PlanDiagnostic] = []
39
+ self.provenance: list[PlanningProvenance] = []
40
+
41
+ def _generate_session_id(self) -> str:
42
+ return str(uuid.uuid4())
43
+
44
+ def _save_plan(self, plan: RefactorPlan, command_type: str) -> None:
45
+ self.session_store.save_plan(plan, command_type=command_type)
46
+
47
+ def get_plan(self, session_id: str) -> RefactorPlan | None:
48
+ return self.session_store.load_plan(session_id)
49
+
50
+ def _file_content_path(self, file) -> tuple[str, str]:
51
+ if hasattr(file, "content"):
52
+ return file.content, file.path
53
+ return file["content"], file["path"]
54
+
55
+ def _llm_enabled(self) -> bool:
56
+ # An explicit model request must not silently degrade when no router is wired.
57
+ return bool(
58
+ self.workspace
59
+ and (
60
+ self.workspace.cfg.model
61
+ or self.workspace.cfg.llm_api
62
+ or self.workspace.cfg.llm_base_url
63
+ )
64
+ )
65
+
66
+ async def _llm_rewrite(
67
+ self, content: str, path: str, instruction: str, description: str
68
+ ) -> FileChange | None:
69
+ """Ask the LLM to rewrite a whole module; returns a reviewable change or None."""
70
+ prompt = (
71
+ f"{instruction}\nReturn ONLY the complete rewritten module, no prose.\n\n"
72
+ f"```python\n{content}\n```"
73
+ )
74
+ model = self.workspace.cfg.model if self.workspace else ""
75
+ model = "custom endpoint" if "://" in model else model
76
+ try:
77
+ if self.llm is None:
78
+ raise ModelRewriteError("No model router available")
79
+ raw = await self.llm.complete(
80
+ prompt, system_prompt="You are an expert Python engineer."
81
+ )
82
+ code = strip_code_fence(raw)
83
+ if not code.strip():
84
+ raise ValueError("Empty model response")
85
+ compile(code, path, "exec")
86
+ except Exception as error:
87
+ from reducio.llm.router import LLMError
88
+
89
+ detail = f": {error}" if isinstance(error, LLMError) else ""
90
+ self.diagnostics.append(
91
+ PlanDiagnostic(
92
+ code="model_failed",
93
+ file=path,
94
+ message="Model rewrite failed or returned empty/invalid Python"
95
+ + detail
96
+ + (
97
+ "; explicit fallback enabled"
98
+ if self.allow_fallback
99
+ else "; no application permitted"
100
+ ),
101
+ severity="warning" if self.allow_fallback else "error",
102
+ )
103
+ )
104
+ self.provenance.append(
105
+ PlanningProvenance(file=path, engine="model", outcome="failed", model=model)
106
+ )
107
+ raise ModelRewriteError("Model rewrite unavailable") from None
108
+ self.provenance.append(
109
+ PlanningProvenance(
110
+ file=path,
111
+ engine="model",
112
+ outcome="unchanged" if code.strip() == content.strip() else "proposed",
113
+ model=model,
114
+ )
115
+ )
116
+ if code.strip() == content.strip():
117
+ return None
118
+ return FileChange(
119
+ path=path,
120
+ original=content,
121
+ modified=code if code.endswith("\n") else code + "\n",
122
+ description=description,
123
+ )
124
+
125
+ def _finalize_plan(
126
+ self, changes: list, description: str, command_type: str, **plan_kw
127
+ ) -> RefactorPlan:
128
+ plan = RefactorPlan(
129
+ schema_version=2,
130
+ session_id=self._generate_session_id(),
131
+ changes=changes,
132
+ description=description,
133
+ diagnostics=self.diagnostics,
134
+ provenance=self.provenance,
135
+ **plan_kw,
136
+ )
137
+ for change in plan.changes:
138
+ if change.operation is None:
139
+ change.operation = "create" if change.creates_file else "replace"
140
+ # Coalesce truly identical changes, but never silently pick one conflicting version.
141
+ unique = []
142
+ for change in plan.changes:
143
+ if change not in unique:
144
+ unique.append(change)
145
+ plan.changes = unique
146
+ plan.diagnostics.extend(
147
+ validate_plan(plan, self.workspace.root if self.workspace else None)
148
+ )
149
+ plan.complete = not any(item.severity == "error" for item in plan.diagnostics)
150
+ self._save_plan(plan, command_type)
151
+ return plan
@@ -0,0 +1,193 @@
1
+ """Deduplicator agent — semantic duplicate detection."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ast
6
+ import builtins
7
+ import symtable
8
+ from typing import TYPE_CHECKING
9
+
10
+ from reducio.agents.base import BaseAgent
11
+
12
+ if TYPE_CHECKING:
13
+ from reducio.embeddings.service import EmbeddingService
14
+ from reducio.models import (
15
+ CodeBlock,
16
+ DeduplicateRequest,
17
+ FileChange,
18
+ FileInfo,
19
+ Language,
20
+ PlanDiagnostic,
21
+ PlanningProvenance,
22
+ RefactorPlan,
23
+ )
24
+ from reducio.parse import ParserError, get_complexity
25
+ from reducio.plan_review import advisory_path
26
+ from reducio.repo import detect_language
27
+ from reducio.session import SessionStore
28
+ from reducio.workspace import Workspace
29
+
30
+
31
+ class DeduplicatorAgent(BaseAgent):
32
+ workspace: Workspace
33
+
34
+ def __init__(
35
+ self,
36
+ workspace: Workspace,
37
+ embedding_service: EmbeddingService,
38
+ llm_router=None,
39
+ session_store: SessionStore | None = None,
40
+ ):
41
+ super().__init__(workspace, llm_router, session_store)
42
+ self.embedding_service = embedding_service
43
+
44
+ async def find_duplicates(self, request: DeduplicateRequest) -> RefactorPlan:
45
+ self._begin_plan()
46
+ files = request.files or self.workspace.list_files()
47
+ if not self.embedding_service.is_using_real_embeddings:
48
+ self.diagnostics.append(
49
+ PlanDiagnostic(
50
+ code="embeddings_unavailable",
51
+ severity="error",
52
+ message="Semantic embeddings unavailable; install reducio[embeddings] and retry.",
53
+ )
54
+ )
55
+ return self._finalize_plan(
56
+ [],
57
+ "Semantic embeddings unavailable; no duplicates analyzed. See installation docs for the embeddings extra.",
58
+ "deduplicate",
59
+ )
60
+ blocks = self._extract_blocks(files)
61
+ try:
62
+ groups = await self.embedding_service.find_duplicates(
63
+ blocks, request.similarity_threshold
64
+ )
65
+ except Exception:
66
+ self.diagnostics.append(
67
+ PlanDiagnostic(
68
+ code="embeddings_failed",
69
+ severity="error",
70
+ message="Semantic duplicate search failed; no application permitted.",
71
+ )
72
+ )
73
+ groups = []
74
+ self.provenance.append(
75
+ PlanningProvenance(
76
+ file="",
77
+ engine="embeddings",
78
+ outcome=(
79
+ "failed" if any(d.severity == "error" for d in self.diagnostics) else "scanned"
80
+ ),
81
+ )
82
+ )
83
+ changes = []
84
+ for group in groups:
85
+ if len(group) >= 2:
86
+ ch = self._create_dedup_change(group)
87
+ if ch:
88
+ changes.append(ch)
89
+ return self._finalize_plan(
90
+ changes,
91
+ f"Found {len(groups)} duplicate group(s); proposing {len(changes)} shared-util "
92
+ "suggestion(s) (review before adopting — call sites are not rewritten).",
93
+ "deduplicate",
94
+ )
95
+
96
+ def _extract_blocks(self, files: list[FileInfo]) -> list[CodeBlock]:
97
+ blocks: list[CodeBlock] = []
98
+ for f in files:
99
+ lang = detect_language(f.path)
100
+ if not f.error and lang == Language.UNKNOWN:
101
+ continue
102
+ try:
103
+ tree = f.tree
104
+ module_scope = symtable.symtable(f.content, f.path, "exec")
105
+ except ParserError, SyntaxError, ValueError:
106
+ self.diagnostics.append(
107
+ PlanDiagnostic(
108
+ code="parser_failed",
109
+ file=f.path,
110
+ severity="error",
111
+ message="Python parsing failed; duplicate analysis is incomplete.",
112
+ )
113
+ )
114
+ continue
115
+ top = [n for n in tree.body if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef))]
116
+ for node in ast.walk(tree):
117
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node not in top:
118
+ self.diagnostics.append(
119
+ PlanDiagnostic(
120
+ code="unsupported_scope",
121
+ file=f.path,
122
+ message=f"Skipped {node.name}: methods and nested functions are not standalone utilities.",
123
+ )
124
+ )
125
+ for node in top:
126
+ content = ast.get_source_segment(f.content, node) or ""
127
+ bindings = {
128
+ s.get_name()
129
+ for s in module_scope.get_symbols()
130
+ if s.is_assigned() or s.is_imported()
131
+ }
132
+ dependencies = _dependencies(content, node.name, bindings)
133
+ if node.decorator_list or dependencies:
134
+ self.diagnostics.append(
135
+ PlanDiagnostic(
136
+ code="dependencies",
137
+ file=f.path,
138
+ message=f"Skipped {node.name}: decorators or external dependencies require review.",
139
+ )
140
+ )
141
+ continue
142
+ try:
143
+ metrics = get_complexity(content)
144
+ except SyntaxError, ValueError:
145
+ continue # invalid snippets have no trustworthy numeric score
146
+ blocks.append(
147
+ CodeBlock(
148
+ id=f"{f.path}:{node.lineno}:{node.name}",
149
+ file=f.path,
150
+ start_line=node.lineno,
151
+ end_line=node.end_lineno or node.lineno,
152
+ content=content,
153
+ language=lang,
154
+ symbol_type="function",
155
+ symbol_name=node.name,
156
+ metrics=metrics,
157
+ )
158
+ )
159
+ return blocks
160
+
161
+ def _create_dedup_change(self, group: list[CodeBlock]) -> FileChange | None:
162
+ primary = group[0]
163
+ return FileChange(
164
+ path=advisory_path(
165
+ "utils", primary.file, f"{primary.symbol_name}_{primary.start_line}_dedup"
166
+ ),
167
+ original="",
168
+ modified=primary.content,
169
+ description=(
170
+ f"Proposed shared util for '{primary.symbol_name}' from {len(group)} sites "
171
+ "(suggestion only; applying writes the utility module; "
172
+ "originals and call sites are not rewritten)"
173
+ ),
174
+ )
175
+
176
+
177
+ def _dependencies(content: str, name: str, module_bindings: set[str]) -> set[str]:
178
+ """Conservatively reject names that need the original module's namespace."""
179
+ table = symtable.symtable(content, "<advisory>", "exec")
180
+ required: set[str] = set()
181
+ pending = [table]
182
+ while pending:
183
+ scope = pending.pop()
184
+ required.update(
185
+ s.get_name()
186
+ for s in scope.get_symbols()
187
+ if (s.is_referenced() or s.is_declared_global()) and s.is_global()
188
+ )
189
+ pending.extend(scope.get_children())
190
+ # Recursion is self-contained; builtins need no accompanying import.
191
+ dynamic = {"globals", "locals", "eval", "exec", "__import__"}
192
+ allowed_builtins = set(dir(builtins)) - module_bindings - dynamic
193
+ return required - allowed_builtins - {name}
@@ -0,0 +1,85 @@
1
+ """
2
+ Idiomatizer agent for transforming code to idiomatic patterns (Python heuristics).
3
+ """
4
+
5
+ from reducio.agents.base import BaseAgent, ModelRewriteError
6
+ from reducio.models import (
7
+ FileChange,
8
+ IdiomatizeRequest,
9
+ Language,
10
+ PlanDiagnostic,
11
+ PlanningProvenance,
12
+ RefactorPlan,
13
+ )
14
+ from reducio.repo import detect_language
15
+
16
+
17
+ class IdiomatizerAgent(BaseAgent):
18
+ async def idiomatize(self, request: IdiomatizeRequest) -> RefactorPlan:
19
+ self._begin_plan(request.allow_fallback)
20
+ changes = []
21
+ idioms = 0
22
+ for file in request.files:
23
+ try:
24
+ change, count = await self._idiomatize_file(file)
25
+ except ModelRewriteError:
26
+ break
27
+ if change:
28
+ changes.append(change)
29
+ idioms += count
30
+ return self._finalize_plan(
31
+ changes,
32
+ f"Found {idioms} opportunities for idiomatic improvements in {len(changes)} file(s).",
33
+ "idiomatize",
34
+ )
35
+
36
+ async def _idiomatize_file(self, file) -> tuple[FileChange | None, int]:
37
+ content, path = self._file_content_path(file)
38
+ if not file.error and detect_language(path) != Language.PYTHON:
39
+ return None, 0
40
+ try:
41
+ file.tree # Validated once, shared with subsequent analysis.
42
+ except SyntaxError, ValueError:
43
+ self.diagnostics.append(
44
+ PlanDiagnostic(
45
+ code="invalid_source",
46
+ file=path,
47
+ severity="error",
48
+ message="Cannot read or parse source; planning incomplete",
49
+ )
50
+ )
51
+ return None, 0
52
+ if self._llm_enabled():
53
+ try:
54
+ change = await self._llm_rewrite(
55
+ content,
56
+ path,
57
+ "Rewrite the following Python module to be more idiomatic and concise without changing behaviour.",
58
+ "LLM idiomatic rewrite",
59
+ )
60
+ if change:
61
+ change.operation, change.encoding = "replace", file.encoding
62
+ return change, int(change is not None)
63
+ except ModelRewriteError:
64
+ if not self.allow_fallback:
65
+ raise
66
+ change, count = self._idiomatize_python(content, path)
67
+ if change:
68
+ change.operation, change.encoding = "replace", file.encoding
69
+ self.provenance.append(
70
+ PlanningProvenance(
71
+ file=path, engine="heuristic", outcome="proposed" if change else "unchanged"
72
+ )
73
+ )
74
+ return change, count
75
+
76
+ def _idiomatize_python(self, content: str, path: str) -> tuple[FileChange | None, int]:
77
+ from reducio.idioms import rewrite
78
+
79
+ updated, descriptions, diagnostics = rewrite(content, path)
80
+ self.diagnostics.extend(diagnostics)
81
+ if updated == content:
82
+ return None, 0
83
+ return FileChange(
84
+ path=path, original=content, modified=updated, description="; ".join(descriptions)
85
+ ), len(descriptions)