pyinc 1.0.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.
pyinc/__init__.py ADDED
@@ -0,0 +1,57 @@
1
+ from .core import Input, query
2
+ from .errors import (
3
+ CycleError,
4
+ MutationError,
5
+ PyIncError,
6
+ UnsupportedValueError,
7
+ UntrackedReadError,
8
+ )
9
+ from .explain import InspectionNode
10
+ from .resources import (
11
+ DirectoryResource,
12
+ EnvResource,
13
+ FileResource,
14
+ FileStatResource,
15
+ FileStatSnapshot,
16
+ )
17
+ from .runtime import Database, DatabaseStatistics, DependencyGraphNode, QueryProfile
18
+ from .value import (
19
+ FrozenAdapterValue,
20
+ FrozenDict,
21
+ FrozenList,
22
+ FrozenRecord,
23
+ FrozenSet,
24
+ ValueAdapter,
25
+ freeze,
26
+ semantic_equal,
27
+ thaw,
28
+ )
29
+
30
+ __all__ = [
31
+ "CycleError",
32
+ "Database",
33
+ "DatabaseStatistics",
34
+ "DependencyGraphNode",
35
+ "DirectoryResource",
36
+ "EnvResource",
37
+ "FileResource",
38
+ "FileStatResource",
39
+ "FileStatSnapshot",
40
+ "FrozenAdapterValue",
41
+ "FrozenDict",
42
+ "FrozenList",
43
+ "FrozenRecord",
44
+ "FrozenSet",
45
+ "Input",
46
+ "InspectionNode",
47
+ "MutationError",
48
+ "PyIncError",
49
+ "QueryProfile",
50
+ "UnsupportedValueError",
51
+ "UntrackedReadError",
52
+ "ValueAdapter",
53
+ "freeze",
54
+ "query",
55
+ "semantic_equal",
56
+ "thaw",
57
+ ]
pyinc/core.py ADDED
@@ -0,0 +1,92 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable
4
+ from dataclasses import dataclass
5
+ from functools import wraps
6
+ from typing import TYPE_CHECKING, Any, Concatenate, Generic, ParamSpec, TypeVar, overload
7
+
8
+ from .value import semantic_equal
9
+
10
+ if TYPE_CHECKING:
11
+ from .runtime import Database
12
+
13
+ P = ParamSpec("P")
14
+ T = TypeVar("T")
15
+ EqFn = Callable[[Any, Any], bool]
16
+ CutoffFn = Callable[[Any], Any]
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class Input(Generic[T]):
21
+ name: str
22
+ eq: EqFn | None = None
23
+ cutoff: CutoffFn | None = None
24
+
25
+ def __post_init__(self) -> None:
26
+ if self.eq is not None and self.cutoff is not None:
27
+ raise ValueError("Input() accepts either eq= or cutoff=, but not both.")
28
+
29
+ def read(self, db: Database) -> T:
30
+ return db._read_input(self)
31
+
32
+
33
+ class Query(Generic[P, T]):
34
+ def __init__(
35
+ self,
36
+ fn: Callable[Concatenate[Database, P], T],
37
+ *,
38
+ eq: EqFn | None = None,
39
+ cutoff: CutoffFn | None = None,
40
+ ) -> None:
41
+ if eq is not None and cutoff is not None:
42
+ raise ValueError("@query accepts either eq= or cutoff=, but not both.")
43
+ self.fn = fn
44
+ self.eq = eq
45
+ self.cutoff = cutoff
46
+ self.query_id = f"{fn.__module__}:{fn.__qualname__}"
47
+ self.__name__ = fn.__name__
48
+ self.__doc__ = fn.__doc__
49
+ self.__module__ = fn.__module__
50
+ self.__wrapped__ = fn
51
+ wraps(fn)(self)
52
+
53
+ def __call__(self, db: Database, *args: P.args, **kwargs: P.kwargs) -> T:
54
+ return db.get(self, *args, **kwargs)
55
+
56
+ def compare(self, old_value: Any, new_value: Any) -> bool:
57
+ if self.cutoff is not None:
58
+ return semantic_equal(self.cutoff(old_value), self.cutoff(new_value))
59
+ comparator = self.eq or semantic_equal
60
+ return comparator(old_value, new_value)
61
+
62
+
63
+ @overload
64
+ def query(
65
+ fn: Callable[Concatenate[Database, P], T],
66
+ *,
67
+ eq: EqFn | None = None,
68
+ cutoff: CutoffFn | None = None,
69
+ ) -> Query[P, T]: ...
70
+
71
+
72
+ @overload
73
+ def query(
74
+ fn: None = None,
75
+ *,
76
+ eq: EqFn | None = None,
77
+ cutoff: CutoffFn | None = None,
78
+ ) -> Callable[[Callable[Concatenate[Database, P], T]], Query[P, T]]: ...
79
+
80
+
81
+ def query(
82
+ fn: Callable[Concatenate[Database, P], T] | None = None,
83
+ *,
84
+ eq: EqFn | None = None,
85
+ cutoff: CutoffFn | None = None,
86
+ ) -> Query[P, T] | Callable[[Callable[Concatenate[Database, P], T]], Query[P, T]]:
87
+ def decorate(wrapped: Callable[Concatenate[Database, P], T]) -> Query[P, T]:
88
+ return Query(wrapped, eq=eq, cutoff=cutoff)
89
+
90
+ if fn is None:
91
+ return decorate
92
+ return decorate(fn)
pyinc/errors.py ADDED
@@ -0,0 +1,21 @@
1
+ from __future__ import annotations
2
+
3
+
4
+ class PyIncError(Exception):
5
+ """Base error for pyinc."""
6
+
7
+
8
+ class MutationError(PyIncError):
9
+ """Raised when a query mutates one of its boundary inputs."""
10
+
11
+
12
+ class UntrackedReadError(PyIncError):
13
+ """Raised when code performs an undeclared external read."""
14
+
15
+
16
+ class UnsupportedValueError(PyIncError):
17
+ """Raised when a value cannot cross a cached boundary safely."""
18
+
19
+
20
+ class CycleError(PyIncError):
21
+ """Raised when query evaluation encounters a dependency cycle."""
pyinc/explain.py ADDED
@@ -0,0 +1,41 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+
6
+ @dataclass(frozen=True)
7
+ class InspectionNode:
8
+ label: str
9
+ kind: str
10
+ changed_at: int
11
+ verified_at: int
12
+ last_decision: str
13
+ last_recompute: str
14
+ reason: str
15
+ untracked_reasons: tuple[str, ...] = ()
16
+ dependencies: tuple[InspectionNode, ...] = ()
17
+
18
+ @property
19
+ def is_untracked(self) -> bool:
20
+ return bool(self.untracked_reasons)
21
+
22
+
23
+ def format_explanation(root: InspectionNode) -> str:
24
+ lines: list[str] = []
25
+
26
+ def walk(current: InspectionNode, depth: int) -> None:
27
+ indent = " " * depth
28
+ lines.append(
29
+ f"{indent}- {current.label}: {current.last_decision}"
30
+ f" [last_recompute={current.last_recompute}]"
31
+ f" (changed_at={current.changed_at}, verified_at={current.verified_at})"
32
+ )
33
+ if current.reason:
34
+ lines.append(f"{indent} reason: {current.reason}")
35
+ for item in current.untracked_reasons:
36
+ lines.append(f"{indent} untracked: {item}")
37
+ for dependency in current.dependencies:
38
+ walk(dependency, depth + 1)
39
+
40
+ walk(root, 0)
41
+ return "\n".join(lines)
@@ -0,0 +1,183 @@
1
+ from .csv_data import (
2
+ CsvAnalysis,
3
+ CsvColumn,
4
+ csv_analysis,
5
+ workspace_csv_analysis,
6
+ )
7
+ from .deep_module_resolution import (
8
+ DeepModuleResolutionAnalysis,
9
+ ModulePathEntry,
10
+ NamespacePackage,
11
+ PthDirective,
12
+ ResolvedModuleLocation,
13
+ deep_module_resolution_analysis,
14
+ resolve_module_path,
15
+ )
16
+ from .dependency_check import (
17
+ DependencyCheckAnalysis,
18
+ DependencyStatus,
19
+ UndeclaredImport,
20
+ dependency_check_analysis,
21
+ workspace_dependency_check,
22
+ )
23
+ from .env_file import (
24
+ EnvEntry,
25
+ EnvFileAnalysis,
26
+ env_analysis,
27
+ workspace_env_analysis,
28
+ )
29
+ from .installed_packages import (
30
+ ImportNameResolution,
31
+ InstalledPackageRef,
32
+ InstalledPackagesAnalysis,
33
+ installed_packages_analysis,
34
+ resolve_import_name,
35
+ )
36
+ from .json_config import (
37
+ JsonAnalysis,
38
+ JsonKey,
39
+ JsonSection,
40
+ json_analysis,
41
+ workspace_json_analysis,
42
+ )
43
+ from .python_source import (
44
+ DefinitionRef,
45
+ DependencySurface,
46
+ Diagnostic,
47
+ ImportRef,
48
+ PythonFileAnalysis,
49
+ PythonModuleAnalysis,
50
+ PythonWorkspaceAnalysis,
51
+ ResolvedImportRef,
52
+ directory_analysis,
53
+ file_analysis,
54
+ module_analysis,
55
+ workspace_analysis,
56
+ )
57
+ from .requirement_evaluation import (
58
+ ApplicableRequirement,
59
+ ApplicableRequirementsAnalysis,
60
+ MarkerEvaluation,
61
+ PythonEnvironmentSnapshot,
62
+ VersionSpecifierEvaluation,
63
+ applicable_requirements,
64
+ evaluate_markers,
65
+ evaluate_version_specifier,
66
+ workspace_applicable_requirements,
67
+ )
68
+ from .requirements_txt import (
69
+ FileReference,
70
+ IndexDirective,
71
+ RequirementRef,
72
+ RequirementsAnalysis,
73
+ deep_requirements_analysis,
74
+ requirements_analysis,
75
+ workspace_requirements_analysis,
76
+ )
77
+ from .symbol_resolution import (
78
+ ModuleSymbolTable,
79
+ Parameter,
80
+ ResolvedSymbol,
81
+ Signature,
82
+ Symbol,
83
+ WorkspaceSymbolEntry,
84
+ WorkspaceSymbolIndex,
85
+ module_symbol_table,
86
+ resolve_symbol,
87
+ workspace_symbol_index,
88
+ )
89
+ from .toml_config import (
90
+ ConfigAnalysis,
91
+ ConfigKey,
92
+ ConfigSection,
93
+ config_analysis,
94
+ workspace_config_analysis,
95
+ )
96
+ from .xml_config import (
97
+ XmlAnalysis,
98
+ XmlAttribute,
99
+ XmlElement,
100
+ workspace_xml_analysis,
101
+ xml_analysis,
102
+ )
103
+
104
+ __all__ = [
105
+ "ApplicableRequirement",
106
+ "ApplicableRequirementsAnalysis",
107
+ "ConfigAnalysis",
108
+ "ConfigKey",
109
+ "ConfigSection",
110
+ "CsvAnalysis",
111
+ "CsvColumn",
112
+ "DeepModuleResolutionAnalysis",
113
+ "DefinitionRef",
114
+ "DependencyCheckAnalysis",
115
+ "DependencyStatus",
116
+ "DependencySurface",
117
+ "Diagnostic",
118
+ "EnvEntry",
119
+ "EnvFileAnalysis",
120
+ "FileReference",
121
+ "ImportNameResolution",
122
+ "ImportRef",
123
+ "IndexDirective",
124
+ "InstalledPackageRef",
125
+ "InstalledPackagesAnalysis",
126
+ "JsonAnalysis",
127
+ "JsonKey",
128
+ "JsonSection",
129
+ "MarkerEvaluation",
130
+ "ModulePathEntry",
131
+ "ModuleSymbolTable",
132
+ "NamespacePackage",
133
+ "Parameter",
134
+ "PthDirective",
135
+ "PythonEnvironmentSnapshot",
136
+ "PythonFileAnalysis",
137
+ "PythonModuleAnalysis",
138
+ "PythonWorkspaceAnalysis",
139
+ "RequirementRef",
140
+ "RequirementsAnalysis",
141
+ "ResolvedImportRef",
142
+ "ResolvedModuleLocation",
143
+ "ResolvedSymbol",
144
+ "Signature",
145
+ "Symbol",
146
+ "UndeclaredImport",
147
+ "VersionSpecifierEvaluation",
148
+ "WorkspaceSymbolEntry",
149
+ "WorkspaceSymbolIndex",
150
+ "XmlAnalysis",
151
+ "XmlAttribute",
152
+ "XmlElement",
153
+ "applicable_requirements",
154
+ "config_analysis",
155
+ "csv_analysis",
156
+ "deep_module_resolution_analysis",
157
+ "deep_requirements_analysis",
158
+ "dependency_check_analysis",
159
+ "directory_analysis",
160
+ "env_analysis",
161
+ "evaluate_markers",
162
+ "evaluate_version_specifier",
163
+ "file_analysis",
164
+ "installed_packages_analysis",
165
+ "json_analysis",
166
+ "module_analysis",
167
+ "module_symbol_table",
168
+ "requirements_analysis",
169
+ "resolve_import_name",
170
+ "resolve_module_path",
171
+ "resolve_symbol",
172
+ "workspace_analysis",
173
+ "workspace_applicable_requirements",
174
+ "workspace_config_analysis",
175
+ "workspace_csv_analysis",
176
+ "workspace_dependency_check",
177
+ "workspace_env_analysis",
178
+ "workspace_json_analysis",
179
+ "workspace_requirements_analysis",
180
+ "workspace_symbol_index",
181
+ "workspace_xml_analysis",
182
+ "xml_analysis",
183
+ ]
@@ -0,0 +1,246 @@
1
+ from __future__ import annotations
2
+
3
+ import csv
4
+ import hashlib
5
+ import io
6
+ import os
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+ from typing import TypeAlias, cast
10
+
11
+ from pyinc.core import query
12
+ from pyinc.resources import DirectoryResource, _file_read_snapshot
13
+ from pyinc.runtime import Database
14
+ from pyinc.value import freeze, thaw
15
+
16
+ CsvColumnPayload: TypeAlias = tuple[str, int]
17
+ DiagnosticPayload: TypeAlias = tuple[str, str]
18
+ CsvMetaPayload: TypeAlias = tuple[int, str, bool]
19
+ CsvAnalysisPayload: TypeAlias = tuple[
20
+ str, # path
21
+ tuple[CsvColumnPayload, ...], # columns
22
+ int, # row_count
23
+ str, # delimiter
24
+ bool, # has_header
25
+ tuple[DiagnosticPayload, ...], # diagnostics
26
+ ]
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class CsvColumn:
31
+ name: str
32
+ index: int
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class CsvAnalysis:
37
+ path: str
38
+ columns: tuple[CsvColumn, ...]
39
+ row_count: int
40
+ delimiter: str
41
+ has_header: bool
42
+ diagnostics: tuple[tuple[str, str], ...]
43
+
44
+
45
+ # ---------------------------------------------------------------------------
46
+ # Resources
47
+ # ---------------------------------------------------------------------------
48
+
49
+
50
+ @dataclass(frozen=True)
51
+ class _CsvFileResource:
52
+ encoding: str = "utf-8"
53
+
54
+ def read(self, db: Database, path: str | os.PathLike[str]) -> str:
55
+ return cast(str, db._read_resource(self, os.fspath(path)))
56
+
57
+ def label(self, path: str) -> str:
58
+ return f"csvfile[{path}]"
59
+
60
+ def probe(self, path: str) -> tuple[str, str] | tuple[str]:
61
+ file_path = Path(path)
62
+ if not file_path.exists():
63
+ return ("missing",)
64
+ return ("present", hashlib.sha256(file_path.read_bytes()).hexdigest())
65
+
66
+ def load(self, db: Database, path: str) -> str:
67
+ file_path = Path(path)
68
+ if not file_path.exists():
69
+ return ""
70
+ with db._allow_raw_open():
71
+ return file_path.read_text(encoding=self.encoding)
72
+
73
+ def probe_and_load(
74
+ self, db: Database, path: str
75
+ ) -> tuple[tuple[str, str] | tuple[str], str]:
76
+ probe, text = _file_read_snapshot(path, self.encoding)
77
+ return probe, text if text is not None else ""
78
+
79
+
80
+ _FILES = _CsvFileResource()
81
+ _DIRECTORIES = DirectoryResource()
82
+
83
+
84
+ # ---------------------------------------------------------------------------
85
+ # Helpers
86
+ # ---------------------------------------------------------------------------
87
+
88
+
89
+ def _detect_delimiter(text: str) -> str:
90
+ try:
91
+ dialect = csv.Sniffer().sniff(text[:8192])
92
+ return dialect.delimiter
93
+ except csv.Error:
94
+ return ","
95
+
96
+
97
+ def _detect_has_header(text: str) -> bool:
98
+ try:
99
+ return csv.Sniffer().has_header(text[:8192])
100
+ except csv.Error:
101
+ return False
102
+
103
+
104
+ def _parse_csv(
105
+ text: str,
106
+ ) -> tuple[list[CsvColumnPayload], int, str, bool, list[DiagnosticPayload]]:
107
+ stripped = text.strip()
108
+ if not stripped:
109
+ return [], 0, ",", False, []
110
+
111
+ delimiter = _detect_delimiter(stripped)
112
+ has_header = _detect_has_header(stripped)
113
+
114
+ reader = csv.reader(io.StringIO(stripped), delimiter=delimiter)
115
+ rows = list(reader)
116
+
117
+ if not rows:
118
+ return [], 0, delimiter, has_header, []
119
+
120
+ diagnostics: list[DiagnosticPayload] = []
121
+
122
+ if has_header:
123
+ header_row = rows[0]
124
+ data_rows = rows[1:]
125
+ columns: list[CsvColumnPayload] = [
126
+ (name, idx) for idx, name in enumerate(header_row)
127
+ ]
128
+ else:
129
+ data_rows = rows
130
+ col_count = len(rows[0]) if rows else 0
131
+ columns = [(f"column_{idx}", idx) for idx in range(col_count)]
132
+
133
+ expected_cols = len(columns)
134
+ for row_idx, row in enumerate(data_rows):
135
+ if len(row) != expected_cols:
136
+ diagnostics.append(
137
+ (
138
+ "inconsistent-columns",
139
+ f"row {row_idx + 1 + (1 if has_header else 0)}: "
140
+ f"expected {expected_cols} columns, got {len(row)}",
141
+ )
142
+ )
143
+
144
+ row_count = len(data_rows)
145
+ return columns, row_count, delimiter, has_header, diagnostics
146
+
147
+
148
+ def _csv_cutoff_token(text: str) -> tuple[str, str]:
149
+ columns, row_count, delimiter, has_header, _ = _parse_csv(text)
150
+ snapshot = freeze((columns, row_count, delimiter, has_header))
151
+ return ("parsed", repr(snapshot))
152
+
153
+
154
+ # ---------------------------------------------------------------------------
155
+ # Layer 1 — Payload queries
156
+ # ---------------------------------------------------------------------------
157
+
158
+
159
+ @query(cutoff=_csv_cutoff_token)
160
+ def csv_file_text(db: Database, path: str) -> str:
161
+ return _FILES.read(db, path)
162
+
163
+
164
+ @query
165
+ def csv_columns_payload(db: Database, path: str) -> tuple[CsvColumnPayload, ...]:
166
+ text = csv_file_text(db, path)
167
+ if not text.strip():
168
+ return ()
169
+ columns, _, _, _, _ = _parse_csv(text)
170
+ return tuple(columns)
171
+
172
+
173
+ @query
174
+ def csv_meta_payload(db: Database, path: str) -> CsvMetaPayload:
175
+ text = csv_file_text(db, path)
176
+ if not text.strip():
177
+ return (0, ",", False)
178
+ _, row_count, delimiter, has_header, _ = _parse_csv(text)
179
+ return (row_count, delimiter, has_header)
180
+
181
+
182
+ @query
183
+ def csv_diagnostics_payload(db: Database, path: str) -> tuple[DiagnosticPayload, ...]:
184
+ text = csv_file_text(db, path)
185
+ if not text.strip():
186
+ return ()
187
+ _, _, _, _, diagnostics = _parse_csv(text)
188
+ return tuple(diagnostics)
189
+
190
+
191
+ # ---------------------------------------------------------------------------
192
+ # Layer 2 — Composition
193
+ # ---------------------------------------------------------------------------
194
+
195
+
196
+ @query
197
+ def csv_analysis_payload(db: Database, path: str) -> CsvAnalysisPayload:
198
+ columns = csv_columns_payload(db, path)
199
+ meta = csv_meta_payload(db, path)
200
+ diagnostics = csv_diagnostics_payload(db, path)
201
+ row_count, delimiter, has_header = meta
202
+ return (path, columns, row_count, delimiter, has_header, diagnostics)
203
+
204
+
205
+ # ---------------------------------------------------------------------------
206
+ # Layer 3 — Entrypoints
207
+ # ---------------------------------------------------------------------------
208
+
209
+
210
+ def csv_analysis(db: Database, path: str | os.PathLike[str]) -> CsvAnalysis:
211
+ normalized = os.fspath(path)
212
+ payload = cast(CsvAnalysisPayload, thaw(db.get(csv_analysis_payload, normalized)))
213
+ path_str, columns, row_count, delimiter, has_header, diagnostics = payload
214
+ return CsvAnalysis(
215
+ path=path_str,
216
+ columns=tuple(CsvColumn(name=c[0], index=c[1]) for c in columns),
217
+ row_count=row_count,
218
+ delimiter=delimiter,
219
+ has_header=has_header,
220
+ diagnostics=diagnostics,
221
+ )
222
+
223
+
224
+ def workspace_csv_analysis(
225
+ db: Database,
226
+ root: str | os.PathLike[str],
227
+ filename: str = "data.csv",
228
+ ) -> CsvAnalysis | None:
229
+ normalized_root = os.fspath(root)
230
+ dir_entries = _DIRECTORIES.read(db, normalized_root)
231
+ csv_path = None
232
+ for name in dir_entries:
233
+ if name == filename:
234
+ csv_path = str(Path(normalized_root) / name)
235
+ break
236
+ if csv_path is None:
237
+ return None
238
+ return csv_analysis(db, csv_path)
239
+
240
+
241
+ __all__ = [
242
+ "CsvColumn",
243
+ "CsvAnalysis",
244
+ "csv_analysis",
245
+ "workspace_csv_analysis",
246
+ ]