kodit 0.3.1__py3-none-any.whl → 0.3.3__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.

Potentially problematic release.


This version of kodit might be problematic. Click here for more details.

Files changed (57) hide show
  1. kodit/_version.py +2 -2
  2. kodit/application/factories/code_indexing_factory.py +77 -28
  3. kodit/application/services/code_indexing_application_service.py +148 -119
  4. kodit/cli.py +49 -52
  5. kodit/domain/entities.py +268 -189
  6. kodit/domain/protocols.py +61 -0
  7. kodit/domain/services/embedding_service.py +1 -1
  8. kodit/domain/services/index_query_service.py +66 -0
  9. kodit/domain/services/index_service.py +323 -0
  10. kodit/domain/value_objects.py +225 -92
  11. kodit/infrastructure/cloning/git/working_copy.py +17 -8
  12. kodit/infrastructure/cloning/metadata.py +37 -67
  13. kodit/infrastructure/embedding/embedding_factory.py +1 -1
  14. kodit/infrastructure/embedding/local_vector_search_repository.py +1 -1
  15. kodit/infrastructure/embedding/vectorchord_vector_search_repository.py +1 -1
  16. kodit/infrastructure/enrichment/null_enrichment_provider.py +4 -10
  17. kodit/infrastructure/git/git_utils.py +1 -63
  18. kodit/infrastructure/ignore/ignore_pattern_provider.py +1 -2
  19. kodit/infrastructure/indexing/auto_indexing_service.py +2 -12
  20. kodit/infrastructure/indexing/fusion_service.py +1 -1
  21. kodit/infrastructure/mappers/__init__.py +1 -0
  22. kodit/infrastructure/mappers/index_mapper.py +344 -0
  23. kodit/infrastructure/snippet_extraction/factories.py +13 -0
  24. kodit/infrastructure/snippet_extraction/language_detection_service.py +1 -1
  25. kodit/infrastructure/snippet_extraction/snippet_query_provider.py +0 -1
  26. kodit/infrastructure/snippet_extraction/tree_sitter_snippet_extractor.py +1 -1
  27. kodit/infrastructure/sqlalchemy/embedding_repository.py +1 -1
  28. kodit/infrastructure/sqlalchemy/entities.py +203 -0
  29. kodit/infrastructure/sqlalchemy/file_repository.py +1 -1
  30. kodit/infrastructure/sqlalchemy/index_repository.py +550 -0
  31. kodit/log.py +4 -1
  32. kodit/mcp.py +1 -13
  33. kodit/migrations/env.py +1 -1
  34. kodit/migrations/versions/4073b33f9436_add_file_processing_flag.py +34 -0
  35. kodit/migrations/versions/4552eb3f23ce_add_summary.py +34 -0
  36. kodit/utils/__init__.py +1 -0
  37. kodit/utils/path_utils.py +54 -0
  38. {kodit-0.3.1.dist-info → kodit-0.3.3.dist-info}/METADATA +1 -1
  39. {kodit-0.3.1.dist-info → kodit-0.3.3.dist-info}/RECORD +42 -45
  40. kodit/domain/enums.py +0 -9
  41. kodit/domain/repositories.py +0 -128
  42. kodit/domain/services/ignore_service.py +0 -45
  43. kodit/domain/services/indexing_service.py +0 -204
  44. kodit/domain/services/snippet_extraction_service.py +0 -89
  45. kodit/domain/services/snippet_service.py +0 -211
  46. kodit/domain/services/source_service.py +0 -85
  47. kodit/infrastructure/cloning/folder/__init__.py +0 -1
  48. kodit/infrastructure/cloning/folder/factory.py +0 -128
  49. kodit/infrastructure/cloning/folder/working_copy.py +0 -38
  50. kodit/infrastructure/cloning/git/factory.py +0 -153
  51. kodit/infrastructure/indexing/index_repository.py +0 -273
  52. kodit/infrastructure/indexing/snippet_domain_service_factory.py +0 -37
  53. kodit/infrastructure/sqlalchemy/repository.py +0 -133
  54. kodit/infrastructure/sqlalchemy/snippet_repository.py +0 -251
  55. {kodit-0.3.1.dist-info → kodit-0.3.3.dist-info}/WHEEL +0 -0
  56. {kodit-0.3.1.dist-info → kodit-0.3.3.dist-info}/entry_points.txt +0 -0
  57. {kodit-0.3.1.dist-info → kodit-0.3.3.dist-info}/licenses/LICENSE +0 -0
@@ -1,153 +0,0 @@
1
- """Factory for creating git-based working copies."""
2
-
3
- import tempfile
4
- from pathlib import Path
5
-
6
- import git
7
- import structlog
8
- from sqlalchemy.ext.asyncio import AsyncSession
9
-
10
- from kodit.domain.entities import AuthorFileMapping, Source, SourceType
11
- from kodit.domain.interfaces import NullProgressCallback, ProgressCallback
12
- from kodit.domain.repositories import SourceRepository
13
- from kodit.domain.services.ignore_service import IgnoreService
14
- from kodit.domain.value_objects import ProgressEvent
15
- from kodit.infrastructure.cloning.git.working_copy import GitWorkingCopyProvider
16
- from kodit.infrastructure.cloning.metadata import (
17
- GitAuthorExtractor,
18
- GitFileMetadataExtractor,
19
- )
20
- from kodit.infrastructure.git.git_utils import sanitize_git_url
21
- from kodit.infrastructure.ignore.ignore_pattern_provider import GitIgnorePatternProvider
22
-
23
-
24
- class GitSourceFactory:
25
- """Factory for creating git-based working copies."""
26
-
27
- def __init__(
28
- self,
29
- repository: SourceRepository,
30
- working_copy: GitWorkingCopyProvider,
31
- session: AsyncSession,
32
- ) -> None:
33
- """Initialize the source factory."""
34
- self.log = structlog.get_logger(__name__)
35
- self.repository = repository
36
- self.working_copy = working_copy
37
- self.metadata_extractor = GitFileMetadataExtractor()
38
- self.author_extractor = GitAuthorExtractor(repository)
39
- self.session = session
40
-
41
- async def create(
42
- self, uri: str, progress_callback: ProgressCallback | None = None
43
- ) -> Source:
44
- """Create a git source from a URI."""
45
- # Use null callback if none provided
46
- if progress_callback is None:
47
- progress_callback = NullProgressCallback()
48
-
49
- # Normalize the URI
50
- # Never log the raw URI in production
51
- self.log.debug("Normalising git uri", uri="[REDACTED]" + uri[-4:])
52
- with tempfile.TemporaryDirectory() as temp_dir:
53
- git.Repo.clone_from(uri, temp_dir)
54
- remote = git.Repo(temp_dir).remote()
55
- uri = remote.url
56
-
57
- # Sanitize the URI to remove any credentials
58
- sanitized_uri = sanitize_git_url(uri)
59
- self.log.debug("Sanitized git uri", sanitized_uri=sanitized_uri)
60
-
61
- # Check if source already exists
62
- self.log.debug("Checking if source already exists", uri=sanitized_uri)
63
- source = await self.repository.get_by_uri(sanitized_uri)
64
-
65
- if source:
66
- self.log.info("Source already exists, reusing...", source_id=source.id)
67
- return source
68
-
69
- # Prepare working copy (use original URI for cloning, sanitized for storage)
70
- clone_path = await self.working_copy.prepare(uri)
71
-
72
- # Create source record
73
- self.log.debug("Creating source", uri=sanitized_uri, clone_path=str(clone_path))
74
- source = await self.repository.save(
75
- Source(
76
- uri=sanitized_uri,
77
- cloned_path=str(clone_path),
78
- source_type=SourceType.GIT,
79
- )
80
- )
81
-
82
- # Commit source creation so we get an ID for foreign key relationships
83
- await self.session.commit()
84
-
85
- # Get files to process using ignore patterns
86
- ignore_provider = GitIgnorePatternProvider(clone_path)
87
- ignore_service = IgnoreService(ignore_provider)
88
- files = [
89
- f
90
- for f in clone_path.rglob("*")
91
- if f.is_file() and not ignore_service.should_ignore(f)
92
- ]
93
-
94
- # Process files
95
- self.log.info("Inspecting files", source_id=source.id, num_files=len(files))
96
- await self._process_files(source, files, progress_callback)
97
-
98
- # Commit file processing
99
- await self.session.commit()
100
-
101
- return source
102
-
103
- async def _process_files(
104
- self, source: Source, files: list[Path], progress_callback: ProgressCallback
105
- ) -> None:
106
- """Process files for a source."""
107
- total_files = len(files)
108
-
109
- # Notify start of operation
110
- await progress_callback.on_progress(
111
- ProgressEvent(
112
- operation="process_files",
113
- current=0,
114
- total=total_files,
115
- message="Processing files...",
116
- )
117
- )
118
-
119
- for i, path in enumerate(files, 1):
120
- if not path.is_file():
121
- continue
122
-
123
- # Extract file metadata
124
- file_record = await self.metadata_extractor.extract(path, source)
125
- await self.repository.create_file(file_record)
126
-
127
- # Extract authors
128
- authors = await self.author_extractor.extract(path, source)
129
-
130
- # Commit authors so they get IDs before creating mappings
131
- if authors:
132
- await self.session.commit()
133
-
134
- for author in authors:
135
- await self.repository.upsert_author_file_mapping(
136
- AuthorFileMapping(
137
- author_id=author.id,
138
- file_id=file_record.id,
139
- )
140
- )
141
-
142
- # Update progress
143
- await progress_callback.on_progress(
144
- ProgressEvent(
145
- operation="process_files",
146
- current=i,
147
- total=total_files,
148
- message=f"Processing {path.name}...",
149
- )
150
- )
151
-
152
- # Notify completion
153
- await progress_callback.on_complete("process_files")
@@ -1,273 +0,0 @@
1
- """Infrastructure implementation of the index repository."""
2
-
3
- from datetime import UTC, datetime
4
- from typing import TypeVar
5
-
6
- from sqlalchemy import delete, func, select
7
- from sqlalchemy.ext.asyncio import AsyncSession
8
-
9
- from kodit.domain.entities import Embedding, File, Index, Snippet, Source
10
- from kodit.domain.services.indexing_service import IndexRepository
11
- from kodit.domain.value_objects import FileInfo, IndexView, SnippetInfo, SnippetWithFile
12
-
13
- T = TypeVar("T")
14
-
15
-
16
- class SQLAlchemyIndexRepository(IndexRepository):
17
- """SQLAlchemy implementation of the index repository."""
18
-
19
- def __init__(self, session: AsyncSession) -> None:
20
- """Initialize the index repository.
21
-
22
- Args:
23
- session: The SQLAlchemy async session to use for database operations.
24
-
25
- """
26
- self.session = session
27
-
28
- async def create_index(self, source_id: int) -> IndexView:
29
- """Create a new index for a source.
30
-
31
- Args:
32
- source_id: The ID of the source to create an index for.
33
-
34
- Returns:
35
- The created index view.
36
-
37
- """
38
- # Check if index already exists
39
- existing_index = await self.get_index_by_source_id(source_id)
40
- if existing_index:
41
- return existing_index
42
-
43
- index = Index(source_id=source_id)
44
- self.session.add(index)
45
-
46
- # Get source for the view
47
- source_query = select(Source).where(Source.id == source_id)
48
- source_result = await self.session.execute(source_query)
49
- source = source_result.scalar_one()
50
-
51
- return IndexView(
52
- id=index.id,
53
- created_at=index.created_at,
54
- updated_at=index.updated_at,
55
- source=source.uri,
56
- num_snippets=0,
57
- )
58
-
59
- async def _get_index_view(self, index: Index, source: Source) -> IndexView:
60
- """Create an IndexView from Index and Source entities.
61
-
62
- Args:
63
- index: The index entity
64
- source: The source entity
65
-
66
- Returns:
67
- The index view
68
-
69
- """
70
- num_snippets = await self.num_snippets_for_index(index.id)
71
- return IndexView(
72
- id=index.id,
73
- created_at=index.created_at,
74
- updated_at=index.updated_at,
75
- source=source.uri,
76
- num_snippets=num_snippets,
77
- )
78
-
79
- async def get_index_by_id(self, index_id: int) -> IndexView | None:
80
- """Get an index by its ID.
81
-
82
- Args:
83
- index_id: The ID of the index to retrieve.
84
-
85
- Returns:
86
- The index view if found, None otherwise.
87
-
88
- """
89
- query = (
90
- select(Index, Source)
91
- .join(Source, Index.source_id == Source.id)
92
- .where(Index.id == index_id)
93
- )
94
- result = await self.session.execute(query)
95
- row = result.first()
96
-
97
- if not row:
98
- return None
99
-
100
- index, source = row
101
- return await self._get_index_view(index, source)
102
-
103
- async def get_index_by_source_id(self, source_id: int) -> IndexView | None:
104
- """Get an index by its source ID.
105
-
106
- Args:
107
- source_id: The ID of the source to retrieve an index for.
108
-
109
- Returns:
110
- The index view if found, None otherwise.
111
-
112
- """
113
- query = (
114
- select(Index, Source)
115
- .join(Source, Index.source_id == Source.id)
116
- .where(Index.source_id == source_id)
117
- )
118
- result = await self.session.execute(query)
119
- row = result.first()
120
-
121
- if not row:
122
- return None
123
-
124
- index, source = row
125
- return await self._get_index_view(index, source)
126
-
127
- async def list_indexes(self) -> list[IndexView]:
128
- """List all indexes.
129
-
130
- Returns:
131
- A list of index views.
132
-
133
- """
134
- query = select(Index, Source).join(
135
- Source, Index.source_id == Source.id, full=True
136
- )
137
- result = await self.session.execute(query)
138
- rows = result.tuples()
139
-
140
- indexes = []
141
- for index, source in rows:
142
- index_view = await self._get_index_view(index, source)
143
- indexes.append(index_view)
144
-
145
- return indexes
146
-
147
- async def update_index_timestamp(self, index_id: int) -> None:
148
- """Update the timestamp of an index.
149
-
150
- Args:
151
- index_id: The ID of the index to update.
152
-
153
- """
154
- query = select(Index).where(Index.id == index_id)
155
- result = await self.session.execute(query)
156
- index = result.scalar_one_or_none()
157
-
158
- if index:
159
- index.updated_at = datetime.now(UTC)
160
-
161
- async def delete_all_snippets(self, index_id: int) -> None:
162
- """Delete all snippets for an index.
163
-
164
- Args:
165
- index_id: The ID of the index to delete snippets for.
166
-
167
- """
168
- # First get all snippets for this index
169
- snippets = await self.get_snippets_for_index(index_id)
170
-
171
- # Delete all embeddings for these snippets, if there are any
172
- for snippet in snippets:
173
- query = delete(Embedding).where(Embedding.snippet_id == snippet.id)
174
- await self.session.execute(query)
175
-
176
- # Now delete the snippets
177
- query = delete(Snippet).where(Snippet.index_id == index_id)
178
- await self.session.execute(query)
179
-
180
- async def get_snippets_for_index(self, index_id: int) -> list[Snippet]:
181
- """Get all snippets for an index.
182
-
183
- Args:
184
- index_id: The ID of the index to get snippets for.
185
-
186
- Returns:
187
- A list of Snippet entities.
188
-
189
- """
190
- query = select(Snippet).where(Snippet.index_id == index_id)
191
- result = await self.session.execute(query)
192
- return list(result.scalars())
193
-
194
- async def add_snippet(self, snippet: dict) -> None:
195
- """Add a snippet to the database.
196
-
197
- Args:
198
- snippet: The snippet to add.
199
-
200
- """
201
- db_snippet = Snippet(
202
- file_id=snippet["file_id"],
203
- index_id=snippet["index_id"],
204
- content=snippet["content"],
205
- )
206
- self.session.add(db_snippet)
207
-
208
- async def update_snippet_content(self, snippet_id: int, content: str) -> None:
209
- """Update the content of an existing snippet.
210
-
211
- Args:
212
- snippet_id: The ID of the snippet to update.
213
- content: The new content for the snippet.
214
-
215
- """
216
- query = select(Snippet).where(Snippet.id == snippet_id)
217
- result = await self.session.execute(query)
218
- snippet = result.scalar_one_or_none()
219
-
220
- if snippet:
221
- snippet.content = content
222
- # SQLAlchemy will automatically track this change
223
-
224
- async def list_snippets_by_ids(self, ids: list[int]) -> list[SnippetWithFile]:
225
- """List snippets by IDs.
226
-
227
- Args:
228
- ids: List of snippet IDs to retrieve.
229
-
230
- Returns:
231
- List of SnippetWithFile objects containing file and snippet information.
232
-
233
- """
234
- query = (
235
- select(Snippet, File)
236
- .where(Snippet.id.in_(ids))
237
- .join(File, Snippet.file_id == File.id)
238
- )
239
- rows = await self.session.execute(query)
240
-
241
- # Create a dictionary for O(1) lookup of results by ID
242
- id_to_result = {}
243
- for snippet, file in rows.all():
244
- id_to_result[snippet.id] = SnippetWithFile(
245
- file=FileInfo(uri=file.uri),
246
- snippet=SnippetInfo(id=snippet.id, content=snippet.content)
247
- )
248
-
249
- # Check that all IDs are present
250
- if len(id_to_result) != len(ids):
251
- # Create a list of missing IDs
252
- missing_ids = [
253
- snippet_id for snippet_id in ids if snippet_id not in id_to_result
254
- ]
255
- msg = f"Some IDs are not present: {missing_ids}"
256
- raise ValueError(msg)
257
-
258
- # Rebuild the list in the same order that it was passed in
259
- return [id_to_result[i] for i in ids]
260
-
261
- async def num_snippets_for_index(self, index_id: int) -> int:
262
- """Get the number of snippets for an index.
263
-
264
- Args:
265
- index_id: The ID of the index.
266
-
267
- Returns:
268
- The number of snippets.
269
-
270
- """
271
- query = select(func.count()).where(Snippet.index_id == index_id)
272
- result = await self.session.execute(query)
273
- return result.scalar_one()
@@ -1,37 +0,0 @@
1
- """Factory for creating snippet domain service."""
2
-
3
- from sqlalchemy.ext.asyncio import AsyncSession
4
-
5
- from kodit.domain.services.snippet_service import SnippetDomainService
6
- from kodit.infrastructure.snippet_extraction.snippet_extraction_factory import (
7
- create_snippet_extraction_domain_service,
8
- )
9
- from kodit.infrastructure.sqlalchemy.file_repository import SqlAlchemyFileRepository
10
- from kodit.infrastructure.sqlalchemy.snippet_repository import (
11
- SqlAlchemySnippetRepository,
12
- )
13
-
14
-
15
- def snippet_domain_service_factory(session: AsyncSession) -> SnippetDomainService:
16
- """Create a snippet domain service with all dependencies.
17
-
18
- Args:
19
- session: The database session
20
-
21
- Returns:
22
- Configured snippet domain service
23
-
24
- """
25
- # Create domain service for snippet extraction
26
- snippet_extraction_service = create_snippet_extraction_domain_service()
27
-
28
- # Create repositories
29
- snippet_repository = SqlAlchemySnippetRepository(session)
30
- file_repository = SqlAlchemyFileRepository(session)
31
-
32
- # Create and return the domain service
33
- return SnippetDomainService(
34
- snippet_extraction_service=snippet_extraction_service,
35
- snippet_repository=snippet_repository,
36
- file_repository=file_repository,
37
- )
@@ -1,133 +0,0 @@
1
- """SQLAlchemy repository."""
2
-
3
- from collections.abc import Sequence
4
- from typing import cast
5
-
6
- from sqlalchemy import select
7
- from sqlalchemy.ext.asyncio import AsyncSession
8
-
9
- from kodit.domain.entities import Author, AuthorFileMapping, File, Source, SourceType
10
- from kodit.domain.repositories import AuthorRepository, SourceRepository
11
-
12
-
13
- class SqlAlchemySourceRepository(SourceRepository):
14
- """SQLAlchemy source repository."""
15
-
16
- def __init__(self, session: AsyncSession) -> None:
17
- """Initialize the repository."""
18
- self._session = session
19
-
20
- async def get(self, id: int) -> Source | None: # noqa: A002
21
- """Get a source by ID."""
22
- return await self._session.get(Source, id)
23
-
24
- async def save(self, entity: Source) -> Source:
25
- """Save entity."""
26
- self._session.add(entity)
27
- return entity
28
-
29
- async def delete(self, id: int) -> None: # noqa: A002
30
- """Delete entity by ID."""
31
- source = await self.get(id)
32
- if source:
33
- await self._session.delete(source)
34
-
35
- async def list(self) -> Sequence[Source]:
36
- """List all entities."""
37
- stmt = select(Source)
38
- return (await self._session.scalars(stmt)).all()
39
-
40
- async def get_by_uri(self, uri: str) -> Source | None:
41
- """Get a source by URI."""
42
- stmt = select(Source).where(Source.uri == uri)
43
- return cast("Source | None", await self._session.scalar(stmt))
44
-
45
- async def list_by_type(
46
- self, source_type: SourceType | None = None
47
- ) -> Sequence[Source]:
48
- """List sources by type."""
49
- stmt = select(Source)
50
- if source_type is not None:
51
- stmt = stmt.where(Source.type == source_type)
52
- return (await self._session.scalars(stmt)).all()
53
-
54
- async def create_file(self, file: File) -> File:
55
- """Create a new file record."""
56
- self._session.add(file)
57
- return file
58
-
59
- async def upsert_author(self, author: Author) -> Author:
60
- """Create a new author or return existing one if email already exists."""
61
- # First check if author already exists with same name and email
62
- stmt = select(Author).where(
63
- Author.name == author.name, Author.email == author.email
64
- )
65
- existing_author = cast("Author | None", await self._session.scalar(stmt))
66
-
67
- if existing_author:
68
- return existing_author
69
-
70
- # Author doesn't exist, create new one
71
- self._session.add(author)
72
- return author
73
-
74
- async def upsert_author_file_mapping(
75
- self, mapping: AuthorFileMapping
76
- ) -> AuthorFileMapping:
77
- """Create a new author file mapping or return existing one if already exists."""
78
- # First check if mapping already exists with same author_id and file_id
79
- stmt = select(AuthorFileMapping).where(
80
- AuthorFileMapping.author_id == mapping.author_id,
81
- AuthorFileMapping.file_id == mapping.file_id,
82
- )
83
- existing_mapping = cast(
84
- "AuthorFileMapping | None", await self._session.scalar(stmt)
85
- )
86
-
87
- if existing_mapping:
88
- return existing_mapping
89
-
90
- # Mapping doesn't exist, create new one
91
- self._session.add(mapping)
92
- return mapping
93
-
94
-
95
- class SqlAlchemyAuthorRepository(AuthorRepository):
96
- """SQLAlchemy author repository."""
97
-
98
- def __init__(self, session: AsyncSession) -> None:
99
- """Initialize the repository."""
100
- self._session = session
101
-
102
- async def get(self, id: int) -> Author | None: # noqa: A002
103
- """Get an author by ID."""
104
- return await self._session.get(Author, id)
105
-
106
- async def save(self, entity: Author) -> Author:
107
- """Save entity."""
108
- self._session.add(entity)
109
- return entity
110
-
111
- async def delete(self, id: int) -> None: # noqa: A002
112
- """Delete entity by ID."""
113
- author = await self.get(id)
114
- if author:
115
- await self._session.delete(author)
116
-
117
- async def list(self) -> Sequence[Author]:
118
- """List authors."""
119
- return (await self._session.scalars(select(Author))).all()
120
-
121
- async def get_by_name(self, name: str) -> Author | None:
122
- """Get an author by name."""
123
- return cast(
124
- "Author | None",
125
- await self._session.scalar(select(Author).where(Author.name == name)),
126
- )
127
-
128
- async def get_by_email(self, email: str) -> Author | None:
129
- """Get an author by email."""
130
- return cast(
131
- "Author | None",
132
- await self._session.scalar(select(Author).where(Author.email == email)),
133
- )