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 +57 -0
- pyinc/core.py +92 -0
- pyinc/errors.py +21 -0
- pyinc/explain.py +41 -0
- pyinc/integrations/__init__.py +183 -0
- pyinc/integrations/csv_data.py +246 -0
- pyinc/integrations/deep_module_resolution.py +572 -0
- pyinc/integrations/dependency_check.py +248 -0
- pyinc/integrations/env_file.py +236 -0
- pyinc/integrations/installed_packages.py +375 -0
- pyinc/integrations/json_config.py +244 -0
- pyinc/integrations/python_source.py +952 -0
- pyinc/integrations/requirement_evaluation.py +1051 -0
- pyinc/integrations/requirements_txt.py +490 -0
- pyinc/integrations/symbol_resolution.py +975 -0
- pyinc/integrations/toml_config.py +283 -0
- pyinc/integrations/xml_config.py +289 -0
- pyinc/py.typed +1 -0
- pyinc/resources.py +135 -0
- pyinc/runtime.py +1126 -0
- pyinc/value.py +406 -0
- pyinc-1.0.0.dist-info/METADATA +150 -0
- pyinc-1.0.0.dist-info/RECORD +25 -0
- pyinc-1.0.0.dist-info/WHEEL +4 -0
- pyinc-1.0.0.dist-info/licenses/LICENSE +201 -0
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
|
+
]
|