chronicle-devkit 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.
Files changed (61) hide show
  1. chronicle/__init__.py +1 -0
  2. chronicle/ai/__init__.py +0 -0
  3. chronicle/ai/base.py +7 -0
  4. chronicle/ai/factory.py +23 -0
  5. chronicle/ai/google_ai.py +14 -0
  6. chronicle/ai/openai.py +14 -0
  7. chronicle/analysis/__init__.py +0 -0
  8. chronicle/analysis/analyzers/__init__.py +0 -0
  9. chronicle/analysis/analyzers/base.py +15 -0
  10. chronicle/analysis/analyzers/django_migrations.py +74 -0
  11. chronicle/analysis/analyzers/git.py +37 -0
  12. chronicle/analysis/context.py +12 -0
  13. chronicle/analysis/engine.py +18 -0
  14. chronicle/cli/__init__.py +1 -0
  15. chronicle/cli/commands/__init__.py +0 -0
  16. chronicle/cli/commands/analyze.py +73 -0
  17. chronicle/cli/commands/config.py +79 -0
  18. chronicle/cli/commands/init.py +47 -0
  19. chronicle/cli/commands/interpret.py +48 -0
  20. chronicle/cli/commands/scan.py +87 -0
  21. chronicle/cli/commands/show.py +106 -0
  22. chronicle/cli/commands/status.py +26 -0
  23. chronicle/cli/commands/version.py +6 -0
  24. chronicle/cli/main.py +30 -0
  25. chronicle/config/__init__.py +0 -0
  26. chronicle/config/credentials.py +17 -0
  27. chronicle/config/loader.py +21 -0
  28. chronicle/config/manager.py +52 -0
  29. chronicle/integrations/__init__.py +0 -0
  30. chronicle/integrations/git.py +16 -0
  31. chronicle/interpretation/__init__.py +0 -0
  32. chronicle/interpretation/context.py +8 -0
  33. chronicle/interpretation/context_builder.py +147 -0
  34. chronicle/interpretation/interpreter.py +12 -0
  35. chronicle/interpretation/prompts.py +51 -0
  36. chronicle/project/__init__.py +0 -0
  37. chronicle/project/discovery.py +9 -0
  38. chronicle/project/initializer.py +45 -0
  39. chronicle/project/status.py +25 -0
  40. chronicle/scanning/__init__.py +0 -0
  41. chronicle/scanning/context.py +10 -0
  42. chronicle/scanning/engine.py +16 -0
  43. chronicle/scanning/scanners/__init__.py +0 -0
  44. chronicle/scanning/scanners/base.py +17 -0
  45. chronicle/scanning/scanners/django_migrations.py +156 -0
  46. chronicle/scanning/scanners/django_model.py +12 -0
  47. chronicle/scanning/scanners/git.py +94 -0
  48. chronicle/scanning/scanners/git_models.py +6 -0
  49. chronicle/storage/__init__.py +0 -0
  50. chronicle/storage/analysis_state.py +44 -0
  51. chronicle/storage/database.py +9 -0
  52. chronicle/storage/findings.py +53 -0
  53. chronicle/storage/models.py +20 -0
  54. chronicle/storage/observations.py +93 -0
  55. chronicle/storage/scan_state.py +44 -0
  56. chronicle/storage/schema.py +47 -0
  57. chronicle_devkit-0.1.0.dist-info/METADATA +233 -0
  58. chronicle_devkit-0.1.0.dist-info/RECORD +61 -0
  59. chronicle_devkit-0.1.0.dist-info/WHEEL +5 -0
  60. chronicle_devkit-0.1.0.dist-info/entry_points.txt +2 -0
  61. chronicle_devkit-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,156 @@
1
+ from pathlib import Path
2
+ import ast
3
+ from datetime import datetime,timezone
4
+ from .base import Scanner
5
+ from chronicle.scanning.context import ScanContext
6
+ from chronicle.storage.observations import Observation
7
+ from .django_model import MigrationOperation
8
+
9
+ class DjangoMigrationScanner(Scanner):
10
+ def __init__(self, project_root: Path) -> None:
11
+ super().__init__(project_root)
12
+
13
+ def scan(self, contexts: list[ScanContext]) -> list[Observation]:
14
+ observation = []
15
+ last_migration = None
16
+ last_migration_name = None
17
+ last_app = None
18
+ for context in contexts:
19
+ last_migration = context.get_state("django_migrations",".last_migration")
20
+ if last_migration:
21
+ break
22
+ for migration_file in self._find_migrations():
23
+ migration_name = migration_file.stem
24
+ app_label = migration_file.parent.parent.name
25
+ if last_migration:
26
+ last_app , last_migration_name = last_migration.split(":",1,)
27
+ if app_label == last_app:
28
+ if self._migration_num(migration_name) <= self._migration_num(last_migration_name):
29
+ continue
30
+ observation.append(
31
+ self._parse_migration(
32
+ migration_file
33
+ )
34
+ )
35
+
36
+ return observation
37
+
38
+ def _find_migrations(self) -> list[Path]:
39
+ migration_files = []
40
+ for path in self.project_root.rglob(
41
+ "migrations/*.py"
42
+ ):
43
+ if self._is_ignored(path):
44
+ continue
45
+ migration_files.append(path)
46
+ return migration_files
47
+
48
+ def _is_ignored(self, path:Path) -> bool:
49
+ IGNORED_DIRECTORIES = {
50
+ ".git",
51
+ ".venv",
52
+ "venv",
53
+ "env",
54
+ "node_modules",
55
+ "__pycache__",
56
+ }
57
+ return any(part in IGNORED_DIRECTORIES for part in path.parts)
58
+
59
+ def _parse_migration(self,migration_file:Path) -> Observation:
60
+ source = migration_file.read_text(encoding="utf-8")
61
+ migration_name = migration_file.stem
62
+ app_label = migration_file.parent.parent.name
63
+ tree = ast.parse(source)
64
+ dependencies = []
65
+ parsed_operations = []
66
+
67
+ for node in ast.walk(tree):
68
+ if isinstance(node,ast.ClassDef):
69
+ if node.name == "Migration":
70
+ migration_class = node
71
+ for statement in migration_class.body:
72
+ if isinstance(statement,ast.Assign):
73
+ target = statement.targets[0]
74
+ if isinstance(target, ast.Name):
75
+ if target.id == "dependencies":
76
+ dependencies = ast.literal_eval(statement.value)
77
+ elif target.id == "operations":
78
+ if not isinstance(
79
+ statement.value,
80
+ ast.List,
81
+ ):
82
+ continue
83
+
84
+ parsed_operations.extend(
85
+ self.get_operations(
86
+ statement.value
87
+ )
88
+ )
89
+
90
+ return Observation(
91
+ source="django",
92
+ type="migration",
93
+ external_id=f"{app_label}:{migration_name}",
94
+ timestamp=datetime.now(timezone.utc),
95
+ data={
96
+ "app": app_label,
97
+ "name": migration_name,
98
+ "dependencies": dependencies,
99
+ "operations": parsed_operations,
100
+ },
101
+ )
102
+
103
+ def get_operations(
104
+ self,
105
+ operations_node: ast.List,
106
+ ) -> list[MigrationOperation]:
107
+
108
+ operations = []
109
+
110
+ for operation_node in operations_node.elts:
111
+
112
+ if not isinstance(
113
+ operation_node,
114
+ ast.Call,
115
+ ):
116
+ continue
117
+
118
+ if not isinstance(
119
+ operation_node.func,
120
+ ast.Attribute,
121
+ ):
122
+ continue
123
+
124
+ operation_name = operation_node.func.attr
125
+
126
+ model_name = None
127
+ field_name = None
128
+
129
+ for keyword in operation_node.keywords:
130
+
131
+ if keyword.arg == "model_name":
132
+
133
+ model_name = ast.literal_eval(
134
+ keyword.value
135
+ )
136
+
137
+ elif keyword.arg == "name":
138
+
139
+ field_name = ast.literal_eval(
140
+ keyword.value
141
+ )
142
+
143
+ operations.append(
144
+ MigrationOperation(
145
+ operation=operation_name,
146
+ details={
147
+ "model": model_name,
148
+ "field": field_name,
149
+ },
150
+ ).to_dict()
151
+ )
152
+
153
+ return operations
154
+
155
+ def _migration_num(self,migration_name:str) -> int:
156
+ return int(migration_name.split("_",1)[0])
@@ -0,0 +1,12 @@
1
+ from dataclasses import dataclass
2
+
3
+ @dataclass
4
+ class MigrationOperation:
5
+ operation:str
6
+ details:dict
7
+
8
+ def to_dict(self) -> dict:
9
+ return {
10
+ "operation":self.operation,
11
+ "details":self.details
12
+ }
@@ -0,0 +1,94 @@
1
+ import subprocess
2
+ from pathlib import Path
3
+ from datetime import datetime
4
+ from chronicle.integrations.git import GitIntegration
5
+ from ...storage.models import Observation
6
+ from .git_models import FileChange
7
+ from .base import Scanner
8
+ from chronicle.scanning.context import ScanContext
9
+
10
+ class GitScanner(Scanner):
11
+ """Scanner for Git repo history."""
12
+
13
+ def __init__(self, project_root: Path) -> None:
14
+ super().__init__(project_root)
15
+ self.git = GitIntegration(project_root)
16
+
17
+ def scan(self,contexts:list[ScanContext]) -> list[Observation]:
18
+ for context in contexts:
19
+ last_commit = context.get_state("git","last_commit")
20
+ if last_commit:
21
+ break
22
+ try:
23
+ if last_commit:
24
+ output = self.git.run(
25
+ "log",
26
+ f"{last_commit}..HEAD",
27
+ "--format=%H|%P|%aI|%an|%s",
28
+ )
29
+ else:
30
+ output = self.git.run(
31
+ "log",
32
+ "--format=%H|%P|%aI|%an|%s",
33
+ )
34
+ except subprocess.CalledProcessError:
35
+ return []
36
+ observation:list[Observation] = []
37
+ for line in output.splitlines():
38
+ line = line.strip()
39
+ if not line:
40
+ continue
41
+
42
+ commit_hash, parents, timestamp, author, msg = line.split("|",4)
43
+
44
+ parents_hash = parents.split() if parents else []
45
+
46
+ changes = self.get_changed_file(commit_hash=commit_hash)
47
+
48
+ observation.append(Observation(
49
+ source="git",
50
+ type="commit",
51
+ external_id=commit_hash,
52
+ timestamp=datetime.fromisoformat(timestamp),
53
+ data={
54
+ "hash":commit_hash,
55
+ "message":msg,
56
+ "author":author,
57
+ "parents":parents_hash,
58
+ "changes": [
59
+ {
60
+ "path": change.path,
61
+ "status": change.status,
62
+ }
63
+ for change in changes
64
+ ]
65
+ }
66
+ ))
67
+
68
+ return observation
69
+
70
+ def get_changed_file(self,commit_hash:str) -> list[FileChange]:
71
+ changes_observed = self.git.run(
72
+ "diff-tree",
73
+ "--no-commit-id",
74
+ "--name-status",
75
+ "-r",
76
+ commit_hash,
77
+ )
78
+
79
+ changes = []
80
+ for line in changes_observed.splitlines():
81
+ line = line.strip()
82
+ if not line:
83
+ continue
84
+
85
+ status, path = line.split("\t", 1)
86
+
87
+ changes.append(
88
+ FileChange(
89
+ path=path,
90
+ status=status,
91
+ )
92
+ )
93
+
94
+ return changes
@@ -0,0 +1,6 @@
1
+ from dataclasses import dataclass
2
+
3
+ @dataclass
4
+ class FileChange:
5
+ path:str
6
+ status:str
File without changes
@@ -0,0 +1,44 @@
1
+ import sqlite3
2
+ from datetime import datetime, timezone
3
+
4
+ class AnalysisStateRepo:
5
+ def __init__(self,conn:sqlite3.Connection) -> None:
6
+ self.conn = conn
7
+
8
+ def get(self,key:str,analyzer:str) -> str | None:
9
+ row = self.conn.execute(
10
+ """
11
+ SELECT value FROM analysis_state WHERE key=? AND analyzer=?;
12
+ """,
13
+ (key,analyzer),
14
+ ).fetchone()
15
+
16
+ if row is None:
17
+ return None
18
+
19
+ return row["value"]
20
+
21
+ def set(self,key:str,analyzer:str,value:str) -> None:
22
+ self.conn.execute(
23
+ """
24
+ INSERT INTO analysis_state(
25
+ analyzer,
26
+ key,
27
+ value,
28
+ updated_at
29
+ )
30
+ VALUES (?,?,?,?)
31
+
32
+ ON CONFLICT(analyzer,key)
33
+ DO UPDATE SET
34
+ value = excluded.value,
35
+ updated_at = excluded.updated_at
36
+ """,
37
+ (
38
+ analyzer,
39
+ key,
40
+ value,
41
+ datetime.now(timezone.utc).isoformat(),
42
+ ),
43
+ )
44
+
@@ -0,0 +1,9 @@
1
+ import sqlite3
2
+ from pathlib import Path
3
+
4
+ def connect(database_path:Path) -> sqlite3.Connection:
5
+ connection = sqlite3.connect(database_path)
6
+ connection.row_factory = sqlite3.Row
7
+ cursor = connection.cursor()
8
+ cursor.execute("PRAGMA foreign_keys = ON;")
9
+ return connection
@@ -0,0 +1,53 @@
1
+ import json
2
+ import sqlite3
3
+ from chronicle.storage.models import Findings
4
+
5
+ class FindingsRepo:
6
+ def __init__(self,conn:sqlite3.Connection) -> None:
7
+ self.conn = conn
8
+
9
+ def save(self, finding:Findings) -> bool:
10
+ cursor = self.conn.execute(
11
+ """
12
+ INSERT INTO
13
+ findings(
14
+ analyzer,
15
+ severity,
16
+ title,
17
+ message,
18
+ observation_id,
19
+ data
20
+ ) VALUES (?,?,?,?,?,?)
21
+ """,
22
+ (
23
+ finding.analyzer,
24
+ finding.severity,
25
+ finding.title,
26
+ finding.message,
27
+ finding.observation_id,
28
+ json.dumps(finding.data),
29
+ ),
30
+ )
31
+
32
+ return cursor.rowcount == 1
33
+
34
+ def list_all(self) -> list[Findings]:
35
+ findings = []
36
+
37
+ rows = self.conn.execute(
38
+ """
39
+ SELECT * FROM findings ORDER BY observation_id;
40
+ """
41
+ ).fetchall()
42
+
43
+ for row in rows:
44
+ findings.append(Findings(
45
+ analyzer=row["analyzer"],
46
+ severity=row["severity"],
47
+ title=row["title"],
48
+ message=row["message"],
49
+ observation_id=row["observation_id"],
50
+ data=json.loads(row["data"]),
51
+ ))
52
+
53
+ return findings
@@ -0,0 +1,20 @@
1
+ from dataclasses import dataclass,field
2
+ from datetime import datetime
3
+
4
+ @dataclass
5
+ class Observation:
6
+ source:str
7
+ type:str
8
+ external_id:str
9
+ timestamp:datetime
10
+ data:dict
11
+ id:int | None = None
12
+
13
+ @dataclass
14
+ class Findings:
15
+ analyzer: str
16
+ severity: str
17
+ title: str
18
+ message: str
19
+ observation_id: int
20
+ data: dict = field(default_factory=dict)
@@ -0,0 +1,93 @@
1
+ import json
2
+ import sqlite3
3
+ from chronicle.storage.models import Observation
4
+ from datetime import datetime
5
+
6
+ class ObservationRepo:
7
+ def __init__(self, connection:sqlite3.Connection) -> None:
8
+ self.conn = connection
9
+
10
+ def save(self,observation:Observation)->bool:
11
+ cursor = self.conn.execute(
12
+ """
13
+ INSERT OR IGNORE INTO observations(
14
+ source,
15
+ type,
16
+ external_id,
17
+ timestamp,
18
+ data
19
+ ) VALUES (?,?,?,?,?)
20
+ """,
21
+ (
22
+ observation.source,
23
+ observation.type,
24
+ observation.external_id,
25
+ observation.timestamp.isoformat(),
26
+ json.dumps(observation.data),
27
+ ),
28
+ )
29
+
30
+ return cursor.rowcount == 1
31
+
32
+ def list_all(self,id:int | None = None) -> list[Observation]:
33
+ observations = []
34
+ if id:
35
+ row = self.conn.execute(
36
+ """
37
+ SELECT
38
+ id,
39
+ source,
40
+ type,
41
+ external_id,
42
+ timestamp,
43
+ data
44
+ FROM observations
45
+ WHERE id = ?;
46
+ """,
47
+ (id,),
48
+ ).fetchone()
49
+
50
+ if row is None:
51
+ return []
52
+
53
+ observations.append(
54
+ Observation(
55
+ source=row["source"],
56
+ type=row["type"],
57
+ external_id=row["external_id"],
58
+ timestamp=datetime.fromisoformat(
59
+ row["timestamp"]
60
+ ),
61
+ data=json.loads(row["data"]),
62
+ )
63
+ )
64
+ else:
65
+ rows = self.conn.execute(
66
+ """
67
+ SELECT
68
+ id,
69
+ source,
70
+ type,
71
+ external_id,
72
+ timestamp,
73
+ data
74
+ FROM observations
75
+ ORDER BY id
76
+ """
77
+ ).fetchall()
78
+
79
+ for row in rows:
80
+ observations.append(
81
+ Observation(
82
+ id=row["id"],
83
+ source=row["source"],
84
+ type=row["type"],
85
+ external_id=row["external_id"],
86
+ timestamp=datetime.fromisoformat(
87
+ row["timestamp"]
88
+ ),
89
+ data=json.loads(row["data"]),
90
+ )
91
+ )
92
+
93
+ return observations
@@ -0,0 +1,44 @@
1
+ import sqlite3
2
+ from datetime import datetime, timezone
3
+
4
+ class ScanStateRepo:
5
+ def __init__(self,conn:sqlite3.Connection) -> None:
6
+ self.conn = conn
7
+
8
+ def get(self,key:str,scanner:str) -> str | None:
9
+ row = self.conn.execute(
10
+ """
11
+ SELECT VALUE FROM scan_state WHERE key=? AND scanner=?;
12
+ """,
13
+ (key,scanner),
14
+ ).fetchone()
15
+
16
+ if row is None:
17
+ return None
18
+
19
+ return row["value"]
20
+
21
+ def set(self,key:str,scanner:str,value:str) -> None:
22
+ self.conn.execute(
23
+ """
24
+ INSERT INTO scan_state(
25
+ scanner,
26
+ key,
27
+ value,
28
+ updated_at
29
+ )
30
+ VALUES (?,?,?,?)
31
+
32
+ ON CONFLICT(scanner,key)
33
+ DO UPDATE SET
34
+ value = excluded.value,
35
+ updated_at = excluded.updated_at
36
+ """,
37
+ (
38
+ scanner,
39
+ key,
40
+ value,
41
+ datetime.now(timezone.utc).isoformat(),
42
+ )
43
+ )
44
+
@@ -0,0 +1,47 @@
1
+ import sqlite3
2
+
3
+ SCHEMA = """
4
+ CREATE TABLE IF NOT EXISTS observations(
5
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
6
+ source TEXT NOT NULL,
7
+ type TEXT NOT NULL,
8
+ external_id TEXT NOT NULL,
9
+ timestamp TEXT NOT NULL,
10
+ data TEXT NOT NULL,
11
+ UNIQUE(source,type,external_id)
12
+ );
13
+
14
+ CREATE TABLE IF NOT EXISTS scan_state(
15
+ scanner TEXT NOT NULL,
16
+ key TEXT NOT NULL,
17
+ value TEXT NOT NULL,
18
+ updated_at TEXT NOT NULL,
19
+ PRIMARY KEY(scanner , key)
20
+ );
21
+
22
+ CREATE TABLE IF NOT EXISTS analysis_state(
23
+ analyzer TEXT NOT NULL,
24
+ key TEXT NOT NULL,
25
+ value TEXT NOT NULL,
26
+ updated_at TEXT NOT NULL,
27
+ PRIMARY KEY(analyzer , key)
28
+ );
29
+
30
+ CREATE TABLE IF NOT EXISTS findings(
31
+ analyzer TEXT NOT NULL,
32
+ severity TEXT NOT NULL,
33
+ title TEXT NOT NULL,
34
+ message TEXT NOT NULL,
35
+ observation_id INTEGER,
36
+ data TEXT NOT NULL,
37
+ UNIQUE(analyzer,observation_id,data),
38
+ FOREIGN KEY (observation_id)
39
+ REFERENCES observations (id)
40
+ ON DELETE CASCADE
41
+ ON UPDATE CASCADE
42
+ );
43
+ """
44
+
45
+ def init_schema(connection:sqlite3.Connection) -> None:
46
+ connection.executescript(SCHEMA)
47
+ connection.commit()