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
chronicle/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
File without changes
chronicle/ai/base.py ADDED
@@ -0,0 +1,7 @@
1
+ from abc import ABC, abstractmethod
2
+
3
+ class AIProvider(ABC):
4
+ @abstractmethod
5
+ def generate(self,prompt:str) -> str:
6
+ """Generate a response from the AI model."""
7
+ raise NotImplementedError
@@ -0,0 +1,23 @@
1
+ from chronicle.ai.base import AIProvider
2
+ from chronicle.ai.openai import OpenAIProvider
3
+ from chronicle.ai.google_ai import GenAIProvider
4
+
5
+ def create_provider(
6
+ provider: str,
7
+ model:str,
8
+ api_key:str
9
+ ) -> AIProvider:
10
+ if provider == "openai":
11
+ return OpenAIProvider(
12
+ api_key=api_key,
13
+ model=model,
14
+ )
15
+ elif provider == "gemini":
16
+ return GenAIProvider(
17
+ api_key=api_key,
18
+ model=model,
19
+ )
20
+
21
+ raise ValueError(
22
+ f"Unsupported AI provider: {provider}"
23
+ )
@@ -0,0 +1,14 @@
1
+ from google import genai
2
+ from chronicle.ai.base import AIProvider
3
+
4
+ class GenAIProvider(AIProvider):
5
+ def __init__(self,model:str,api_key:str) -> None:
6
+ self.client = genai.Client(api_key=api_key)
7
+ self.model = model
8
+
9
+ def generate(self, prompt: str) -> str:
10
+ response = self.client.models.generate_content(
11
+ model=self.model,
12
+ contents = prompt,
13
+ )
14
+ return response.text if response.text else "Error: Model did not respond"
chronicle/ai/openai.py ADDED
@@ -0,0 +1,14 @@
1
+ from openai import OpenAI
2
+ from chronicle.ai.base import AIProvider
3
+
4
+ class OpenAIProvider(AIProvider):
5
+ def __init__(self,api_key:str,model:str) -> None:
6
+ self.client = OpenAI(api_key=api_key)
7
+ self.model = model
8
+
9
+ def generate(self, prompt: str) -> str:
10
+ response = self.client.responses.create(
11
+ model=self.model,
12
+ input=prompt,
13
+ )
14
+ return response.output_text
File without changes
File without changes
@@ -0,0 +1,15 @@
1
+ from abc import ABC,abstractmethod
2
+ from pathlib import Path
3
+ from chronicle.storage.models import Observation,Findings
4
+ from chronicle.analysis.context import AnalysisContext
5
+
6
+ class BaseAnalyzer(ABC):
7
+ """Base class for all chronicle analyzers."""
8
+
9
+ def __init__(self,project_root:Path) -> None:
10
+ self.project_root = project_root
11
+
12
+ @abstractmethod
13
+ def analyze(self, context:AnalysisContext) -> list[Findings]:
14
+ """Analyze the project and return findings."""
15
+ pass
@@ -0,0 +1,74 @@
1
+ from chronicle.analysis.analyzers.base import BaseAnalyzer
2
+ from chronicle.storage.models import Findings
3
+ import typer
4
+ from chronicle.storage.observations import Observation
5
+ from chronicle.analysis.context import AnalysisContext
6
+
7
+ class DjangoMigrationAnalyzer(BaseAnalyzer):
8
+
9
+ def analyze(
10
+ self,
11
+ context: AnalysisContext
12
+ ) -> list[Findings]:
13
+
14
+ findings = []
15
+
16
+ for observation in context.observations:
17
+
18
+ if observation.source != "django":
19
+ continue
20
+
21
+ if observation.type != "migration":
22
+ continue
23
+
24
+ operations = observation.data.get(
25
+ "operations",
26
+ []
27
+ )
28
+
29
+ for operation in operations:
30
+
31
+ operation_name = operation.get(
32
+ "operation"
33
+ )
34
+
35
+ if operation_name == "RemoveField":
36
+ typer.echo(
37
+ f"Generating finding: "
38
+ f"observation={observation.id}, "
39
+ f"data={operation}"
40
+ )
41
+ findings.append(
42
+ Findings(
43
+ analyzer="django-migrations",
44
+ severity="warning",
45
+ title="Field removed",
46
+ message=(
47
+ "A database field is being "
48
+ "removed by this migration."
49
+ ),
50
+ observation_id= observation.id, #type: ignore
51
+ data=operation,
52
+ )
53
+ )
54
+ if operation_name == "AddField":
55
+ typer.echo(
56
+ f"Generating finding: "
57
+ f"observation={observation.id}, "
58
+ f"data={operation}"
59
+ )
60
+ findings.append(
61
+ Findings(
62
+ analyzer="django-migrations",
63
+ severity="info",
64
+ title="Field added",
65
+ message=(
66
+ "A database field is being "
67
+ "added by this migration."
68
+ ),
69
+ observation_id= observation.id, #type: ignore
70
+ data=operation,
71
+ )
72
+ )
73
+
74
+ return findings
@@ -0,0 +1,37 @@
1
+ from pathlib import Path
2
+
3
+ from chronicle.storage.models import Observation,Findings
4
+ from .base import BaseAnalyzer
5
+ from chronicle.analysis.context import AnalysisContext
6
+
7
+ class GitAnalyzer(BaseAnalyzer):
8
+ def __init__(self, project_root: Path) -> None:
9
+ super().__init__(project_root)
10
+
11
+ def analyze(self,context:AnalysisContext) -> list[Findings]:
12
+ findings = []
13
+ for observation in context.observations:
14
+ if observation.type != "commit":
15
+ continue
16
+ if observation.source != "git":
17
+ continue
18
+
19
+ message = observation.data.get("message","Not given")
20
+ author = observation.data.get("author","unknown")
21
+ parsed_message = author + ":" + message
22
+ changes = observation.data.get("changes",[])
23
+
24
+ for change in changes:
25
+ file_status = change.get("status")
26
+ if file_status == "A":
27
+ findings.append(
28
+ Findings(
29
+ analyzer="git_analyzer",
30
+ severity="info",
31
+ title="New File created",
32
+ message=parsed_message,
33
+ observation_id=observation.id, #type: ignore
34
+ data=change
35
+ ))
36
+
37
+ return findings
@@ -0,0 +1,12 @@
1
+ from dataclasses import dataclass,field
2
+ from chronicle.storage.observations import Observation
3
+
4
+ @dataclass
5
+ class AnalysisContext:
6
+ observations: list[Observation]
7
+ state: dict[str,str | None] = field(
8
+ default_factory=dict
9
+ )
10
+
11
+ def get_state(self,analyzer:str,key:str) -> str |None:
12
+ return self.state.get(f"{analyzer}.{key}")
@@ -0,0 +1,18 @@
1
+ from pathlib import Path
2
+ from chronicle.analysis.context import AnalysisContext
3
+
4
+
5
+ from chronicle.analysis.analyzers.base import BaseAnalyzer
6
+ from chronicle.storage.models import Observation,Findings
7
+
8
+ class AnalyzeEngine:
9
+ def __init__(self, analyzers:list[BaseAnalyzer]) -> None:
10
+ self.analyzers = analyzers
11
+
12
+ def analyze(self,context:AnalysisContext) -> list[Findings]:
13
+ findings = []
14
+ for analyzer in self.analyzers:
15
+ analyzer_findings = analyzer.analyze(context)
16
+ findings.extend(analyzer_findings)
17
+
18
+ return findings
@@ -0,0 +1 @@
1
+ __version__ = '0.1.0'
File without changes
@@ -0,0 +1,73 @@
1
+ import typer
2
+ from chronicle.analysis.engine import AnalyzeEngine
3
+ from chronicle.analysis.analyzers.git import GitAnalyzer
4
+ from chronicle.analysis.analyzers.django_migrations import DjangoMigrationAnalyzer
5
+ from chronicle.config.loader import get_chronicle_directory
6
+ from chronicle.project.discovery import find_project_root
7
+ from chronicle.storage.database import connect
8
+ from chronicle.storage.observations import ObservationRepo
9
+ from chronicle.storage.findings import FindingsRepo
10
+ from chronicle.analysis.context import AnalysisContext
11
+ from chronicle.storage.analysis_state import AnalysisStateRepo
12
+
13
+ def analyze():
14
+ """Analyzes all observations done by chronicle."""
15
+
16
+ project_root = find_project_root()
17
+ if project_root:
18
+ db_path = (get_chronicle_directory(project_root) / "chronicle.db")
19
+ conn = connect(db_path)
20
+ observations = ObservationRepo(conn).list_all()
21
+ analysis_state = AnalysisStateRepo(conn)
22
+ last_obs_id = analysis_state.get(
23
+ "last_observation_id",
24
+ "analysis"
25
+ )
26
+
27
+ if last_obs_id is not None:
28
+ observations = [
29
+ observation for observation in observations if observation.id > int(last_obs_id) #type: ignore
30
+ ]
31
+
32
+ context = AnalysisContext(
33
+ observations=observations,
34
+ state={
35
+ "analysis.last_observation_id":last_obs_id,
36
+ }
37
+ )
38
+
39
+ repo = FindingsRepo(conn)
40
+ analyzers = [
41
+ GitAnalyzer(project_root),
42
+ DjangoMigrationAnalyzer(project_root),
43
+ ]
44
+ engine = AnalyzeEngine(analyzers) #type: ignore
45
+ findings = engine.analyze(context)
46
+
47
+ finding_count = 0
48
+
49
+ with conn:
50
+ for finding in findings:
51
+ if repo.save(finding):
52
+ finding_count +=1
53
+
54
+ if observations:
55
+ newest_obs_id = max(
56
+ observation.id for observation in observations #type: ignore
57
+ )
58
+
59
+ analysis_state.set(
60
+ "last_observation_id",
61
+ "analysis",
62
+ str(newest_obs_id)
63
+ )
64
+ typer.echo(f"Analyzed and stored {finding_count} findings.")
65
+ typer.echo(
66
+ f"Last analyzed observation: {last_obs_id}"
67
+ )
68
+
69
+ typer.echo(
70
+ f"Observations being analyzed: "
71
+ f"{[observation.id for observation in observations]}"
72
+ )
73
+
@@ -0,0 +1,79 @@
1
+ from pathlib import Path
2
+ import typer
3
+ from chronicle.project.discovery import find_project_root
4
+ from chronicle.config.loader import *
5
+ from chronicle.config.manager import *
6
+ from chronicle.config.credentials import has_api_key,set_api_key
7
+
8
+ config_app = typer.Typer(
9
+ help="Manage Chronicle configuration."
10
+ )
11
+
12
+ @config_app.command("set")
13
+ def set_config(
14
+ key: str = typer.Argument(...),
15
+ value: str = typer.Argument(...),
16
+ ):
17
+ """Set a Chronicle config value."""
18
+ project_root = find_project_root()
19
+ if project_root:
20
+ config_path = get_config_path(project_root)
21
+ config = load_config(config_path)
22
+ if key == "provider":
23
+ update_ai_config(
24
+ config_path,
25
+ provider=value,
26
+ )
27
+ elif key == "model":
28
+ update_ai_config(
29
+ config_path,
30
+ model=value,
31
+ )
32
+ elif key == "api_key":
33
+ if config["ai"]["provider"] != "":
34
+ set_api_key(provider=config["ai"]["provider"],api_key=value)
35
+ else:
36
+ typer.echo(f"Unknown config key: {key}")
37
+ typer.echo("Available key: provider, model, api_key")
38
+ raise typer.Exit(1)
39
+
40
+ typer.echo(
41
+ f"Updated {key} = {value}"
42
+ )
43
+ else:
44
+ typer.echo("Chronicle not initialized.")
45
+ raise typer.Exit(1)
46
+
47
+ @config_app.callback(invoke_without_command=True)
48
+ def config(ctx: typer.Context):
49
+ """View Chronicle configurations."""
50
+ if ctx.invoked_subcommand is not None:
51
+ return
52
+
53
+ project_root = find_project_root()
54
+ if project_root:
55
+ config_path = get_config_path(project_root)
56
+ configuration = load_config(config_path)
57
+ if not configuration:
58
+ typer.echo("Chronicle config is empty.")
59
+ return
60
+ ai = configuration.get("ai",{})
61
+
62
+ provider = ai.get("provider","")
63
+ model = ai.get('model', '')
64
+
65
+ if provider:
66
+ api_status = (
67
+ "configured" if has_api_key(provider) else "not configured"
68
+ )
69
+ else:
70
+ api_status = "not configured"
71
+
72
+ typer.echo("Chronicle Configuration")
73
+ typer.echo("=======================")
74
+ typer.echo(f"Provider : {provider}")
75
+ typer.echo(f"Model : {model}")
76
+ typer.echo(f"API Key : {api_status}")
77
+ else:
78
+ typer.echo("Chronicle not initialized.")
79
+
@@ -0,0 +1,47 @@
1
+ import typer
2
+ from chronicle.project.discovery import find_project_root
3
+ from chronicle.project.initializer import initialize_chronicle,setup_ai_config
4
+
5
+ def init():
6
+ """Initialize chronicle in a project"""
7
+
8
+ project_root = find_project_root()
9
+
10
+ if project_root is None:
11
+ typer.echo(f"Error: No .git files found in the current working directory",err=True)
12
+ raise typer.Exit(code=1)
13
+
14
+ config_path,created = initialize_chronicle(project_root)
15
+
16
+ if created:
17
+ typer.echo()
18
+ typer.echo(" ╔══════════════════════════════════════╗")
19
+ typer.echo(" ║ C H R O N I C L E ║")
20
+ typer.echo(" ╚══════════════════════════════════════╝")
21
+ typer.echo()
22
+
23
+ typer.echo(" ✦ Initializing Chronicle...")
24
+ typer.echo()
25
+
26
+ typer.echo(" ✓ Project detected")
27
+ typer.echo(" ✓ Chronicle directory created")
28
+ typer.echo(" ✓ Local database initialized")
29
+ typer.echo(" ✓ Initialized NLTK tokenizer")
30
+ typer.echo()
31
+
32
+ choice = typer.prompt(" Configure AI now?[Y/n] ").lower()
33
+ if choice == "y":
34
+ setup_ai_config(config_path=config_path)
35
+ typer.echo(" ✓ AI successfully configured.")
36
+ typer.echo()
37
+ else:
38
+ typer.echo(" AI not configured.")
39
+ typer.echo(" Use chronicle set command to configure AI.")
40
+ typer.echo()
41
+
42
+ typer.echo(" Chronicle is ready.")
43
+ typer.echo()
44
+ typer.echo(" Run `chronicle scan` to begin collecting observations.")
45
+ typer.echo()
46
+ else:
47
+ typer.echo(f"Chronicle already initiated at: {project_root}")
@@ -0,0 +1,48 @@
1
+ import typer
2
+ from chronicle.project.discovery import find_project_root
3
+ from chronicle.config.loader import get_chronicle_directory, load_config, get_config_path
4
+ from chronicle.interpretation.context_builder import *
5
+ from chronicle.storage.database import connect
6
+ from chronicle.storage.findings import FindingsRepo
7
+ from chronicle.storage.observations import ObservationRepo
8
+ from chronicle.interpretation.interpreter import Interpreter
9
+ from chronicle.ai.factory import GenAIProvider
10
+ from chronicle.config.credentials import get_api_key
11
+
12
+ def interpret(query: str = typer.Option(...,"--question","-q",help="Question to ask the project history.")):
13
+ """Interpret Chronicle's project history using AI."""
14
+ project_root = find_project_root()
15
+ if project_root is None:
16
+ typer.echo("Chronicle project not found.")
17
+ raise typer.Exit(1)
18
+
19
+ db_path = (get_chronicle_directory(project_root) / "chronicle.db")
20
+
21
+ conn = connect(db_path)
22
+
23
+ findings_repo = FindingsRepo(conn)
24
+ observation_repo = ObservationRepo(conn)
25
+
26
+ findings = findings_repo.list_all()
27
+ observations = observation_repo.list_all()
28
+
29
+ context = build_context(
30
+ question=query,
31
+ findings=findings,
32
+ observations=observations)
33
+
34
+ config_path = get_config_path(project_root)
35
+ config = load_config(config_path)
36
+ ai = config.get("ai",{})
37
+ model = ai.get("model")
38
+ provider_name = ai.get("provider")
39
+ api_key = get_api_key(provider=provider_name)
40
+ if not api_key:
41
+ typer.echo(f"Couldn't find an api key for {provider_name} provider")
42
+ raise typer.Exit(1)
43
+ provider = GenAIProvider(model=model,api_key=api_key)
44
+
45
+ interpreter = Interpreter(provider=provider)
46
+ response = interpreter.interpret(context)
47
+
48
+ typer.echo(response)
@@ -0,0 +1,87 @@
1
+ import typer
2
+ from chronicle.project.discovery import find_project_root
3
+ from chronicle.scanning.scanners.git import GitScanner
4
+ from chronicle.scanning.scanners.django_migrations import DjangoMigrationScanner
5
+ from chronicle.scanning.engine import ScanEngine
6
+ from chronicle.config.loader import get_chronicle_directory
7
+ from chronicle.storage.database import connect
8
+ from chronicle.storage.observations import ObservationRepo
9
+ from chronicle.storage.scan_state import ScanStateRepo
10
+ from chronicle.scanning.context import ScanContext
11
+
12
+ def scan():
13
+ """Scan the project and collect observation"""
14
+ project_root = find_project_root()
15
+ if project_root is None:
16
+ typer.echo("Error: could not find git repo.",err=True)
17
+ raise typer.Exit(code=1)
18
+
19
+ db_path = (get_chronicle_directory(project_root) / "chronicle.db")
20
+ connection = connect(database_path=db_path)
21
+ scan_state = ScanStateRepo(connection)
22
+ repo = ObservationRepo(connection)
23
+ last_commit = scan_state.get(
24
+ "last_commit",
25
+ "git",
26
+ )
27
+ last_migration = scan_state.get(
28
+ "last_migration",
29
+ "django_migrations",
30
+ )
31
+ git_context = ScanContext(
32
+ state={
33
+ "git.last_commit":last_commit,
34
+ }
35
+ )
36
+ django_context = ScanContext(
37
+ state={
38
+ "django_migrations.last_migration":last_migration,
39
+ }
40
+ )
41
+ contexts = [git_context,django_context]
42
+
43
+ scanners = [
44
+ GitScanner(project_root),
45
+ DjangoMigrationScanner(project_root)
46
+ ]
47
+
48
+ engine = ScanEngine(
49
+ project_root=project_root,
50
+ scanners=scanners, #type: ignore
51
+ )
52
+
53
+ observations = engine.scan(contexts=contexts)
54
+
55
+ created_count = 0
56
+
57
+ git_observations = [ observation for observation in observations if observation.source == "git"]
58
+
59
+ django_observations = [ observation for observation in observations if observation.source == "django"]
60
+
61
+ with connection:
62
+ for observation in observations:
63
+ if repo.save(observation=observation):
64
+ created_count+=1
65
+
66
+ if git_observations:
67
+ newest_commit = git_observations[0].external_id
68
+ scan_state.set(
69
+ "last_commit",
70
+ "git",
71
+ newest_commit
72
+ )
73
+ if django_observations:
74
+ newest_migration = django_observations[-1].external_id
75
+ scan_state.set(
76
+ "last_migration",
77
+ "django_migrations",
78
+ newest_migration
79
+ )
80
+
81
+ connection.close()
82
+
83
+ if created_count > 0:
84
+ typer.echo(f"Collected {len(observations)} observation(s).")
85
+ typer.echo(f"Stored {created_count} new observation(s)")
86
+ else:
87
+ typer.echo(f"All observation(s) already stored.")