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,106 @@
1
+ import typer
2
+ from chronicle.storage.observations import ObservationRepo
3
+ from chronicle.storage.database import connect
4
+ from chronicle.config.loader import get_chronicle_directory
5
+ from chronicle.project.discovery import find_project_root
6
+
7
+ def show(id:int | None = typer.Option(None, "--id")):
8
+ """Shows all the observations done by chronicle or shows observation by id using --id."""
9
+ project_root = find_project_root()
10
+ if project_root is None:
11
+ typer.echo("Error: could not find git repo.",err=True)
12
+ raise typer.Exit(code=1)
13
+ db_path = (get_chronicle_directory(project_root) / "chronicle.db")
14
+ conn = connect(db_path)
15
+ if id:
16
+ observation, = ObservationRepo(conn).list_all(id)
17
+ typer.echo()
18
+ typer.echo(f"Observation #{id}")
19
+ typer.echo("─" * 44)
20
+ typer.echo()
21
+
22
+ typer.echo(f"{'Source:':<14}{observation.source}")
23
+ typer.echo(f"{'Type:':<14}{observation.type}")
24
+ typer.echo(f"{'External ID:':<14}{observation.external_id}")
25
+ typer.echo(
26
+ f"{'Timestamp:':<14}"
27
+ f"{observation.timestamp.strftime('%Y-%m-%d %H:%M:%S UTC')}"
28
+ )
29
+
30
+ typer.echo()
31
+ typer.echo("Data")
32
+ typer.echo("─" * 44)
33
+
34
+ data = observation.data
35
+
36
+ if "app" in data:
37
+ typer.echo(f"{'App:':<14}{data['app']}")
38
+
39
+ if "name" in data:
40
+ typer.echo(f"{'Migration:':<14}{data['name']}")
41
+
42
+ if "dependencies" in data:
43
+ typer.echo()
44
+ typer.echo("Dependencies:")
45
+
46
+ for dependency in data["dependencies"]:
47
+ typer.echo(f" • {dependency[0]}:{dependency[1]}")
48
+
49
+ if "operations" in data:
50
+ typer.echo()
51
+ typer.echo("Operations:")
52
+
53
+ for operation in data["operations"]:
54
+ typer.echo(
55
+ f" • {operation['operation']}"
56
+ )
57
+
58
+ details = operation.get("details", {})
59
+
60
+ if details.get("model"):
61
+ typer.echo(
62
+ f" Model: {details['model']}"
63
+ )
64
+
65
+ if details.get("field"):
66
+ typer.echo(
67
+ f" Field: {details['field']}"
68
+ )
69
+
70
+ typer.echo()
71
+ typer.echo("─" * 44)
72
+ else:
73
+ observations = ObservationRepo(conn).list_all()
74
+
75
+ typer.echo()
76
+ typer.echo("Chronicle Observations")
77
+ typer.echo("─" * 75)
78
+ typer.echo()
79
+
80
+ if not observations:
81
+ typer.echo("No observations found.")
82
+ typer.echo()
83
+ return
84
+
85
+ typer.echo(
86
+ f"{'ID':<5}"
87
+ f"{'SOURCE':<12}"
88
+ f"{'TYPE':<15}"
89
+ f"EXTERNAL ID"
90
+ )
91
+
92
+ typer.echo("─" * 75)
93
+
94
+ for observation in observations:
95
+ formatted_external_id = observation.external_id[:7] if observation.source == "git" else observation.external_id
96
+
97
+ typer.echo(
98
+ f"{observation.id:<5}"
99
+ f"{observation.source:<12}"
100
+ f"{observation.type:<15}"
101
+ f"{formatted_external_id}"
102
+ )
103
+
104
+ typer.echo()
105
+ typer.echo("─" * 75)
106
+ typer.echo(f"{len(observations)} observations")
@@ -0,0 +1,26 @@
1
+ from pathlib import Path
2
+ from chronicle.project.status import get_status
3
+ import typer
4
+
5
+ def status():
6
+ """Show the current chronicle project status."""
7
+ project_status=get_status()
8
+ if project_status is None:
9
+ typer.echo("Error: could not find a Git repo in the project.")
10
+ raise typer.Exit(code=1)
11
+
12
+ typer.echo("")
13
+ typer.echo(f"Project: {project_status.project_root.name}")
14
+ typer.echo(f"Root: {project_status.project_root}")
15
+ if project_status.chronicle_initialized:
16
+ typer.echo("Chronicle: initialized")
17
+ else:
18
+ typer.echo("Chronicle: not initialized")
19
+ if project_status.git_detected:
20
+ typer.echo("Git: detected")
21
+ else:
22
+ typer.echo("Git: not detected")
23
+ if project_status.config_exists:
24
+ typer.echo("Configuration: found")
25
+ else:
26
+ typer.echo("Configuration: missing")
@@ -0,0 +1,6 @@
1
+ import typer
2
+ from chronicle import __version__
3
+
4
+ def version():
5
+ """Show Chronicle Version"""
6
+ typer.echo(f"Chronicle {__version__}")
chronicle/cli/main.py ADDED
@@ -0,0 +1,30 @@
1
+ import typer
2
+ from chronicle.cli.commands.init import init
3
+ from chronicle.cli.commands.version import version
4
+ from chronicle.cli.commands.status import status
5
+ from chronicle.cli.commands.scan import scan
6
+ from chronicle.cli.commands.show import show
7
+ from chronicle.cli.commands.analyze import analyze
8
+ from chronicle.cli.commands.config import config_app
9
+ from chronicle.cli.commands.interpret import interpret
10
+
11
+ app = typer.Typer(
12
+ name="chronicle",
13
+ help="Local-first developer intelligence tool.",
14
+ no_args_is_help=True,
15
+ )
16
+
17
+ app.command(name="init")(init)
18
+ app.command(name="version")(version)
19
+ app.command(name="status")(status)
20
+ app.command(name="scan")(scan)
21
+ app.command(name="show")(show)
22
+ app.command(name="analyze")(analyze)
23
+ app.command(name="interpret")(interpret)
24
+ app.add_typer(
25
+ config_app,
26
+ name="config"
27
+ )
28
+
29
+ def main():
30
+ app()
File without changes
@@ -0,0 +1,17 @@
1
+ import os
2
+ import keyring
3
+
4
+ SERVICE_NAME = "chronicle"
5
+
6
+ def set_api_key(provider:str,api_key:str):
7
+ keyring.set_password(
8
+ SERVICE_NAME,
9
+ provider,
10
+ api_key
11
+ )
12
+
13
+ def get_api_key(provider: str) -> str | None:
14
+ return keyring.get_password(SERVICE_NAME,provider)
15
+
16
+ def has_api_key(provider: str) -> bool:
17
+ return get_api_key(provider) is not None
@@ -0,0 +1,21 @@
1
+ from pathlib import Path
2
+ try:
3
+ import tomllib #type:ignore
4
+ except ModuleNotFoundError:
5
+ import tomli as tomllib
6
+
7
+ CHRONICLE_DIRECTORY = ".chronicle"
8
+ CONFIG_FILE = "config.toml"
9
+
10
+ def get_chronicle_directory(project_root:Path) -> Path:
11
+ return project_root / CHRONICLE_DIRECTORY
12
+
13
+ def get_config_path(project_root:Path) -> Path:
14
+ return get_chronicle_directory(project_root=project_root) / CONFIG_FILE
15
+
16
+ def load_config(config_path: Path) ->dict:
17
+ if not config_path.exists():
18
+ return {}
19
+
20
+ with config_path.open("rb") as file:
21
+ return tomllib.load(file)
@@ -0,0 +1,52 @@
1
+ from pathlib import Path
2
+ from chronicle.config.loader import *
3
+ import tomli_w
4
+
5
+ DEFAULT_CONFIG = """\
6
+ [chronicle]
7
+ version=1
8
+
9
+ [ai]
10
+ provider=""
11
+ model=""
12
+ """
13
+
14
+ def create_gitignore(project_root:Path):
15
+ gitignore_path = project_root / ".gitignore"
16
+ if not gitignore_path.exists():
17
+ gitignore_path.write_text(".chronicle/\n")
18
+ return
19
+ existing_content = gitignore_path.read_text()
20
+ if ".chronicle/" in existing_content.splitlines():
21
+ return
22
+ with gitignore_path.open(mode="a") as file:
23
+ file.write("\n.chronicle/\n")
24
+
25
+ def create_config(config_path:Path) -> bool:
26
+ if not config_path.exists():
27
+ config_path.write_text(
28
+ DEFAULT_CONFIG
29
+ )
30
+ return True
31
+ return False
32
+
33
+ def update_ai_config(
34
+ config_path:Path,
35
+ provider:str | None = None,
36
+ model:str | None=None,
37
+ ) -> None:
38
+ if not config_path.exists():
39
+ create_config(config_path)
40
+
41
+ config = load_config(config_path)
42
+
43
+ ai = config.setdefault("ai",{})
44
+ if provider is not None:
45
+ ai["provider"] = provider
46
+ if model is not None:
47
+ ai["model"] = model
48
+
49
+ with config_path.open("wb") as file:
50
+ tomli_w.dump(config,file)
51
+
52
+
File without changes
@@ -0,0 +1,16 @@
1
+ import subprocess
2
+ from pathlib import Path
3
+
4
+ class GitIntegration:
5
+ def __init__(self,repo:Path) -> None:
6
+ self.repo = repo
7
+
8
+ def run(self, *arguments:str) -> str:
9
+ result = subprocess.run(
10
+ ["git", *arguments],
11
+ cwd=self.repo,
12
+ capture_output=True,
13
+ check=True,
14
+ text=True,
15
+ )
16
+ return result.stdout
File without changes
@@ -0,0 +1,8 @@
1
+ from dataclasses import dataclass
2
+ from chronicle.storage.models import Findings,Observation
3
+
4
+ @dataclass
5
+ class InterpretationContext:
6
+ question: str
7
+ findings: list[Findings]
8
+ observations: list[Observation]
@@ -0,0 +1,147 @@
1
+ from chronicle.interpretation.context import InterpretationContext
2
+ from chronicle.storage.models import Findings,Observation
3
+ from chronicle.project.discovery import find_project_root
4
+ from nltk.tokenize import word_tokenize
5
+ from pathlib import Path
6
+
7
+ STOP_WORDS = {"the","a","an","is","was","were","to","of","in","on","for","my","me","why","what","how","did","does","do","has","have","been","there",}
8
+
9
+ def build_context(
10
+ question:str,
11
+ findings: list[Findings],
12
+ observations:list[Observation],
13
+ ) -> InterpretationContext:
14
+
15
+ keywords = _extract_keywords(question)
16
+ scored_findings = []
17
+
18
+ for finding in findings:
19
+ score = _score_finding(
20
+ finding,
21
+ keywords,
22
+ )
23
+
24
+ if score > 0:
25
+ scored_findings.append(
26
+ (score, finding)
27
+ )
28
+
29
+ scored_findings.sort(
30
+ key = lambda item: item[0],
31
+ reverse=True
32
+ )
33
+
34
+ selected_findings = [
35
+ finding for _, finding in scored_findings[:10]
36
+ ]
37
+
38
+ observation_map = {
39
+ observation.id: observation for observation in observations
40
+ }
41
+
42
+ selected_observations = []
43
+
44
+ for finding in selected_findings:
45
+ observation = observation_map.get(finding.observation_id)
46
+
47
+ if observation is not None:
48
+ selected_observations.append(observation)
49
+
50
+ related_observations = []
51
+
52
+ for observation in selected_observations:
53
+ related = _find_related_git_observations(
54
+ observation,
55
+ observations
56
+ )
57
+
58
+ related_observations.extend(related)
59
+
60
+ selected_observations.extend(related_observations)
61
+
62
+ unique_observations = {}
63
+
64
+ for observation in selected_observations:
65
+ unique_observations[observation.id] = observation
66
+
67
+ selected_observations = list(unique_observations.values())
68
+
69
+ return InterpretationContext(
70
+ question=question,
71
+ findings=selected_findings,
72
+ observations=selected_observations,
73
+ )
74
+
75
+ def _extract_keywords(question:str) -> list[str]:
76
+ tokens = word_tokenize(question.lower())
77
+ return [
78
+ token for token in tokens if token.isalnum() and token not in STOP_WORDS and len(token) >= 3
79
+ ]
80
+
81
+ def _finding_text(finding: Findings) -> str:
82
+ return " ".join(
83
+ [
84
+ finding.title,
85
+ finding.message,
86
+ str(finding.data),
87
+ ]
88
+ ).lower()
89
+
90
+ def _score_finding(finding: Findings, keywords: list[str]) -> int:
91
+ text = _finding_text(finding)
92
+ score = 0
93
+
94
+ for keyword in keywords:
95
+ if keyword in finding.title.lower():
96
+ score += 3
97
+ if keyword in finding.message.lower():
98
+ score += 2
99
+ if keyword in str(finding.data).lower():
100
+ score += 3
101
+
102
+ return score
103
+
104
+ def _get_migration_path(observation: Observation) -> Path | None:
105
+ project_root = find_project_root()
106
+ if project_root is None:
107
+ return None
108
+ if observation.source != "django":
109
+ return None
110
+ if observation.type != "migration":
111
+ return None
112
+
113
+ root_name = project_root.name
114
+ migration_name = observation.data.get("name")
115
+ app_name = observation.data.get("app")
116
+ if not migration_name:
117
+ return None
118
+ if not app_name:
119
+ return None
120
+
121
+ migration_file = migration_name + ".py"
122
+ if app_name == root_name:
123
+ return Path("migrations") / migration_file
124
+
125
+ return Path(app_name) / "migrations" / migration_file
126
+
127
+ def _find_related_git_observations(migration: Observation, observations: list[Observation]) -> list[Observation]:
128
+ migration_path = _get_migration_path(migration)
129
+
130
+ if migration_path is None:
131
+ return []
132
+
133
+ related = []
134
+ for observation in observations:
135
+ if observation.source != "git":
136
+ continue
137
+ if observation.type != "commit":
138
+ continue
139
+
140
+ changes = observation.data.get("changes",[])
141
+
142
+ for change in changes:
143
+ if change.get("path") == str(migration_path):
144
+ related.append(observation)
145
+ break
146
+
147
+ return related
@@ -0,0 +1,12 @@
1
+ from chronicle.ai.base import AIProvider
2
+ from chronicle.interpretation.context import InterpretationContext
3
+ from chronicle.interpretation.prompts import build_interpretation_prompt
4
+
5
+ class Interpreter:
6
+ def __init__(self,provider: AIProvider) -> None:
7
+ self.provider = provider
8
+
9
+ def interpret(self, context: InterpretationContext) -> str:
10
+ prompt = build_interpretation_prompt(context)
11
+
12
+ return self.provider.generate(prompt)
@@ -0,0 +1,51 @@
1
+ from chronicle.interpretation.context import InterpretationContext
2
+
3
+ def build_interpretation_prompt(context: InterpretationContext) -> str:
4
+ prompt = f"""
5
+ You are Chronicle, a software project history interpreter.
6
+
7
+ Answer the user's question using only the project history
8
+ provided below.
9
+
10
+ User question:
11
+ {context.question}
12
+
13
+ Findings:
14
+ """
15
+
16
+ for finding in context.findings:
17
+ prompt += f"""
18
+ - Analyzer: {finding.analyzer}
19
+ - Severity: {finding.severity}
20
+ - Title: {finding.title}
21
+ - Message: {finding.message}
22
+ - Observation ID: {finding.observation_id}
23
+ - Data: {finding.data}
24
+ """
25
+
26
+ prompt += """
27
+
28
+ Observations:
29
+ """
30
+
31
+ for observation in context.observations:
32
+ prompt += f"""
33
+ - ID: {observation.id}
34
+ - Source: {observation.source}
35
+ - Type: {observation.type}
36
+ - External ID: {observation.external_id}
37
+ - Timestamp: {observation.timestamp}
38
+ - Data: {observation.data}
39
+ """
40
+
41
+ prompt += """
42
+
43
+ Explain what happened, why it happened, and what effect it
44
+ may have on the project.
45
+
46
+ If the provided project history does not contain enough
47
+ information to answer the question, say so instead of
48
+ inventing information.
49
+ """
50
+
51
+ return prompt
File without changes
@@ -0,0 +1,9 @@
1
+ from pathlib import Path
2
+
3
+ def find_project_root(start:Path | None=None) -> Path | None:
4
+ """Find the nearest ancestor containing a .git directory"""
5
+ cwd = start or Path.cwd()
6
+ for directory in [cwd,*cwd.parents]:
7
+ if (directory / ".git").exists():
8
+ return directory
9
+ return None
@@ -0,0 +1,45 @@
1
+ from pathlib import Path
2
+ from chronicle.config.loader import *
3
+ from chronicle.config.manager import *
4
+ from chronicle.storage.schema import init_schema
5
+ from chronicle.storage.database import connect
6
+ from chronicle.config.credentials import has_api_key,set_api_key
7
+ import nltk
8
+ import os
9
+ import typer
10
+
11
+ def initialize_chronicle(project_root:Path) -> tuple[Path, bool]:
12
+ chronicle_directory = get_chronicle_directory(project_root)
13
+ chronicle_directory.mkdir(parents=True,exist_ok=True)
14
+
15
+ db_path = chronicle_directory / "chronicle.db"
16
+ if not db_path.exists():
17
+ db_path.touch()
18
+
19
+ conn = connect(db_path)
20
+ init_schema(conn)
21
+
22
+ config_path = get_config_path(project_root)
23
+ created = create_config(config_path)
24
+
25
+ create_gitignore(project_root)
26
+
27
+ try:
28
+ nltk.data.find("tokenizers/punkt_tab")
29
+ except LookupError:
30
+ nltk.download("punkt_tab",quiet=True)
31
+
32
+ return config_path,created
33
+
34
+ def setup_ai_config(config_path: Path):
35
+ provider = typer.prompt(" AI provider", default="gemini")
36
+ model = typer.prompt(" AI model", default="gemini-2.5-flash")
37
+ if has_api_key(provider):
38
+ typer.echo(" API key already found in the system.")
39
+ else:
40
+ api_key = typer.prompt(" API KEY",hide_input=True)
41
+ set_api_key(provider,api_key)
42
+ typer.echo(" ✓ API key stored securely.")
43
+
44
+
45
+ update_ai_config(config_path=config_path,provider=provider,model=model)
@@ -0,0 +1,25 @@
1
+ from pathlib import Path
2
+ from dataclasses import dataclass
3
+ from chronicle.project.discovery import find_project_root
4
+ from chronicle.config.loader import *
5
+
6
+ @dataclass
7
+ class ProjectStatus:
8
+ project_root:Path
9
+ chronicle_initialized:bool
10
+ git_detected:bool
11
+ config_exists:bool
12
+
13
+ def get_status() -> ProjectStatus | None:
14
+ project_root = find_project_root()
15
+ if project_root is None:
16
+ return None
17
+
18
+ config_path = get_config_path(project_root)
19
+
20
+ return ProjectStatus(
21
+ project_root=project_root,
22
+ chronicle_initialized=config_path.exists(),
23
+ git_detected=(project_root / ".git").exists(),
24
+ config_exists=config_path.exists()
25
+ )
File without changes
@@ -0,0 +1,10 @@
1
+ from dataclasses import dataclass, field
2
+
3
+ @dataclass
4
+ class ScanContext:
5
+ state: dict[str,str | None] = field(
6
+ default_factory=dict
7
+ )
8
+
9
+ def get_state(self,scanner:str,key:str) -> str |None:
10
+ return self.state.get(f"{scanner}.{key}")
@@ -0,0 +1,16 @@
1
+ from pathlib import Path
2
+ from chronicle.scanning.scanners.base import Scanner
3
+ from chronicle.storage.models import Observation
4
+ from .context import ScanContext
5
+
6
+ class ScanEngine:
7
+ def __init__(self,project_root:Path,scanners:list[Scanner]) -> None:
8
+ self.project_root = project_root
9
+ self.scanners = scanners
10
+
11
+ def scan(self,contexts:list[ScanContext]) -> list[Observation]:
12
+ observation:list[Observation] = []
13
+ for scanner in self.scanners:
14
+ observation.extend(scanner.scan(contexts=contexts))
15
+
16
+ return observation
File without changes
@@ -0,0 +1,17 @@
1
+ from abc import ABC, abstractmethod
2
+ from pathlib import Path
3
+
4
+ from ...storage.models import Observation
5
+ from chronicle.scanning.context import ScanContext
6
+
7
+ class Scanner(ABC):
8
+ """Base class for all chronicle scanners."""
9
+
10
+ def __init__(self,project_root:Path) -> None:
11
+ self.project_root = project_root
12
+
13
+ @abstractmethod
14
+ def scan(self,contexts:list[ScanContext]) -> list[Observation]:
15
+ """Scan the project and return observation"""
16
+ pass
17
+