rylox 0.0.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.
rylox/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Rylox — local, deterministic repository context engine."""
2
+
3
+ __version__ = "0.0.1"
rylox/cache.py ADDED
@@ -0,0 +1,151 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import tempfile
6
+ from dataclasses import asdict, dataclass, field
7
+ from pathlib import Path
8
+ from typing import Any, Optional
9
+
10
+ from rylox.chunking import Chunk
11
+ from rylox.errors import IndexCorruptError, IndexNotFoundError
12
+
13
+ CACHE_DIRNAME = ".rylox"
14
+ INDEX_FILENAME = "index.json"
15
+ SCHEMA_VERSION = 1
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class CachedChunk:
20
+ path: str
21
+ kind: str
22
+ name: str
23
+ start_line: int
24
+ end_line: int
25
+ parent_class: Optional[str]
26
+ docstring: Optional[str]
27
+ content: str
28
+
29
+ @classmethod
30
+ def from_chunk(cls, chunk: Chunk) -> CachedChunk:
31
+ return cls(
32
+ path=chunk.path.as_posix(),
33
+ kind=chunk.kind,
34
+ name=chunk.name,
35
+ start_line=chunk.start_line,
36
+ end_line=chunk.end_line,
37
+ parent_class=chunk.parent_class,
38
+ docstring=chunk.docstring,
39
+ content=chunk.content,
40
+ )
41
+
42
+ def to_chunk(self) -> Chunk:
43
+ return Chunk(
44
+ path=Path(self.path),
45
+ kind=self.kind, # type: ignore[arg-type]
46
+ name=self.name,
47
+ start_line=self.start_line,
48
+ end_line=self.end_line,
49
+ parent_class=self.parent_class,
50
+ docstring=self.docstring,
51
+ content=self.content,
52
+ )
53
+
54
+
55
+ @dataclass
56
+ class FileEntry:
57
+ hash: str
58
+ chunks: list[CachedChunk] = field(default_factory=list)
59
+
60
+
61
+ @dataclass
62
+ class IndexManifest:
63
+ schema_version: int = SCHEMA_VERSION
64
+ files: dict[str, FileEntry] = field(default_factory=dict)
65
+
66
+
67
+ def cache_dir(repo: Path) -> Path:
68
+ return repo / CACHE_DIRNAME
69
+
70
+
71
+ def index_path(repo: Path) -> Path:
72
+ return cache_dir(repo) / INDEX_FILENAME
73
+
74
+
75
+ def ensure_cache_dir(repo: Path) -> Path:
76
+ directory = cache_dir(repo)
77
+ directory.mkdir(parents=True, exist_ok=True)
78
+ gitignore = directory / ".gitignore"
79
+ if not gitignore.exists():
80
+ gitignore.write_text("*\n", encoding="utf-8")
81
+ return directory
82
+
83
+
84
+ def save_index(repo: Path, manifest: IndexManifest) -> None:
85
+ ensure_cache_dir(repo)
86
+ target = index_path(repo)
87
+ payload = _serialize(manifest)
88
+
89
+ fd, tmp_name = tempfile.mkstemp(dir=str(target.parent), prefix=".index-", suffix=".tmp")
90
+ try:
91
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
92
+ json.dump(payload, f, indent=2)
93
+ os.replace(tmp_name, target)
94
+ except Exception:
95
+ if os.path.exists(tmp_name):
96
+ os.remove(tmp_name)
97
+ raise
98
+
99
+
100
+ def load_index(repo: Path) -> IndexManifest:
101
+ target = index_path(repo)
102
+ if not target.exists():
103
+ raise IndexNotFoundError(
104
+ f"no index found at {target}. Run `rylox index` first."
105
+ )
106
+
107
+ try:
108
+ raw = json.loads(target.read_text(encoding="utf-8"))
109
+ except (OSError, json.JSONDecodeError) as exc:
110
+ raise IndexCorruptError(f"{target} is unreadable or corrupt: {exc}") from exc
111
+
112
+ try:
113
+ return _deserialize(raw)
114
+ except (KeyError, TypeError, ValueError) as exc:
115
+ raise IndexCorruptError(f"{target} has an unexpected structure: {exc}") from exc
116
+
117
+
118
+ def load_or_empty(repo: Path) -> IndexManifest:
119
+ try:
120
+ return load_index(repo)
121
+ except IndexNotFoundError:
122
+ return IndexManifest()
123
+
124
+
125
+ def _serialize(manifest: IndexManifest) -> dict[str, Any]:
126
+ return {
127
+ "schema_version": manifest.schema_version,
128
+ "files": {
129
+ relpath: {
130
+ "hash": entry.hash,
131
+ "chunks": [asdict(c) for c in entry.chunks],
132
+ }
133
+ for relpath, entry in manifest.files.items()
134
+ },
135
+ }
136
+
137
+
138
+ def _deserialize(raw: dict[str, Any]) -> IndexManifest:
139
+ schema_version = raw["schema_version"]
140
+ if schema_version != SCHEMA_VERSION:
141
+ raise IndexCorruptError(
142
+ f"index schema_version {schema_version} is not supported "
143
+ f"(expected {SCHEMA_VERSION}); run `rylox clean` and re-index."
144
+ )
145
+
146
+ files: dict[str, FileEntry] = {}
147
+ for relpath, entry_raw in raw["files"].items():
148
+ chunks = [CachedChunk(**c) for c in entry_raw["chunks"]]
149
+ files[relpath] = FileEntry(hash=entry_raw["hash"], chunks=chunks)
150
+
151
+ return IndexManifest(schema_version=schema_version, files=files)
rylox/chunking.py ADDED
@@ -0,0 +1,216 @@
1
+ """Python chunking via tree-sitter (spec §2).
2
+
3
+ Scope for this module, deliberately narrow:
4
+ - integrate tree-sitter-python and parse .py source into an AST
5
+ - chunk at function / method / class granularity (never file, never line)
6
+ - store per-chunk metadata: file path, line range, parent class, docstring,
7
+ source text
8
+ - skip a file tree-sitter can't parse without crashing the whole run
9
+
10
+ Explicitly NOT in this module yet (later phases): content hashing for
11
+ incremental re-index, the `.rylox/` cache, and CLI wiring for `rylox index`.
12
+ `parse_source` and `parse_file` below are self-contained and testable without
13
+ any of that machinery existing yet.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from dataclasses import dataclass
19
+ from pathlib import Path
20
+ from typing import Literal, Optional
21
+
22
+ import tree_sitter_python as tspython
23
+ from tree_sitter import Language, Node, Parser
24
+
25
+ _PY_LANGUAGE = Language(tspython.language(), "python")
26
+
27
+ # One parser instance, reused across calls. tree_sitter.Parser holds no
28
+ # per-parse mutable state worth avoiding reuse over, so constructing a new
29
+ # one on every parse_source() call was pure overhead.
30
+ _PARSER = Parser()
31
+ _PARSER.set_language(_PY_LANGUAGE)
32
+
33
+ ChunkKind = Literal["function", "method", "class"]
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class Chunk:
38
+ """One retrievable unit of code, per spec §2's chunk metadata list."""
39
+
40
+ path: Path
41
+ kind: ChunkKind
42
+ name: str
43
+ start_line: int # 1-indexed, inclusive. Includes decorator lines, if any.
44
+ end_line: int # 1-indexed, inclusive
45
+ parent_class: Optional[str]
46
+ docstring: Optional[str]
47
+ content: str # exact source text of the chunk, decorators included
48
+
49
+
50
+ @dataclass
51
+ class ParseResult:
52
+ """Outcome of parsing one file: either chunks, or a recorded error.
53
+
54
+ `error` being set means the file was skipped, not that the whole
55
+ operation failed (spec §12: malformed files are logged and skipped,
56
+ never fatal for the whole `index` run). Callers should check `error`
57
+ and continue on to the next file rather than propagating an exception.
58
+ """
59
+
60
+ chunks: list[Chunk]
61
+ error: Optional[str] = None
62
+
63
+
64
+ def parse_source(source: str, path: Path) -> list[Chunk]:
65
+ """Parse Python source text into function/method/class chunks.
66
+
67
+ Pure function, no filesystem access — this is what makes chunking
68
+ behavior directly unit-testable against hand-written source strings,
69
+ independent of `parse_file`'s I/O and error handling.
70
+ """
71
+ tree = _PARSER.parse(source.encode("utf-8"))
72
+ lines = source.splitlines()
73
+ chunks: list[Chunk] = []
74
+ _walk(tree.root_node, path, lines, parent_class=None, chunks=chunks)
75
+ return chunks
76
+
77
+
78
+ def parse_file(path: Path) -> ParseResult:
79
+ """Read and parse a single .py file. Never raises — malformed or
80
+ unreadable files come back as a ParseResult with `error` set and an
81
+ empty chunk list, per spec §12.
82
+ """
83
+ try:
84
+ source = path.read_text(encoding="utf-8-sig")
85
+ except (OSError, UnicodeDecodeError) as exc:
86
+ return ParseResult(chunks=[], error=f"{path}: could not read file ({exc})")
87
+
88
+ try:
89
+ chunks = parse_source(source, path)
90
+ except Exception as exc: # noqa: BLE001 — deliberately broad: any parser
91
+ # failure must degrade to "skip this file", never crash the whole
92
+ # index run (spec §12). Narrowing this to tree_sitter's specific
93
+ # exception types would risk missing a case and defeating the point.
94
+ return ParseResult(chunks=[], error=f"{path}: failed to parse ({exc})")
95
+
96
+ return ParseResult(chunks=chunks, error=None)
97
+
98
+
99
+ def _walk(
100
+ node: Node,
101
+ path: Path,
102
+ lines: list[str],
103
+ *,
104
+ parent_class: Optional[str],
105
+ chunks: list[Chunk],
106
+ ) -> None:
107
+ for child in node.children:
108
+ # Decorated definitions (@foo\ndef bar(): ...) wrap the real
109
+ # function_definition/class_definition in a decorated_definition
110
+ # node. Unwrap it, but keep the *outer* node's start point so the
111
+ # decorator line(s) are included in the chunk's span — the
112
+ # decorator is semantically part of the unit being chunked.
113
+ target = child
114
+ span_start = child
115
+ if child.type == "decorated_definition":
116
+ inner = child.child_by_field_name("definition")
117
+ if inner is None:
118
+ _walk(child, path, lines, parent_class=parent_class, chunks=chunks)
119
+ continue
120
+ target = inner
121
+ span_start = child
122
+
123
+ if target.type == "class_definition":
124
+ name = _node_name(target)
125
+ chunks.append(
126
+ Chunk(
127
+ path=path,
128
+ kind="class",
129
+ name=name,
130
+ start_line=span_start.start_point[0] + 1,
131
+ end_line=target.end_point[0] + 1,
132
+ parent_class=parent_class,
133
+ docstring=_docstring(target, lines),
134
+ content=_content(span_start, lines),
135
+ )
136
+ )
137
+ # Recurse with this class as the new parent_class, so nested
138
+ # function_definitions/class_definitions inside it are chunked
139
+ # as methods/nested classes respectively.
140
+ _walk(target, path, lines, parent_class=name, chunks=chunks)
141
+
142
+ elif target.type == "function_definition":
143
+ name = _node_name(target)
144
+ chunks.append(
145
+ Chunk(
146
+ path=path,
147
+ kind="method" if parent_class is not None else "function",
148
+ name=name,
149
+ start_line=span_start.start_point[0] + 1,
150
+ end_line=target.end_point[0] + 1,
151
+ parent_class=parent_class,
152
+ docstring=_docstring(target, lines),
153
+ content=_content(span_start, lines),
154
+ )
155
+ )
156
+ # Recurse without a parent_class: a function nested inside
157
+ # another function is chunked as its own top-level-style
158
+ # "function" (v0.1 doesn't track function-in-function nesting).
159
+ _walk(target, path, lines, parent_class=None, chunks=chunks)
160
+
161
+ else:
162
+ _walk(child, path, lines, parent_class=parent_class, chunks=chunks)
163
+
164
+
165
+ def _content(span_start: Node, lines: list[str]) -> str:
166
+ """Exact source text of the chunk's span (decorator lines included, if any).
167
+
168
+ Safe for both plain definitions and decorated_definition wrappers: a
169
+ decorator can only add lines *before* the def/class line, never after,
170
+ so span_start.end_point always matches the inner definition's end.
171
+ """
172
+ start = span_start.start_point[0]
173
+ end = span_start.end_point[0]
174
+ return "\n".join(lines[start : end + 1])
175
+
176
+
177
+ def _node_name(def_node: Node) -> str:
178
+ name_node = def_node.child_by_field_name("name")
179
+ if name_node is None:
180
+ return "<anonymous>"
181
+ return name_node.text.decode("utf-8")
182
+
183
+
184
+ def _docstring(def_node: Node, lines: list[str]) -> Optional[str]:
185
+ """Extract a def/class's own docstring: the first statement in its body,
186
+ if that statement is a bare string expression.
187
+ """
188
+ body = def_node.child_by_field_name("body")
189
+ if body is None or body.child_count == 0:
190
+ return None
191
+
192
+ first_statement = body.children[0]
193
+ if first_statement.type != "expression_statement":
194
+ return None
195
+ if first_statement.child_count == 0:
196
+ return None
197
+
198
+ string_node = first_statement.children[0]
199
+ if string_node.type != "string":
200
+ return None
201
+
202
+ raw = string_node.text.decode("utf-8")
203
+ return _strip_string_literal(raw)
204
+
205
+
206
+ def _strip_string_literal(raw: str) -> str:
207
+ """Strip Python string-literal quoting/prefixes to get the docstring body."""
208
+ text = raw.strip()
209
+ for prefix in ("u", "U", "r", "R", "b", "B", "rb", "Rb", "rB", "RB", "br", "Br", "bR", "BR"):
210
+ if text.startswith(prefix) and text[len(prefix) :].startswith(('"', "'")):
211
+ text = text[len(prefix) :]
212
+ break
213
+ for quote in ('"""', "'''", '"', "'"):
214
+ if text.startswith(quote) and text.endswith(quote) and len(text) >= 2 * len(quote):
215
+ return text[len(quote) : -len(quote)].strip()
216
+ return text.strip()
rylox/cli.py ADDED
@@ -0,0 +1,119 @@
1
+ """Rylox CLI — exactly four commands (spec §9). No fifth command in v0.1.
2
+
3
+ Phase 1 goal: lock in the *interface* (command names, options, exit codes,
4
+ error presentation) before any real logic exists. Every command below is a
5
+ stub that returns a clearly-labeled "not implemented" result rather than
6
+ doing nothing silently — that way `rylox --help` and each subcommand's
7
+ `--help` are already correct and testable, and later phases fill bodies in
8
+ without touching signatures.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from pathlib import Path
14
+ from typing import Optional
15
+
16
+ import typer
17
+
18
+ from rylox import __version__
19
+ from rylox.errors import RyloxError
20
+
21
+ app = typer.Typer(
22
+ name="rylox",
23
+ help=(
24
+ "Local repository context engine — turns a repo + task into a "
25
+ "token-budgeted markdown package."
26
+ ),
27
+ add_completion=False,
28
+ no_args_is_help=True,
29
+ )
30
+
31
+
32
+ def _version_callback(value: bool) -> None:
33
+ if value:
34
+ typer.echo(f"rylox {__version__}")
35
+ raise typer.Exit()
36
+
37
+
38
+ @app.callback()
39
+ def main(
40
+ version: Optional[bool] = typer.Option(
41
+ None,
42
+ "--version",
43
+ callback=_version_callback,
44
+ is_eager=True,
45
+ help="Show the Rylox version and exit.",
46
+ ),
47
+ ) -> None:
48
+ """Rylox: build the best local repository context assembler."""
49
+
50
+
51
+ @app.command()
52
+ def index(
53
+ repo: Path = typer.Option(
54
+ Path("."), "--repo", exists=True, file_okay=False, help="Repository root to index."
55
+ ),
56
+ ) -> None:
57
+ """Build or incrementally update the local `.rylox/` index."""
58
+ _not_implemented("index")
59
+
60
+
61
+ @app.command()
62
+ def context(
63
+ task: str = typer.Argument(..., help="The task/question to build context for."),
64
+ max_tokens: int = typer.Option(
65
+ 16000, "--max-tokens", min=1, help="Hard token budget for the assembled context."
66
+ ),
67
+ output_format: str = typer.Option(
68
+ "markdown",
69
+ "--format",
70
+ help="Output format. Only 'markdown' is supported in v0.1.",
71
+ ),
72
+ repo: Path = typer.Option(
73
+ Path("."), "--repo", exists=True, file_okay=False, help="Repository root."
74
+ ),
75
+ ) -> None:
76
+ """Assemble a token-budgeted, relationship-aware context package for TASK."""
77
+ if output_format != "markdown":
78
+ raise typer.BadParameter("Only --format markdown is supported in v0.1.")
79
+ _not_implemented("context")
80
+
81
+
82
+ @app.command()
83
+ def clean(
84
+ repo: Path = typer.Option(
85
+ Path("."), "--repo", exists=True, file_okay=False, help="Repository root."
86
+ ),
87
+ ) -> None:
88
+ """Delete the `.rylox/` cache and start fresh."""
89
+ _not_implemented("clean")
90
+
91
+
92
+ @app.command()
93
+ def doctor() -> None:
94
+ """Run environment/health checks and report each pass/fail individually."""
95
+ _not_implemented("doctor")
96
+
97
+
98
+ def _not_implemented(command: str) -> None:
99
+ """Placeholder body for Phase 1 stubs.
100
+
101
+ Deliberately visible and non-zero-exit rather than a silent no-op, so
102
+ running any command today tells you honestly that it's unbuilt instead
103
+ of pretending to succeed.
104
+ """
105
+ typer.echo(f"rylox {command}: not implemented yet (Phase 1 skeleton)", err=True)
106
+ raise typer.Exit(code=1)
107
+
108
+
109
+ def run() -> None:
110
+ """Entry point wrapper: turn RyloxError into a clean message + exit code."""
111
+ try:
112
+ app()
113
+ except RyloxError as err:
114
+ typer.echo(f"error: {err.message}", err=True)
115
+ raise typer.Exit(code=err.exit_code) from err
116
+
117
+
118
+ if __name__ == "__main__":
119
+ run()