deproc-core 0.1.0__tar.gz

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.
@@ -0,0 +1,19 @@
1
+ Metadata-Version: 2.4
2
+ Name: deproc-core
3
+ Version: 0.1.0
4
+ Summary: Language-agnostic core contracts and runtime for deproc plugins.
5
+ Author-email: Gurveer Singh Virk <gurveervirk.15517@gmail.com>
6
+ License: Apache-2.0
7
+ Project-URL: Homepage, https://github.com/gurveervirk/deproc
8
+ Project-URL: Repository, https://github.com/gurveervirk/deproc
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: License :: OSI Approved :: Apache Software License
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3 :: Only
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Topic :: Software Development :: Libraries
19
+ Requires-Python: >=3.10
@@ -0,0 +1,5 @@
1
+ from .discovery import find_source_files
2
+
3
+ __all__ = [
4
+ "find_source_files",
5
+ ]
@@ -0,0 +1,207 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from typing import TYPE_CHECKING
5
+
6
+ if TYPE_CHECKING:
7
+ from .interfaces.linker import Linker
8
+ from .interfaces.parser import SourceParser
9
+ from .interfaces.resolver import Resolver
10
+ from .interfaces.symbol_cache import SymbolCache
11
+
12
+ from .runtime import EntityRegistry
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+ class Context:
17
+ def __init__(self, base_path: str = "", copy_from: Context | None = None):
18
+ if copy_from:
19
+ self.base_path = copy_from.base_path
20
+ self.entity_registry = copy_from.entity_registry
21
+ self._language_aliases = dict(copy_from._language_aliases)
22
+ self._language_extensions = dict(copy_from._language_extensions)
23
+ self._all_languages = set(copy_from._all_languages)
24
+ self._all_file_extensions = set(copy_from._all_file_extensions)
25
+ self._selected_languages = set(copy_from._selected_languages)
26
+ self._selected_file_extensions = set(copy_from._selected_file_extensions)
27
+ self._parsers = dict(copy_from._parsers)
28
+ self._resolvers = dict(copy_from._resolvers)
29
+ self._linkers = dict(copy_from._linkers)
30
+ self.symbol_caches = dict(copy_from.symbol_caches)
31
+ self._skip_paths = set(copy_from._skip_paths)
32
+ else:
33
+ self.base_path = base_path
34
+ self.entity_registry = EntityRegistry()
35
+ self._language_aliases: dict[str, list[str]] = {}
36
+ self._language_extensions: dict[str, list[str]] = {}
37
+ self._all_languages: set[str] = set()
38
+ self._all_file_extensions: set[str] = set()
39
+ self._selected_languages: set[str] = set()
40
+ self._selected_file_extensions: set[str] = set()
41
+ self._skip_paths: set[str] = set()
42
+ self._parsers: dict[str, SourceParser] = {}
43
+ self._resolvers: dict[str, Resolver] = {}
44
+ self._linkers: dict[str, Linker] = {}
45
+ self.symbol_caches: dict[str, SymbolCache] = {}
46
+
47
+ def set_language(
48
+ self,
49
+ language: str,
50
+ file_extensions: list[str],
51
+ aliases: list[str] | None = None,
52
+ ) -> None:
53
+ normalized = language.strip().lower()
54
+ normalized_extensions: list[str] = []
55
+ for ext in file_extensions:
56
+ e = ext.strip().lower()
57
+ if not e.startswith("."):
58
+ e = f".{e}"
59
+ logger.warning("Normalized file extension '%s' to '%s' for language '%s'", ext, e, normalized)
60
+ normalized_extensions.append(e)
61
+ self._language_extensions[normalized] = normalized_extensions
62
+ if aliases:
63
+ self._language_aliases[normalized] = [a.strip().lower() for a in aliases]
64
+ else:
65
+ self._language_aliases[normalized] = []
66
+ self._all_languages.add(normalized)
67
+ self._selected_languages.add(normalized)
68
+ self._all_file_extensions.update(normalized_extensions)
69
+ self._selected_file_extensions.update(normalized_extensions)
70
+
71
+ @property
72
+ def selected_languages(self) -> set[str]:
73
+ return self._selected_languages
74
+
75
+ @property
76
+ def selected_file_extensions(self) -> set[str]:
77
+ return self._selected_file_extensions
78
+
79
+ @property
80
+ def skip_paths(self) -> set[str]:
81
+ return self._skip_paths
82
+
83
+ def set_skip_paths(self, paths: set[str]) -> None:
84
+ self._skip_paths = set(paths)
85
+
86
+ def _normalize_language(self, language: str) -> str:
87
+ normalized = language.strip().lower()
88
+ for canonical, aliases in self._language_aliases.items():
89
+ if normalized == canonical or normalized in aliases:
90
+ return canonical
91
+ return normalized
92
+
93
+ def _normalize_extension(self, extension: str) -> str:
94
+ normalized = extension.strip().lower()
95
+ if not normalized.startswith("."):
96
+ normalized = f".{normalized}"
97
+ return normalized
98
+
99
+ def select_languages(self, languages: list[str]) -> None:
100
+ for lang in languages:
101
+ normalized = self._normalize_language(lang)
102
+ exclude = normalized.startswith("!")
103
+ if exclude:
104
+ normalized = normalized[1:]
105
+ if normalized in self._all_languages:
106
+ if exclude:
107
+ self._selected_languages.discard(normalized)
108
+ else:
109
+ self._selected_languages.add(normalized)
110
+
111
+ def select_file_extensions(self, extensions: list[str]) -> None:
112
+ for ext in extensions:
113
+ raw = ext.strip().lower()
114
+ exclude = raw.startswith("!")
115
+ if exclude:
116
+ raw = raw[1:]
117
+ normalized = self._normalize_extension(raw)
118
+ if normalized in self._all_file_extensions:
119
+ if exclude:
120
+ self._selected_file_extensions.discard(normalized)
121
+ else:
122
+ self._selected_file_extensions.add(normalized)
123
+
124
+ def _require_language(self, language: str) -> None:
125
+ if language not in self._all_languages:
126
+ raise KeyError(f"Language '{language}' is not registered. Call set_language() first.")
127
+
128
+ def _warn_if_language_missing(self, language: str) -> None:
129
+ if language not in self._all_languages:
130
+ logger.warning("Language '%s' is not registered. Did you forget to call set_language()?", language)
131
+
132
+ def set_parser(self, language: str, parser: SourceParser) -> None:
133
+ self._require_language(language)
134
+ self._parsers[language] = parser
135
+
136
+ def get_parser(self, language: str) -> SourceParser | None:
137
+ self._warn_if_language_missing(language)
138
+ return self._parsers.get(language)
139
+
140
+ def has_parser(self, language: str) -> bool:
141
+ self._warn_if_language_missing(language)
142
+ return language in self._parsers
143
+
144
+ def remove_parser(self, language: str) -> None:
145
+ self._warn_if_language_missing(language)
146
+ self._parsers.pop(language, None)
147
+
148
+ def set_resolver(self, language: str, resolver: Resolver) -> None:
149
+ self._require_language(language)
150
+ self._resolvers[language] = resolver
151
+
152
+ def get_resolver(self, language: str) -> Resolver | None:
153
+ self._warn_if_language_missing(language)
154
+ return self._resolvers.get(language)
155
+
156
+ def has_resolver(self, language: str) -> bool:
157
+ self._warn_if_language_missing(language)
158
+ return language in self._resolvers
159
+
160
+ def remove_resolver(self, language: str) -> None:
161
+ self._warn_if_language_missing(language)
162
+ self._resolvers.pop(language, None)
163
+
164
+ def set_linker(self, language: str, linker: Linker) -> None:
165
+ self._require_language(language)
166
+ self._linkers[language] = linker
167
+
168
+ def get_linker(self, language: str) -> Linker | None:
169
+ self._warn_if_language_missing(language)
170
+ return self._linkers.get(language)
171
+
172
+ def has_linker(self, language: str) -> bool:
173
+ self._warn_if_language_missing(language)
174
+ return language in self._linkers
175
+
176
+ def remove_linker(self, language: str) -> None:
177
+ self._warn_if_language_missing(language)
178
+ self._linkers.pop(language, None)
179
+
180
+ def set_symbol_cache(self, cache: SymbolCache) -> None:
181
+ self._require_language(cache.language)
182
+ self.symbol_caches[cache.language] = cache
183
+
184
+ def get_symbol_cache(self, language: str) -> SymbolCache | None:
185
+ self._warn_if_language_missing(language)
186
+ return self.symbol_caches.get(language)
187
+
188
+ def has_symbol_cache(self, language: str) -> bool:
189
+ self._warn_if_language_missing(language)
190
+ return language in self.symbol_caches
191
+
192
+ def remove_symbol_cache(self, language: str) -> None:
193
+ self._warn_if_language_missing(language)
194
+ self.symbol_caches.pop(language, None)
195
+
196
+ def reset(
197
+ self,
198
+ include_languages: bool = True,
199
+ include_file_extensions: bool = True,
200
+ include_symbol_caches: bool = True,
201
+ ) -> None:
202
+ if include_languages:
203
+ self._selected_languages = set(self._all_languages)
204
+ if include_file_extensions:
205
+ self._selected_file_extensions = set(self._all_file_extensions)
206
+ if include_symbol_caches:
207
+ self.symbol_caches = {}
@@ -0,0 +1,23 @@
1
+ import fnmatch
2
+ import os
3
+ from .context import Context
4
+
5
+ def _match_any(name: str, patterns: set[str]) -> bool:
6
+ return any(fnmatch.fnmatch(name, p) for p in patterns)
7
+
8
+ def find_source_files(context: Context) -> list[str]:
9
+ root = context.base_path
10
+ extension_set = set(context.selected_file_extensions)
11
+ skip_dirs = context.skip_paths
12
+ if not extension_set:
13
+ return []
14
+
15
+ matches: list[str] = []
16
+ for dirpath, dirnames, filenames in os.walk(root):
17
+ dirnames[:] = [d for d in dirnames if not _match_any(d, skip_dirs)]
18
+ for f in filenames:
19
+ if any(f.lower().endswith(ext) for ext in extension_set):
20
+ matches.append(os.path.join(dirpath, f))
21
+
22
+ matches.sort()
23
+ return matches
@@ -0,0 +1,11 @@
1
+ from .linker import Linker
2
+ from .parser import SourceParser
3
+ from .resolver import Resolver
4
+ from .symbol_cache import SymbolCache
5
+
6
+ __all__ = [
7
+ "Linker",
8
+ "Resolver",
9
+ "SourceParser",
10
+ "SymbolCache",
11
+ ]
@@ -0,0 +1,15 @@
1
+ from __future__ import annotations
2
+ from typing import TYPE_CHECKING, Protocol, TypeVar, runtime_checkable
3
+
4
+ if TYPE_CHECKING:
5
+ from ..context import Context
6
+
7
+ from .parser.models import SourceFile, Node
8
+
9
+ T_In = TypeVar("T_In", bound=list[SourceFile])
10
+ T_Out = TypeVar("T_Out", bound=list[Node])
11
+
12
+ @runtime_checkable
13
+ class Linker(Protocol[T_In, T_Out]):
14
+ def link_files(self, nodes: T_In, context: Context) -> T_Out:
15
+ ...
@@ -0,0 +1,5 @@
1
+ from .base import SourceParser
2
+
3
+ __all__ = [
4
+ "SourceParser"
5
+ ]
@@ -0,0 +1,11 @@
1
+ from __future__ import annotations
2
+ from typing import TYPE_CHECKING, Protocol, runtime_checkable
3
+
4
+ if TYPE_CHECKING:
5
+ from .models import SourceFile
6
+ from ...context import Context
7
+
8
+ @runtime_checkable
9
+ class SourceParser(Protocol):
10
+ def parse_file(self, file_path: str, context: Context) -> SourceFile:
11
+ ...
@@ -0,0 +1,142 @@
1
+ """
2
+ Provisional data models for the parser interface.
3
+ Please use these models either directly in the plugin implementations or as a reference for defining plugin-specific models.
4
+ """
5
+ import hashlib
6
+ from dataclasses import dataclass, field
7
+ from uuid import NAMESPACE_URL, UUID, uuid4, uuid5
8
+
9
+ type SymbolID = str
10
+
11
+ def generate_id() -> SymbolID:
12
+ return uuid4().hex
13
+
14
+ @dataclass(kw_only=True)
15
+ class Entity:
16
+ id: str | None = None
17
+ parent_id: str | None = None
18
+
19
+ def __post_init__(self):
20
+ if self.id is not None:
21
+ return
22
+ self.id = self._compute_id()
23
+
24
+ def _compute_id(self) -> str:
25
+ parent_id = getattr(self, "parent_id", None)
26
+ source_range = getattr(self, "source_range", None)
27
+ if parent_id and source_range:
28
+ namespace = UUID(hex=parent_id)
29
+ name = f"{type(self).__qualname__}:{source_range.lineno}:{source_range.end_lineno}:{source_range.col_offset}:{source_range.end_col_offset}"
30
+ return uuid5(namespace, name).hex
31
+ return uuid4().hex
32
+
33
+ @dataclass(kw_only=True)
34
+ class SourceRange:
35
+ lineno: int
36
+ end_lineno: int
37
+ col_offset: int
38
+ end_col_offset: int
39
+ source_id: str | None = None
40
+
41
+ @dataclass
42
+ class Docstring:
43
+ docstring_range: SourceRange | None
44
+
45
+ @dataclass
46
+ class Signature:
47
+ signature_range: SourceRange
48
+ arguments_range: SourceRange | None
49
+ return_type_range: SourceRange | None
50
+
51
+ @dataclass
52
+ class Annotation:
53
+ source_range: SourceRange
54
+ name: str
55
+
56
+ @dataclass
57
+ class ImportStatement(Entity):
58
+ source_range: SourceRange
59
+ type: str
60
+
61
+ @dataclass
62
+ class Argument:
63
+ name: str
64
+ source_range: SourceRange
65
+ default_value: str | None
66
+ type_annotation: str | None
67
+
68
+ @dataclass(kw_only=True)
69
+ class FunctionLike(Docstring, Entity):
70
+ name: str
71
+ fqn: str
72
+ source_range: SourceRange
73
+ type: str = field(default="FUNCTION")
74
+ signature: Signature
75
+
76
+ @dataclass
77
+ class SimpleBinding:
78
+ name: str
79
+ fqn: str
80
+
81
+ @dataclass
82
+ class ComplexBinding:
83
+ source_range: SourceRange | None
84
+
85
+ @dataclass(kw_only=True)
86
+ class VariableDeclaration(Entity):
87
+ type: str = field(default="VARIABLE")
88
+ source_range: SourceRange
89
+ variable_binding: SimpleBinding | ComplexBinding
90
+ value_range: SourceRange | None
91
+ type_annotation: SourceRange | None
92
+ modifiers: list[str] = field(default_factory=list)
93
+
94
+ @dataclass(kw_only=True)
95
+ class TypeDefinition(Docstring, Entity):
96
+ name: str
97
+ fqn: str
98
+ source_range: SourceRange
99
+ type: str = field(default="TYPE_DEFINITION")
100
+ annotations: list[Annotation] = field(default_factory=list)
101
+ inherits: list[str] = field(default_factory=list)
102
+ method_ids: list[str] = field(default_factory=list)
103
+ inner_type_ids: list[str] = field(default_factory=list)
104
+ property_ids: list[str] = field(default_factory=list)
105
+ visibility: str | None
106
+
107
+ @dataclass
108
+ class ControlFlowBlock(Entity):
109
+ branch: str
110
+ source_range: SourceRange
111
+ condition_range: SourceRange | None
112
+ import_stmt_ids: list[SymbolID] = field(default_factory=list)
113
+ type_ids: list[SymbolID] = field(default_factory=list)
114
+ function_ids: list[SymbolID] = field(default_factory=list)
115
+ variable_ids: list[SymbolID] = field(default_factory=list)
116
+ nested_group_ids: list[SymbolID] = field(default_factory=list)
117
+
118
+ @dataclass
119
+ class ControlFlowGroup(Entity):
120
+ group_type: str
121
+ source_range: SourceRange
122
+ block_ids: list[SymbolID] = field(default_factory=list)
123
+
124
+ @dataclass
125
+ class Node(Entity):
126
+ path: str
127
+
128
+ def _compute_id(self) -> str:
129
+ return uuid5(NAMESPACE_URL, f"file://{self.path}").hex
130
+
131
+ @dataclass(kw_only=True)
132
+ class SourceFile(Docstring, Node):
133
+ import_stmt_ids: list[SymbolID] = field(default_factory=list)
134
+ type_ids: list[SymbolID] = field(default_factory=list)
135
+ function_ids: list[SymbolID] = field(default_factory=list)
136
+ variable_ids: list[SymbolID] = field(default_factory=list)
137
+ control_flow_group_ids: list[SymbolID] = field(default_factory=list)
138
+ source: str
139
+
140
+ def _compute_id(self) -> str:
141
+ h = hashlib.sha256(self.source.encode()).hexdigest()[:16]
142
+ return uuid5(NAMESPACE_URL, f"file://{self.path}#{h}").hex
@@ -0,0 +1,16 @@
1
+ from __future__ import annotations
2
+ from typing import (
3
+ Any,
4
+ Protocol,
5
+ TypeVar,
6
+ runtime_checkable
7
+ )
8
+
9
+ TOut = TypeVar("TOut")
10
+
11
+ @runtime_checkable
12
+ class Resolver(Protocol[TOut]):
13
+ def resolve(self, *args: Any, **kwargs: Any) -> TOut:
14
+ ...
15
+
16
+ __all__ = ["Resolver"]
@@ -0,0 +1,20 @@
1
+ from typing import(
2
+ Any,
3
+ Protocol,
4
+ TypeVar,
5
+ runtime_checkable
6
+ )
7
+
8
+ TCache = TypeVar("TCache")
9
+ TReturn = TypeVar("TReturn")
10
+
11
+ @runtime_checkable
12
+ class SymbolCache(Protocol[TCache, TReturn]):
13
+ language: str
14
+ cache: TCache
15
+
16
+ def get(self, *args: Any, **kwargs: Any) -> TReturn:
17
+ ...
18
+
19
+ def set(self, *args: Any, **kwargs: Any) -> None:
20
+ ...
@@ -0,0 +1,7 @@
1
+ from .registries import (
2
+ EntityRegistry
3
+ )
4
+
5
+ __all__ = [
6
+ "EntityRegistry"
7
+ ]
@@ -0,0 +1,5 @@
1
+ from .entity import EntityRegistry
2
+
3
+ __all__ = [
4
+ "EntityRegistry"
5
+ ]
@@ -0,0 +1,42 @@
1
+ from collections import defaultdict
2
+ from dataclasses import dataclass, field
3
+ from ...interfaces.parser.models import (
4
+ Entity,
5
+ SymbolID,
6
+ )
7
+
8
+ @dataclass
9
+ class EntityRegistry:
10
+ entities: dict[SymbolID, Entity] = field(default_factory=dict)
11
+ fqn_to_ids: dict[str, set[SymbolID]] = field(default_factory=lambda: defaultdict(set))
12
+
13
+ def add(self, entity: Entity) -> None:
14
+ self.entities[entity.id] = entity
15
+ fqn = getattr(entity, "fqn", None)
16
+ if fqn:
17
+ self.fqn_to_ids[fqn].add(entity.id)
18
+
19
+ def add_all(self, entities: list[Entity]) -> None:
20
+ for entity in entities:
21
+ self.add(entity)
22
+
23
+ def remove(self, entity_id: SymbolID) -> None:
24
+ entity = self.entities.pop(entity_id, None)
25
+ if entity is not None:
26
+ fqn = getattr(entity, "fqn", None)
27
+ if fqn and entity_id in self.fqn_to_ids.get(fqn, set()):
28
+ self.fqn_to_ids[fqn].discard(entity_id)
29
+ if not self.fqn_to_ids[fqn]:
30
+ del self.fqn_to_ids[fqn]
31
+
32
+ def get_ids_by_fqn(self, fqn: str) -> set[SymbolID]:
33
+ return set(self.fqn_to_ids.get(fqn, set()))
34
+
35
+ def values(self):
36
+ return self.entities.values()
37
+
38
+ def get(self, entity_id: SymbolID, default=None):
39
+ return self.entities.get(entity_id, default)
40
+
41
+ def __contains__(self, entity_id: SymbolID) -> bool:
42
+ return entity_id in self.entities
@@ -0,0 +1,19 @@
1
+ Metadata-Version: 2.4
2
+ Name: deproc-core
3
+ Version: 0.1.0
4
+ Summary: Language-agnostic core contracts and runtime for deproc plugins.
5
+ Author-email: Gurveer Singh Virk <gurveervirk.15517@gmail.com>
6
+ License: Apache-2.0
7
+ Project-URL: Homepage, https://github.com/gurveervirk/deproc
8
+ Project-URL: Repository, https://github.com/gurveervirk/deproc
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: License :: OSI Approved :: Apache Software License
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3 :: Only
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Topic :: Software Development :: Libraries
19
+ Requires-Python: >=3.10
@@ -0,0 +1,22 @@
1
+ pyproject.toml
2
+ deproc/core/__init__.py
3
+ deproc/core/context.py
4
+ deproc/core/discovery.py
5
+ deproc/core/interfaces/__init__.py
6
+ deproc/core/interfaces/linker.py
7
+ deproc/core/interfaces/resolver.py
8
+ deproc/core/interfaces/symbol_cache.py
9
+ deproc/core/interfaces/parser/__init__.py
10
+ deproc/core/interfaces/parser/base.py
11
+ deproc/core/interfaces/parser/models.py
12
+ deproc/core/runtime/__init__.py
13
+ deproc/core/runtime/registries/__init__.py
14
+ deproc/core/runtime/registries/entity.py
15
+ deproc_core.egg-info/PKG-INFO
16
+ deproc_core.egg-info/SOURCES.txt
17
+ deproc_core.egg-info/dependency_links.txt
18
+ deproc_core.egg-info/top_level.txt
19
+ tests/test_context.py
20
+ tests/test_discovery.py
21
+ tests/test_entity_registry.py
22
+ tests/test_models.py
@@ -0,0 +1 @@
1
+ deproc
@@ -0,0 +1,33 @@
1
+ [build-system]
2
+ requires = ["setuptools>=69", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "deproc-core"
7
+ version = "0.1.0"
8
+ description = "Language-agnostic core contracts and runtime for deproc plugins."
9
+ authors = [
10
+ { name = "Gurveer Singh Virk", email = "gurveervirk.15517@gmail.com" }
11
+ ]
12
+ license = {text = "Apache-2.0"}
13
+ requires-python = ">=3.10"
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Intended Audience :: Developers",
17
+ "License :: OSI Approved :: Apache Software License",
18
+ "Operating System :: OS Independent",
19
+ "Programming Language :: Python :: 3",
20
+ "Programming Language :: Python :: 3 :: Only",
21
+ "Programming Language :: Python :: 3.10",
22
+ "Programming Language :: Python :: 3.11",
23
+ "Programming Language :: Python :: 3.12",
24
+ "Topic :: Software Development :: Libraries",
25
+ ]
26
+
27
+ [project.urls]
28
+ Homepage = "https://github.com/gurveervirk/deproc"
29
+ Repository = "https://github.com/gurveervirk/deproc"
30
+
31
+ [tool.setuptools.packages.find]
32
+ where = ["."]
33
+ include = ["deproc.core*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,307 @@
1
+ import pytest
2
+ from deproc.core.context import Context
3
+ from unittest.mock import MagicMock
4
+
5
+ class TestSetLanguage:
6
+ def test_set_language_adds_to_selected(self):
7
+ ctx = Context()
8
+ ctx.set_language("python", [".py", ".pyi"], aliases=["py"])
9
+ assert ctx.selected_languages == {"python"}
10
+ assert ctx.selected_file_extensions == {".py", ".pyi"}
11
+
12
+ def test_set_language_normalizes_extension(self):
13
+ ctx = Context()
14
+ ctx.set_language("python", ["py", "pyi"])
15
+ assert ctx.selected_file_extensions == {".py", ".pyi"}
16
+
17
+ def test_set_language_normalizes_language(self):
18
+ ctx = Context()
19
+ ctx.set_language("Python", [".py"])
20
+ assert ctx.selected_languages == {"python"}
21
+
22
+ def test_set_language_without_aliases(self):
23
+ ctx = Context()
24
+ ctx.set_language("python", [".py"])
25
+ assert ctx.selected_languages == {"python"}
26
+
27
+ def test_multiple_languages(self):
28
+ ctx = Context()
29
+ ctx.set_language("python", [".py"])
30
+ ctx.set_language("javascript", [".js"])
31
+ assert ctx.selected_languages == {"python", "javascript"}
32
+ assert ctx.selected_file_extensions == {".py", ".js"}
33
+
34
+ class TestSelectLanguages:
35
+ def setup_method(self):
36
+ self.ctx = Context()
37
+ self.ctx.set_language("python", [".py"], aliases=["py"])
38
+ self.ctx.set_language("javascript", [".js"])
39
+
40
+ def test_exclude_language(self):
41
+ self.ctx.select_languages(["!python"])
42
+ assert self.ctx.selected_languages == {"javascript"}
43
+
44
+ def test_include_language(self):
45
+ self.ctx.select_languages(["!python"])
46
+ self.ctx.select_languages(["python"])
47
+ assert self.ctx.selected_languages == {"python", "javascript"}
48
+
49
+ def test_select_by_alias(self):
50
+ self.ctx.select_languages(["!python"])
51
+ self.ctx.select_languages(["py"])
52
+ assert "python" in self.ctx.selected_languages
53
+
54
+ def test_unknown_language_ignored(self):
55
+ self.ctx.select_languages(["!python"])
56
+ self.ctx.select_languages(["unknown"])
57
+ assert self.ctx.selected_languages == {"javascript"}
58
+
59
+ class TestSelectFileExtensions:
60
+ def setup_method(self):
61
+ self.ctx = Context()
62
+ self.ctx.set_language("python", [".py", ".pyi"])
63
+
64
+ def test_exclude_extension(self):
65
+ self.ctx.select_file_extensions(["!.py"])
66
+ assert self.ctx.selected_file_extensions == {".pyi"}
67
+
68
+ def test_include_extension(self):
69
+ self.ctx.select_file_extensions(["!.py"])
70
+ self.ctx.select_file_extensions([".py"])
71
+ assert self.ctx.selected_file_extensions == {".py", ".pyi"}
72
+
73
+ def test_unknown_extension_ignored(self):
74
+ self.ctx.select_file_extensions(["!.py"])
75
+ self.ctx.select_file_extensions([".unknown"])
76
+ assert self.ctx.selected_file_extensions == {".pyi"}
77
+
78
+ class TestSetParser:
79
+ def setup_method(self):
80
+ self.ctx = Context()
81
+ self.ctx.set_language("python", [".py"])
82
+
83
+ def test_set_and_get_parser(self):
84
+ self.ctx.set_parser("python", "fake_parser")
85
+ assert self.ctx.get_parser("python") == "fake_parser"
86
+
87
+ def test_has_parser(self):
88
+ assert not self.ctx.has_parser("python")
89
+ self.ctx.set_parser("python", "fake_parser")
90
+ assert self.ctx.has_parser("python")
91
+
92
+ def test_remove_parser(self):
93
+ self.ctx.set_parser("python", "fake_parser")
94
+ self.ctx.remove_parser("python")
95
+ assert not self.ctx.has_parser("python")
96
+
97
+ def test_set_parser_raises_for_unregistered_language(self):
98
+ with pytest.raises(KeyError, match="not registered"):
99
+ self.ctx.set_parser("unknown", "fake_parser")
100
+
101
+ def test_get_parser_missing_language_logs_warning(self, caplog):
102
+ caplog.set_level("WARNING")
103
+ self.ctx.get_parser("unknown")
104
+ assert "not registered" in caplog.text
105
+
106
+ def test_has_parser_missing_language_logs_warning(self, caplog):
107
+ caplog.set_level("WARNING")
108
+ self.ctx.has_parser("unknown")
109
+ assert "not registered" in caplog.text
110
+
111
+ class TestSetResolver:
112
+ def setup_method(self):
113
+ self.ctx = Context()
114
+ self.ctx.set_language("python", [".py"])
115
+
116
+ def test_set_and_get_resolver(self):
117
+ self.ctx.set_resolver("python", "fake_resolver")
118
+ assert self.ctx.get_resolver("python") == "fake_resolver"
119
+
120
+ def test_has_resolver(self):
121
+ assert not self.ctx.has_resolver("python")
122
+ self.ctx.set_resolver("python", "fake_resolver")
123
+ assert self.ctx.has_resolver("python")
124
+
125
+ def test_remove_resolver(self):
126
+ self.ctx.set_resolver("python", "fake_resolver")
127
+ self.ctx.remove_resolver("python")
128
+ assert not self.ctx.has_resolver("python")
129
+
130
+ def test_set_resolver_raises_for_unregistered_language(self):
131
+ with pytest.raises(KeyError, match="not registered"):
132
+ self.ctx.set_resolver("unknown", "fake_resolver")
133
+
134
+ def test_get_resolver_missing_language_logs_warning(self, caplog):
135
+ caplog.set_level("WARNING")
136
+ self.ctx.get_resolver("unknown")
137
+ assert "not registered" in caplog.text
138
+
139
+ class TestSetLinker:
140
+ def setup_method(self):
141
+ self.ctx = Context()
142
+ self.ctx.set_language("python", [".py"])
143
+
144
+ def test_set_and_get_linker(self):
145
+ self.ctx.set_linker("python", "fake_linker")
146
+ assert self.ctx.get_linker("python") == "fake_linker"
147
+
148
+ def test_has_linker(self):
149
+ assert not self.ctx.has_linker("python")
150
+ self.ctx.set_linker("python", "fake_linker")
151
+ assert self.ctx.has_linker("python")
152
+
153
+ def test_remove_linker(self):
154
+ self.ctx.set_linker("python", "fake_linker")
155
+ self.ctx.remove_linker("python")
156
+ assert not self.ctx.has_linker("python")
157
+
158
+ def test_set_linker_raises_for_unregistered_language(self):
159
+ with pytest.raises(KeyError, match="not registered"):
160
+ self.ctx.set_linker("unknown", "fake_linker")
161
+
162
+ def test_get_linker_missing_language_logs_warning(self, caplog):
163
+ caplog.set_level("WARNING")
164
+ self.ctx.get_linker("unknown")
165
+ assert "not registered" in caplog.text
166
+
167
+ class TestSetSymbolCache:
168
+ def setup_method(self):
169
+ self.ctx = Context()
170
+ self.ctx.set_language("python", [".py"])
171
+ self.cache = MagicMock()
172
+ self.cache.language = "python"
173
+
174
+ def test_set_and_get_symbol_cache(self):
175
+ self.ctx.set_symbol_cache(self.cache)
176
+ assert self.ctx.get_symbol_cache("python") is self.cache
177
+
178
+ def test_has_symbol_cache(self):
179
+ assert not self.ctx.has_symbol_cache("python")
180
+ self.ctx.set_symbol_cache(self.cache)
181
+ assert self.ctx.has_symbol_cache("python")
182
+
183
+ def test_remove_symbol_cache(self):
184
+ self.ctx.set_symbol_cache(self.cache)
185
+ self.ctx.remove_symbol_cache("python")
186
+ assert not self.ctx.has_symbol_cache("python")
187
+
188
+ def test_set_symbol_cache_raises_for_unregistered_language(self):
189
+ cache = MagicMock()
190
+ cache.language = "unknown"
191
+ with pytest.raises(KeyError, match="not registered"):
192
+ self.ctx.set_symbol_cache(cache)
193
+
194
+ def test_get_symbol_cache_missing_language_logs_warning(self, caplog):
195
+ caplog.set_level("WARNING")
196
+ self.ctx.get_symbol_cache("unknown")
197
+ assert "not registered" in caplog.text
198
+
199
+ class TestCopyFrom:
200
+ def test_copy_preserves_language_config(self):
201
+ ctx = Context()
202
+ ctx.set_language("python", [".py"], aliases=["py"])
203
+ ctx.set_parser("python", "fake_parser")
204
+ copy = Context(copy_from=ctx)
205
+ assert copy.selected_languages == {"python"}
206
+ assert copy.selected_file_extensions == {".py"}
207
+ assert copy.get_parser("python") == "fake_parser"
208
+
209
+ def test_copy_is_independent(self):
210
+ ctx = Context()
211
+ ctx.set_language("python", [".py"])
212
+ ctx.set_parser("python", "fake_parser")
213
+ copy = Context(copy_from=ctx)
214
+ copy.set_parser("python", "different_parser")
215
+ assert ctx.get_parser("python") == "fake_parser"
216
+ assert copy.get_parser("python") == "different_parser"
217
+
218
+ def test_copy_by_default_empty(self):
219
+ ctx = Context()
220
+ assert ctx.selected_languages == set()
221
+ assert ctx.selected_file_extensions == set()
222
+
223
+ class TestReset:
224
+ def test_reset_restores_all_languages(self):
225
+ ctx = Context()
226
+ ctx.set_language("python", [".py"])
227
+ ctx.set_language("javascript", [".js"])
228
+ ctx.select_languages(["!python"])
229
+ ctx.reset()
230
+ assert ctx.selected_languages == {"python", "javascript"}
231
+
232
+ def test_reset_restores_all_file_extensions(self):
233
+ ctx = Context()
234
+ ctx.set_language("python", [".py", ".pyi"])
235
+ ctx.select_file_extensions(["!.py"])
236
+ ctx.reset()
237
+ assert ctx.selected_file_extensions == {".py", ".pyi"}
238
+
239
+ def test_reset_skips_languages_when_flag_false(self):
240
+ ctx = Context()
241
+ ctx.set_language("python", [".py"])
242
+ ctx.select_languages(["!python"])
243
+ ctx.reset(include_languages=False)
244
+ assert ctx.selected_languages == set()
245
+
246
+ class TestSkipPaths:
247
+ def test_default_is_empty(self):
248
+ ctx = Context()
249
+ assert ctx.skip_paths == set()
250
+
251
+ def test_set_skip_paths(self):
252
+ ctx = Context()
253
+ ctx.set_skip_paths({".venv", ".git"})
254
+ assert ctx.skip_paths == {".venv", ".git"}
255
+
256
+ def test_set_skip_paths_replaces(self):
257
+ ctx = Context()
258
+ ctx.set_skip_paths({".venv"})
259
+ ctx.set_skip_paths({".git"})
260
+ assert ctx.skip_paths == {".git"}
261
+
262
+ def test_copy_preserves_skip_paths(self):
263
+ ctx = Context()
264
+ ctx.set_skip_paths({".venv", "__pycache__"})
265
+ copy = Context(copy_from=ctx)
266
+ assert copy.skip_paths == {".venv", "__pycache__"}
267
+
268
+ def test_copy_is_independent(self):
269
+ ctx = Context()
270
+ ctx.set_skip_paths({".venv"})
271
+ copy = Context(copy_from=ctx)
272
+ copy.set_skip_paths({".git"})
273
+ assert ctx.skip_paths == {".venv"}
274
+ assert copy.skip_paths == {".git"}
275
+
276
+ class TestEmptyContext:
277
+ def test_empty_selected_languages(self):
278
+ ctx = Context()
279
+ assert ctx.selected_languages == set()
280
+
281
+ def test_empty_selected_file_extensions(self):
282
+ ctx = Context()
283
+ assert ctx.selected_file_extensions == set()
284
+
285
+ def test_get_parser_returns_none(self):
286
+ ctx = Context()
287
+ assert ctx.get_parser("anything") is None
288
+
289
+ def test_has_parser_returns_false(self):
290
+ ctx = Context()
291
+ assert not ctx.has_parser("anything")
292
+
293
+ def test_remove_parser_noop(self):
294
+ ctx = Context()
295
+ ctx.remove_parser("anything")
296
+
297
+ def test_get_resolver_returns_none(self):
298
+ ctx = Context()
299
+ assert ctx.get_resolver("anything") is None
300
+
301
+ def test_get_linker_returns_none(self):
302
+ ctx = Context()
303
+ assert ctx.get_linker("anything") is None
304
+
305
+ def test_get_symbol_cache_returns_none(self):
306
+ ctx = Context()
307
+ assert ctx.get_symbol_cache("anything") is None
@@ -0,0 +1,59 @@
1
+ from deproc.core.context import Context
2
+ from deproc.core.discovery import find_source_files
3
+ from pathlib import Path
4
+
5
+ class TestFindSourceFiles:
6
+ def test_skips_dirs_in_skip_paths(self, tmp_path):
7
+ (tmp_path / "a.py").write_text("")
8
+ (tmp_path / ".venv").mkdir()
9
+ (tmp_path / ".venv" / "b.py").write_text("")
10
+ (tmp_path / ".git").mkdir()
11
+ (tmp_path / ".git" / "c.py").write_text("")
12
+
13
+ ctx = Context(base_path=str(tmp_path))
14
+ ctx.set_language("python", [".py"])
15
+ ctx.set_skip_paths({".venv", ".git"})
16
+
17
+ files = find_source_files(ctx)
18
+ paths = {Path(f).name for f in files}
19
+ assert paths == {"a.py"}
20
+
21
+ def test_empty_skip_paths_finds_all(self, tmp_path):
22
+ (tmp_path / "a.py").write_text("")
23
+ (tmp_path / ".venv").mkdir()
24
+ (tmp_path / ".venv" / "b.py").write_text("")
25
+
26
+ ctx = Context(base_path=str(tmp_path))
27
+ ctx.set_language("python", [".py"])
28
+
29
+ files = find_source_files(ctx)
30
+ assert len(files) == 2
31
+
32
+ def test_glob_pattern_in_skip_paths(self, tmp_path):
33
+ (tmp_path / ".venv").mkdir()
34
+ (tmp_path / ".venv" / "b.py").write_text("")
35
+ (tmp_path / ".venv2").mkdir()
36
+ (tmp_path / ".venv2" / "c.py").write_text("")
37
+ (tmp_path / "a.py").write_text("")
38
+
39
+ ctx = Context(base_path=str(tmp_path))
40
+ ctx.set_language("python", [".py"])
41
+ ctx.set_skip_paths({".venv*"})
42
+
43
+ files = find_source_files(ctx)
44
+ paths = {Path(f).name for f in files}
45
+ assert paths == {"a.py"}
46
+
47
+ def test_glob_pattern_does_not_affect_non_matching(self, tmp_path):
48
+ (tmp_path / ".venv").mkdir()
49
+ (tmp_path / ".venv" / "b.py").write_text("")
50
+ (tmp_path / "vendor").mkdir()
51
+ (tmp_path / "vendor" / "a.py").write_text("")
52
+
53
+ ctx = Context(base_path=str(tmp_path))
54
+ ctx.set_language("python", [".py"])
55
+ ctx.set_skip_paths({"vendor*"})
56
+
57
+ files = find_source_files(ctx)
58
+ paths = {Path(f).name for f in files}
59
+ assert paths == {"b.py"}
@@ -0,0 +1,104 @@
1
+ """Tests for EntityRegistry."""
2
+ from dataclasses import dataclass
3
+ from deproc.core.runtime.registries.entity import EntityRegistry
4
+ from deproc.core.interfaces.parser.models import Entity
5
+
6
+ @dataclass(kw_only=True)
7
+ class _FakeFqnEntity(Entity):
8
+ fqn: str
9
+
10
+ class TestEntityRegistryValues:
11
+ def test_empty_registry_values(self):
12
+ registry = EntityRegistry()
13
+ assert list(registry.values()) == []
14
+
15
+ def test_values_after_add(self):
16
+ registry = EntityRegistry()
17
+ e1 = Entity(id="id_1")
18
+ e2 = Entity(id="id_2")
19
+ registry.add(e1)
20
+ registry.add(e2)
21
+ assert list(registry.values()) == [e1, e2]
22
+
23
+ def test_values_after_add_all(self):
24
+ registry = EntityRegistry()
25
+ e1 = Entity(id="id_1")
26
+ e2 = Entity(id="id_2")
27
+ registry.add_all([e1, e2])
28
+ assert list(registry.values()) == [e1, e2]
29
+
30
+ class TestEntityRegistryGet:
31
+ def test_get_returns_entity(self):
32
+ registry = EntityRegistry()
33
+ e = Entity(id="id_1")
34
+ registry.add(e)
35
+ assert registry.get("id_1") is e
36
+
37
+ def test_get_missing_returns_none(self):
38
+ registry = EntityRegistry()
39
+ assert registry.get("nonexistent") is None
40
+
41
+ def test_get_missing_returns_default(self):
42
+ registry = EntityRegistry()
43
+ assert registry.get("nonexistent", "fallback") == "fallback"
44
+
45
+ def test_get_with_none_default(self):
46
+ registry = EntityRegistry()
47
+ assert registry.get("nonexistent", None) is None
48
+
49
+ def test_get_default_not_used_when_found(self):
50
+ registry = EntityRegistry()
51
+ e = Entity(id="id_1")
52
+ registry.add(e)
53
+ assert registry.get("id_1", "fallback") is e
54
+
55
+ class TestEntityRegistryContains:
56
+ def test_contains_added(self):
57
+ registry = EntityRegistry()
58
+ e = Entity(id="id_1")
59
+ registry.add(e)
60
+ assert "id_1" in registry
61
+
62
+ def test_not_contains_missing(self):
63
+ registry = EntityRegistry()
64
+ assert "nonexistent" not in registry
65
+
66
+ class TestEntityRegistryFqnMapping:
67
+ def test_add_entity_with_fqn_updates_mapping(self):
68
+ registry = EntityRegistry()
69
+ e = _FakeFqnEntity(id="id_1", fqn="mymodule.MyClass")
70
+ registry.add(e)
71
+ assert registry.get_ids_by_fqn("mymodule.MyClass") == {"id_1"}
72
+
73
+ def test_add_entity_without_fqn_does_not_update_mapping(self):
74
+ registry = EntityRegistry()
75
+ e = Entity(id="id_1")
76
+ registry.add(e)
77
+ assert registry.get_ids_by_fqn("anything") == set()
78
+
79
+ def test_get_ids_by_fqn_returns_empty_for_missing(self):
80
+ registry = EntityRegistry()
81
+ assert registry.get_ids_by_fqn("nonexistent") == set()
82
+
83
+ def test_get_ids_by_fqn_multiple_entities_same_fqn(self):
84
+ registry = EntityRegistry()
85
+ e1 = _FakeFqnEntity(id="id_1", fqn="mymodule.MyClass")
86
+ e2 = _FakeFqnEntity(id="id_2", fqn="mymodule.MyClass")
87
+ registry.add(e1)
88
+ registry.add(e2)
89
+ assert registry.get_ids_by_fqn("mymodule.MyClass") == {"id_1", "id_2"}
90
+
91
+ def test_remove_entity_cleans_up_fqn_mapping(self):
92
+ registry = EntityRegistry()
93
+ e = _FakeFqnEntity(id="id_1", fqn="mymodule.MyClass")
94
+ registry.add(e)
95
+ registry.remove("id_1")
96
+ assert registry.get_ids_by_fqn("mymodule.MyClass") == set()
97
+ assert "id_1" not in registry
98
+
99
+ def test_remove_entity_without_fqn_does_not_error(self):
100
+ registry = EntityRegistry()
101
+ e = Entity(id="id_1")
102
+ registry.add(e)
103
+ registry.remove("id_1")
104
+ assert "id_1" not in registry
@@ -0,0 +1,173 @@
1
+ """Tests for deterministic entity ID generation."""
2
+ from dataclasses import dataclass
3
+ from uuid import uuid4
4
+ from deproc.core.interfaces.parser.models import (
5
+ Entity,
6
+ ImportStatement,
7
+ Node,
8
+ SourceFile,
9
+ SourceRange,
10
+ SimpleBinding,
11
+ VariableDeclaration,
12
+ )
13
+
14
+ _PARENT = uuid4().hex
15
+ _OTHER_PARENT = uuid4().hex
16
+
17
+ @dataclass(kw_only=True)
18
+ class _EntityWithSource(Entity):
19
+ source_range: SourceRange
20
+
21
+ class TestEntityDeterministicId:
22
+ def test_same_parent_and_source_range_produces_same_id(self):
23
+ sr = SourceRange(lineno=10, end_lineno=20, col_offset=1, end_col_offset=30)
24
+ e1 = _EntityWithSource(parent_id=_PARENT, source_range=sr)
25
+ e2 = _EntityWithSource(parent_id=_PARENT, source_range=sr)
26
+ assert e1.id == e2.id
27
+
28
+ def test_different_parent_id_produces_different_id(self):
29
+ sr = SourceRange(lineno=10, end_lineno=20, col_offset=1, end_col_offset=30)
30
+ e1 = _EntityWithSource(parent_id=_PARENT, source_range=sr)
31
+ e2 = _EntityWithSource(parent_id=_OTHER_PARENT, source_range=sr)
32
+ assert e1.id != e2.id
33
+
34
+ def test_different_source_range_produces_different_id(self):
35
+ sr1 = SourceRange(lineno=1, end_lineno=5, col_offset=0, end_col_offset=10)
36
+ sr2 = SourceRange(lineno=2, end_lineno=5, col_offset=0, end_col_offset=10)
37
+ e1 = _EntityWithSource(parent_id=_PARENT, source_range=sr1)
38
+ e2 = _EntityWithSource(parent_id=_PARENT, source_range=sr2)
39
+ assert e1.id != e2.id
40
+
41
+ def test_different_subclass_types_produce_different_ids(self):
42
+ sr = SourceRange(lineno=1, end_lineno=1, col_offset=0, end_col_offset=5)
43
+
44
+ @dataclass(kw_only=True)
45
+ class SubA(Entity):
46
+ source_range: SourceRange
47
+
48
+ @dataclass(kw_only=True)
49
+ class SubB(Entity):
50
+ source_range: SourceRange
51
+
52
+ a = SubA(parent_id=_PARENT, source_range=sr)
53
+ b = SubB(parent_id=_PARENT, source_range=sr)
54
+ assert a.id != b.id
55
+
56
+ def test_entity_without_parent_id_is_not_deterministic(self):
57
+ e1 = _EntityWithSource(
58
+ parent_id=None,
59
+ source_range=SourceRange(lineno=1, end_lineno=1, col_offset=0, end_col_offset=5),
60
+ )
61
+ e2 = _EntityWithSource(
62
+ parent_id=None,
63
+ source_range=SourceRange(lineno=1, end_lineno=1, col_offset=0, end_col_offset=5),
64
+ )
65
+ assert e1.id != e2.id
66
+
67
+ def test_entity_without_source_range_is_not_deterministic(self):
68
+ e1 = Entity(parent_id=_PARENT)
69
+ e2 = Entity(parent_id=_PARENT)
70
+ assert e1.id != e2.id
71
+
72
+ def test_explicit_id_is_preserved(self):
73
+ e = Entity(id="my-explicit-id")
74
+ assert e.id == "my-explicit-id"
75
+
76
+ def test_explicit_id_skips_computation(self):
77
+ sr = SourceRange(lineno=1, end_lineno=1, col_offset=0, end_col_offset=5)
78
+ e = _EntityWithSource(id="explicit", parent_id=_PARENT, source_range=sr)
79
+ assert e.id == "explicit"
80
+
81
+ class TestNodeDeterministicId:
82
+ def test_same_path_produces_same_id(self):
83
+ n1 = Node(path="src/main.py")
84
+ n2 = Node(path="src/main.py")
85
+ assert n1.id == n2.id
86
+
87
+ def test_different_path_produces_different_id(self):
88
+ n1 = Node(path="src/main.py")
89
+ n2 = Node(path="src/utils.py")
90
+ assert n1.id != n2.id
91
+
92
+ def test_node_id_is_32_hex_chars(self):
93
+ n = Node(path="src/main.py")
94
+ assert isinstance(n.id, str)
95
+ assert len(n.id) == 32
96
+
97
+ def test_source_file_id_is_deterministic(self):
98
+ sf1 = SourceFile(path="foo/bar.py", docstring_range=None, source="")
99
+ sf2 = SourceFile(path="foo/bar.py", docstring_range=None, source="")
100
+ assert sf1.id == sf2.id
101
+
102
+ def test_source_file_with_different_path_has_different_id(self):
103
+ sf1 = SourceFile(path="foo/bar.py", docstring_range=None, source="")
104
+ sf2 = SourceFile(path="foo/baz.py", docstring_range=None, source="")
105
+ assert sf1.id != sf2.id
106
+
107
+ def test_node_uses_path_not_parent_id(self):
108
+ @dataclass(kw_only=True)
109
+ class NodeSubclass(Node):
110
+ source_range: SourceRange
111
+
112
+ sr = SourceRange(lineno=1, end_lineno=1, col_offset=0, end_col_offset=5)
113
+ n1 = NodeSubclass(path="foo.py", parent_id=_PARENT, source_range=sr)
114
+ n2 = NodeSubclass(path="foo.py", parent_id=_OTHER_PARENT, source_range=sr)
115
+ assert n1.id == n2.id
116
+
117
+ def test_source_file_different_source_different_id(self):
118
+ sf1 = SourceFile(path="foo.py", docstring_range=None, source="a")
119
+ sf2 = SourceFile(path="foo.py", docstring_range=None, source="b")
120
+ assert sf1.id != sf2.id
121
+
122
+ def test_source_file_same_source_same_id(self):
123
+ sf1 = SourceFile(path="foo.py", docstring_range=None, source="hello")
124
+ sf2 = SourceFile(path="foo.py", docstring_range=None, source="hello")
125
+ assert sf1.id == sf2.id
126
+
127
+ class TestSourceRangeSourceId:
128
+ def test_source_id_defaults_to_none(self):
129
+ sr = SourceRange(lineno=1, end_lineno=10, col_offset=0, end_col_offset=5)
130
+ assert sr.source_id is None
131
+
132
+ def test_source_id_set_via_keyword(self):
133
+ sr = SourceRange(lineno=1, end_lineno=10, col_offset=0, end_col_offset=5, source_id="src123")
134
+ assert sr.source_id == "src123"
135
+
136
+ def test_source_id_not_in_entity_id_computation(self):
137
+ sr1 = SourceRange(lineno=5, end_lineno=5, col_offset=0, end_col_offset=10, source_id="file_a")
138
+ sr2 = SourceRange(lineno=5, end_lineno=5, col_offset=0, end_col_offset=10, source_id="file_b")
139
+ e1 = _EntityWithSource(parent_id=_PARENT, source_range=sr1)
140
+ e2 = _EntityWithSource(parent_id=_PARENT, source_range=sr2)
141
+ assert e1.id == e2.id
142
+
143
+ def test_source_id_mutable(self):
144
+ sr = SourceRange(lineno=1, end_lineno=1, col_offset=0, end_col_offset=0)
145
+ assert sr.source_id is None
146
+ sr.source_id = "assigned_later"
147
+ assert sr.source_id == "assigned_later"
148
+
149
+ class TestConcreteSubclassDeterminism:
150
+ def test_variable_declaration_with_parent_and_source_is_deterministic(self):
151
+ sr = SourceRange(lineno=5, end_lineno=5, col_offset=0, end_col_offset=10)
152
+ binding = SimpleBinding(name="x", fqn="mod.x")
153
+ v1 = VariableDeclaration(
154
+ parent_id=_PARENT,
155
+ source_range=sr,
156
+ variable_binding=binding,
157
+ value_range=None,
158
+ type_annotation=None,
159
+ )
160
+ v2 = VariableDeclaration(
161
+ parent_id=_PARENT,
162
+ source_range=sr,
163
+ variable_binding=binding,
164
+ value_range=None,
165
+ type_annotation=None,
166
+ )
167
+ assert v1.id == v2.id
168
+
169
+ def test_import_statement_with_parent_and_source_is_deterministic(self):
170
+ sr = SourceRange(lineno=1, end_lineno=1, col_offset=0, end_col_offset=20)
171
+ i1 = ImportStatement(type="from_import", parent_id=_PARENT, source_range=sr)
172
+ i2 = ImportStatement(type="from_import", parent_id=_PARENT, source_range=sr)
173
+ assert i1.id == i2.id