shiftory 0.1.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.
shiftory/__init__.py ADDED
@@ -0,0 +1,17 @@
1
+ """Shiftory's typed public API."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ from shiftory.evidence.builder import analyze
6
+ from shiftory.explain.validator import validate_explanation
7
+ from shiftory.git.repository import resolve_comparison, resolve_repository
8
+ from shiftory.render.report import render_report
9
+
10
+ __all__ = [
11
+ "__version__",
12
+ "analyze",
13
+ "render_report",
14
+ "resolve_comparison",
15
+ "resolve_repository",
16
+ "validate_explanation",
17
+ ]
@@ -0,0 +1,3 @@
1
+ from shiftory.cache.store import CacheStore, default_cache_root, repository_cache_path
2
+
3
+ __all__ = ["CacheStore", "default_cache_root", "repository_cache_path"]
@@ -0,0 +1,152 @@
1
+ """Repository-scoped, atomic local cache primitives."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import fcntl
7
+ import json
8
+ import os
9
+ import shutil
10
+ import uuid
11
+ from collections.abc import Iterator
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ from platformdirs import user_cache_path
16
+
17
+ from shiftory.errors import CacheError
18
+ from shiftory.models.json import canonical_json
19
+
20
+ CACHE_SCHEMA = "shiftory.cache/v1"
21
+
22
+
23
+ def default_cache_root() -> Path:
24
+ configured = os.environ.get("SHIFTORY_CACHE_DIR")
25
+ return Path(configured).expanduser().resolve() if configured else user_cache_path("shiftory")
26
+
27
+
28
+ def repository_cache_path(repository_id: str, cache_root: Path | None = None) -> Path:
29
+ if len(repository_id) != 64 or any(
30
+ character not in "0123456789abcdef" for character in repository_id
31
+ ):
32
+ raise CacheError("Refusing an invalid repository cache identity")
33
+ root = (cache_root or default_cache_root()).expanduser().resolve()
34
+ return root / "repositories" / repository_id
35
+
36
+
37
+ class CacheStore:
38
+ def __init__(
39
+ self,
40
+ repository_id: str,
41
+ *,
42
+ cache_root: Path | None = None,
43
+ enabled: bool = True,
44
+ ) -> None:
45
+ self.repository_id = repository_id
46
+ self.cache_root = (cache_root or default_cache_root()).expanduser().resolve()
47
+ self.root = repository_cache_path(repository_id, self.cache_root)
48
+ self.enabled = enabled
49
+
50
+ def ensure(self) -> Path:
51
+ self.root.mkdir(parents=True, exist_ok=True, mode=0o700)
52
+ with contextlib.suppress(OSError):
53
+ self.root.chmod(0o700)
54
+ return self.root
55
+
56
+ @contextlib.contextmanager
57
+ def lock(self) -> Iterator[None]:
58
+ if not self.enabled:
59
+ yield
60
+ return
61
+ lock_directory = self.cache_root / ".locks"
62
+ lock_directory.mkdir(parents=True, exist_ok=True, mode=0o700)
63
+ lock_path = lock_directory / f"{self.repository_id}.lock"
64
+ with lock_path.open("a+b") as handle:
65
+ fcntl.flock(handle.fileno(), fcntl.LOCK_EX)
66
+ try:
67
+ yield
68
+ finally:
69
+ fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
70
+
71
+ def read_manifest(self, relative: str) -> dict[str, Any] | None:
72
+ if not self.enabled:
73
+ return None
74
+ path = self._entry_path(relative)
75
+ try:
76
+ value = json.loads(path.read_text(encoding="utf-8"))
77
+ except (OSError, ValueError):
78
+ return None
79
+ if not isinstance(value, dict) or value.get("schema") != CACHE_SCHEMA:
80
+ return None
81
+ return value
82
+
83
+ def atomic_write(self, relative: str, value: dict[str, Any]) -> Path:
84
+ if not self.enabled:
85
+ raise CacheError("Cache is disabled")
86
+ self.ensure()
87
+ path = self._entry_path(relative)
88
+ path.parent.mkdir(parents=True, exist_ok=True)
89
+ temporary = path.with_name(f".{path.name}.{uuid.uuid4().hex}.partial")
90
+ payload = {"schema": CACHE_SCHEMA, **value}
91
+ try:
92
+ temporary.write_text(canonical_json(payload), encoding="utf-8")
93
+ os.chmod(temporary, 0o600)
94
+ with temporary.open("rb") as handle:
95
+ os.fsync(handle.fileno())
96
+ os.replace(temporary, path)
97
+ self._fsync_directory(path.parent)
98
+ finally:
99
+ temporary.unlink(missing_ok=True)
100
+ return path
101
+
102
+ def status(self) -> dict[str, Any]:
103
+ exists = self.root.is_dir()
104
+ files = (
105
+ sorted(
106
+ str(path.relative_to(self.root))
107
+ for path in self.root.rglob("*")
108
+ if path.is_file() and path.name != ".lock"
109
+ )
110
+ if exists
111
+ else []
112
+ )
113
+ return {"path": str(self.root), "exists": exists, "files": files}
114
+
115
+ def clear(self) -> Path:
116
+ root = self.root.resolve()
117
+ repositories = (self.cache_root / "repositories").resolve()
118
+ if (
119
+ root == self.cache_root
120
+ or root.parent != repositories
121
+ or root.name != self.repository_id
122
+ ):
123
+ raise CacheError(f"Refusing to clear unsafe cache path: {root}")
124
+ with self.lock():
125
+ if root.exists():
126
+ shutil.rmtree(root)
127
+ self._fsync_directory(repositories)
128
+ return root
129
+
130
+ def _entry_path(self, relative: str) -> Path:
131
+ candidate = Path(relative)
132
+ if candidate.is_absolute() or not candidate.parts or ".." in candidate.parts:
133
+ raise CacheError(f"Refusing an unsafe cache entry path: {relative!r}")
134
+ path = (self.root / candidate).resolve()
135
+ try:
136
+ path.relative_to(self.root.resolve())
137
+ except ValueError as exc:
138
+ raise CacheError(f"Refusing an unsafe cache entry path: {relative!r}") from exc
139
+ return path
140
+
141
+ @staticmethod
142
+ def _fsync_directory(path: Path) -> None:
143
+ try:
144
+ descriptor = os.open(path, os.O_RDONLY)
145
+ except OSError:
146
+ return
147
+ try:
148
+ os.fsync(descriptor)
149
+ except OSError:
150
+ pass
151
+ finally:
152
+ os.close(descriptor)
@@ -0,0 +1,3 @@
1
+ from shiftory.classify.rules import classify_file
2
+
3
+ __all__ = ["classify_file"]
@@ -0,0 +1,107 @@
1
+ """Explicit deterministic evidence-organization rules."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import PurePosixPath
6
+
7
+ from shiftory.models.core import Confidence, FileChange
8
+
9
+ _LOCKFILES = {
10
+ "cargo.lock",
11
+ "package-lock.json",
12
+ "pnpm-lock.yaml",
13
+ "poetry.lock",
14
+ "uv.lock",
15
+ "yarn.lock",
16
+ }
17
+ _DEPENDENCIES = {
18
+ "cargo.toml",
19
+ "package.json",
20
+ "pyproject.toml",
21
+ "requirements.txt",
22
+ "go.mod",
23
+ "go.sum",
24
+ }
25
+ _CONFIG_NAMES = {
26
+ ".editorconfig",
27
+ ".gitattributes",
28
+ ".gitignore",
29
+ "compose.yaml",
30
+ "compose.yml",
31
+ "dockerfile",
32
+ "makefile",
33
+ "tox.ini",
34
+ }
35
+ _SOURCE_SUFFIXES = {
36
+ ".c",
37
+ ".cc",
38
+ ".cpp",
39
+ ".go",
40
+ ".java",
41
+ ".js",
42
+ ".jsx",
43
+ ".php",
44
+ ".py",
45
+ ".rb",
46
+ ".rs",
47
+ ".ts",
48
+ ".tsx",
49
+ }
50
+
51
+
52
+ def _is_formatting_only(file: FileChange) -> bool:
53
+ before = [line.content for hunk in file.hunks for line in hunk.lines if line.side == "before"]
54
+ after = [line.content for hunk in file.hunks for line in hunk.lines if line.side == "after"]
55
+ if not before or not after:
56
+ return False
57
+ raw_before = "\n".join(before)
58
+ raw_after = "\n".join(after)
59
+ return raw_before != raw_after and "".join(raw_before.split()) == "".join(raw_after.split())
60
+
61
+
62
+ def classify_file(file: FileChange) -> tuple[str, Confidence]:
63
+ path = PurePosixPath(file.new_path or file.old_path or "")
64
+ name = path.name.lower()
65
+ parts = {part.lower() for part in path.parts}
66
+ suffix = path.suffix.lower()
67
+ kinds = {unit.kind for unit in file.units}
68
+ if "unsupported" in kinds:
69
+ return "unsupported", "extracted"
70
+ if "binary" in kinds:
71
+ return "binary", "extracted"
72
+ if "rename" in kinds:
73
+ return "rename", "extracted"
74
+ if file.status == "added":
75
+ return "added", "extracted"
76
+ if file.status == "deleted":
77
+ return "deleted", "extracted"
78
+ if kinds == {"mode"}:
79
+ return "mode", "extracted"
80
+ if not file.hunks:
81
+ return "structural", "extracted"
82
+ if name in _LOCKFILES or name in _DEPENDENCIES:
83
+ return "dependency", "extracted"
84
+ if "generated" in parts or name.endswith((".min.js", ".min.css")):
85
+ return "generated", "inferred"
86
+ if (
87
+ suffix == ".schema"
88
+ or "schema" in parts
89
+ or ("schema" in name and suffix in {".json", ".yaml", ".yml", ".graphql"})
90
+ ):
91
+ return "schema", "inferred"
92
+ if (
93
+ name in _CONFIG_NAMES
94
+ or ".github" in parts
95
+ or suffix in {".ini", ".toml", ".yaml", ".yml"}
96
+ or (name.startswith(".") and name.endswith("rc"))
97
+ ):
98
+ return "configuration", "inferred"
99
+ if "test" in parts or "tests" in parts or name.startswith("test_") or ".test." in name:
100
+ return "tests", "inferred"
101
+ if "docs" in parts or suffix in {".md", ".rst", ".adoc"}:
102
+ return "docs", "inferred"
103
+ if _is_formatting_only(file):
104
+ return "formatting", "inferred"
105
+ if suffix in _SOURCE_SUFFIXES:
106
+ return "behavioral", "inferred"
107
+ return "unresolved", "unresolved"