archunitpython 1.0.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 (75) hide show
  1. archunitpython/__init__.py +45 -0
  2. archunitpython/common/__init__.py +18 -0
  3. archunitpython/common/assertion/__init__.py +3 -0
  4. archunitpython/common/assertion/violation.py +21 -0
  5. archunitpython/common/error/__init__.py +3 -0
  6. archunitpython/common/error/errors.py +13 -0
  7. archunitpython/common/extraction/__init__.py +13 -0
  8. archunitpython/common/extraction/extract_graph.py +345 -0
  9. archunitpython/common/extraction/graph.py +39 -0
  10. archunitpython/common/fluentapi/__init__.py +3 -0
  11. archunitpython/common/fluentapi/checkable.py +28 -0
  12. archunitpython/common/logging/__init__.py +3 -0
  13. archunitpython/common/logging/types.py +18 -0
  14. archunitpython/common/pattern_matching.py +80 -0
  15. archunitpython/common/projection/__init__.py +30 -0
  16. archunitpython/common/projection/cycles/__init__.py +4 -0
  17. archunitpython/common/projection/cycles/cycle_utils.py +49 -0
  18. archunitpython/common/projection/cycles/cycles.py +26 -0
  19. archunitpython/common/projection/cycles/johnsons_apsp.py +110 -0
  20. archunitpython/common/projection/cycles/model.py +22 -0
  21. archunitpython/common/projection/cycles/tarjan_scc.py +86 -0
  22. archunitpython/common/projection/edge_projections.py +36 -0
  23. archunitpython/common/projection/project_cycles.py +85 -0
  24. archunitpython/common/projection/project_edges.py +43 -0
  25. archunitpython/common/projection/project_nodes.py +49 -0
  26. archunitpython/common/projection/types.py +40 -0
  27. archunitpython/common/regex_factory.py +76 -0
  28. archunitpython/common/types.py +29 -0
  29. archunitpython/common/util/__init__.py +3 -0
  30. archunitpython/common/util/declaration_detector.py +115 -0
  31. archunitpython/common/util/logger.py +100 -0
  32. archunitpython/files/__init__.py +3 -0
  33. archunitpython/files/assertion/__init__.py +28 -0
  34. archunitpython/files/assertion/custom_file_logic.py +107 -0
  35. archunitpython/files/assertion/cycle_free.py +29 -0
  36. archunitpython/files/assertion/depend_on_files.py +67 -0
  37. archunitpython/files/assertion/matching_files.py +64 -0
  38. archunitpython/files/fluentapi/__init__.py +3 -0
  39. archunitpython/files/fluentapi/files.py +403 -0
  40. archunitpython/metrics/__init__.py +3 -0
  41. archunitpython/metrics/assertion/__init__.py +0 -0
  42. archunitpython/metrics/assertion/metric_thresholds.py +51 -0
  43. archunitpython/metrics/calculation/__init__.py +0 -0
  44. archunitpython/metrics/calculation/count.py +148 -0
  45. archunitpython/metrics/calculation/distance.py +110 -0
  46. archunitpython/metrics/calculation/lcom.py +177 -0
  47. archunitpython/metrics/common/__init__.py +19 -0
  48. archunitpython/metrics/common/types.py +67 -0
  49. archunitpython/metrics/extraction/__init__.py +0 -0
  50. archunitpython/metrics/extraction/extract_class_info.py +246 -0
  51. archunitpython/metrics/fluentapi/__init__.py +3 -0
  52. archunitpython/metrics/fluentapi/export_utils.py +89 -0
  53. archunitpython/metrics/fluentapi/metrics.py +589 -0
  54. archunitpython/metrics/projection/__init__.py +0 -0
  55. archunitpython/py.typed +0 -0
  56. archunitpython/slices/__init__.py +3 -0
  57. archunitpython/slices/assertion/__init__.py +13 -0
  58. archunitpython/slices/assertion/admissible_edges.py +108 -0
  59. archunitpython/slices/fluentapi/__init__.py +3 -0
  60. archunitpython/slices/fluentapi/slices.py +220 -0
  61. archunitpython/slices/projection/__init__.py +8 -0
  62. archunitpython/slices/projection/slicing_projections.py +128 -0
  63. archunitpython/slices/uml/__init__.py +4 -0
  64. archunitpython/slices/uml/export_diagram.py +31 -0
  65. archunitpython/slices/uml/generate_rules.py +71 -0
  66. archunitpython/testing/__init__.py +3 -0
  67. archunitpython/testing/assertion.py +47 -0
  68. archunitpython/testing/common/__init__.py +4 -0
  69. archunitpython/testing/common/color_utils.py +57 -0
  70. archunitpython/testing/common/violation_factory.py +97 -0
  71. archunitpython/testing/pytest_plugin/__init__.py +0 -0
  72. archunitpython-1.0.0.dist-info/METADATA +660 -0
  73. archunitpython-1.0.0.dist-info/RECORD +75 -0
  74. archunitpython-1.0.0.dist-info/WHEEL +4 -0
  75. archunitpython-1.0.0.dist-info/licenses/LICENSE +7 -0
@@ -0,0 +1,403 @@
1
+ """Fluent API builder chain for file-level architecture rules.
2
+
3
+ Usage:
4
+ project_files('src/')
5
+ .in_folder('**/services/**')
6
+ .should()
7
+ .have_no_cycles()
8
+ .check()
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from collections.abc import Sequence
14
+
15
+ from archunitpython.common.assertion.violation import EmptyTestViolation, Violation
16
+ from archunitpython.common.extraction.extract_graph import extract_graph
17
+ from archunitpython.common.fluentapi.checkable import CheckOptions
18
+ from archunitpython.common.pattern_matching import matches_all_patterns
19
+ from archunitpython.common.projection.edge_projections import per_internal_edge
20
+ from archunitpython.common.projection.project_cycles import project_cycles
21
+ from archunitpython.common.projection.project_edges import project_edges
22
+ from archunitpython.common.projection.project_nodes import project_to_nodes
23
+ from archunitpython.common.projection.types import ProjectedNode
24
+ from archunitpython.common.regex_factory import RegexFactory
25
+ from archunitpython.common.types import Filter, Pattern
26
+ from archunitpython.files.assertion.custom_file_logic import (
27
+ CustomFileCondition,
28
+ gather_custom_file_violations,
29
+ )
30
+ from archunitpython.files.assertion.cycle_free import gather_cycle_violations
31
+ from archunitpython.files.assertion.depend_on_files import gather_depend_on_file_violations
32
+ from archunitpython.files.assertion.matching_files import gather_regex_matching_violations
33
+
34
+
35
+ def project_files(project_path: str | None = None) -> "FileConditionBuilder":
36
+ """Entry point for file-level architecture rules.
37
+
38
+ Args:
39
+ project_path: Root directory of the project to analyze.
40
+ Defaults to current working directory.
41
+ """
42
+ return FileConditionBuilder(project_path)
43
+
44
+
45
+ # Alias
46
+ files = project_files
47
+
48
+
49
+ class FileConditionBuilder:
50
+ """Initial builder for file rules - select files to apply rules to."""
51
+
52
+ def __init__(self, project_path: str | None = None) -> None:
53
+ self._project_path = project_path
54
+ self._filters: list[Filter] = []
55
+
56
+ def with_name(self, name: Pattern) -> "FilesShouldCondition":
57
+ """Filter files by filename pattern."""
58
+ self._filters.append(RegexFactory.filename_matcher(name))
59
+ return FilesShouldCondition(self._project_path, list(self._filters))
60
+
61
+ def in_folder(self, folder: Pattern) -> "FilesShouldCondition":
62
+ """Filter files by folder pattern."""
63
+ self._filters.append(RegexFactory.folder_matcher(folder))
64
+ return FilesShouldCondition(self._project_path, list(self._filters))
65
+
66
+ def in_path(self, path: Pattern) -> "FilesShouldCondition":
67
+ """Filter files by full path pattern."""
68
+ self._filters.append(RegexFactory.path_matcher(path))
69
+ return FilesShouldCondition(self._project_path, list(self._filters))
70
+
71
+ def should(self) -> "PositiveMatchPatternFileConditionBuilder":
72
+ """Begin positive assertion (files SHOULD ...)."""
73
+ return PositiveMatchPatternFileConditionBuilder(
74
+ self._project_path, list(self._filters)
75
+ )
76
+
77
+ def should_not(self) -> "NegatedMatchPatternFileConditionBuilder":
78
+ """Begin negative assertion (files SHOULD NOT ...)."""
79
+ return NegatedMatchPatternFileConditionBuilder(
80
+ self._project_path, list(self._filters)
81
+ )
82
+
83
+
84
+ class FilesShouldCondition:
85
+ """Intermediate builder that allows adding more filters or moving to assertions."""
86
+
87
+ def __init__(self, project_path: str | None, filters: list[Filter]) -> None:
88
+ self._project_path = project_path
89
+ self._filters = filters
90
+
91
+ def with_name(self, name: Pattern) -> "FilesShouldCondition":
92
+ """Add a filename pattern filter."""
93
+ self._filters.append(RegexFactory.filename_matcher(name))
94
+ return self
95
+
96
+ def in_folder(self, folder: Pattern) -> "FilesShouldCondition":
97
+ """Add a folder pattern filter."""
98
+ self._filters.append(RegexFactory.folder_matcher(folder))
99
+ return self
100
+
101
+ def in_path(self, path: Pattern) -> "FilesShouldCondition":
102
+ """Add a full path pattern filter."""
103
+ self._filters.append(RegexFactory.path_matcher(path))
104
+ return self
105
+
106
+ def should(self) -> "PositiveMatchPatternFileConditionBuilder":
107
+ """Begin positive assertion (files SHOULD ...)."""
108
+ return PositiveMatchPatternFileConditionBuilder(
109
+ self._project_path, list(self._filters)
110
+ )
111
+
112
+ def should_not(self) -> "NegatedMatchPatternFileConditionBuilder":
113
+ """Begin negative assertion (files SHOULD NOT ...)."""
114
+ return NegatedMatchPatternFileConditionBuilder(
115
+ self._project_path, list(self._filters)
116
+ )
117
+
118
+
119
+ class PositiveMatchPatternFileConditionBuilder:
120
+ """Positive assertion builder - files SHOULD have certain properties."""
121
+
122
+ def __init__(self, project_path: str | None, filters: list[Filter]) -> None:
123
+ self._project_path = project_path
124
+ self._filters = filters
125
+
126
+ def have_no_cycles(self) -> "CycleFreeFileCondition":
127
+ """Assert that files have no circular dependencies."""
128
+ return CycleFreeFileCondition(self._project_path, self._filters)
129
+
130
+ def depend_on_files(self) -> "DependOnFileConditionBuilder":
131
+ """Begin dependency assertion - files SHOULD depend on ..."""
132
+ return DependOnFileConditionBuilder(
133
+ self._project_path, self._filters, is_negated=False
134
+ )
135
+
136
+ def be_in_folder(self, folder: Pattern) -> "MatchPatternFileCondition":
137
+ """Assert that files are in a certain folder."""
138
+ return MatchPatternFileCondition(
139
+ self._project_path,
140
+ self._filters,
141
+ [RegexFactory.folder_matcher(folder)],
142
+ is_negated=False,
143
+ )
144
+
145
+ def have_name(self, name: Pattern) -> "MatchPatternFileCondition":
146
+ """Assert that files match a name pattern."""
147
+ return MatchPatternFileCondition(
148
+ self._project_path,
149
+ self._filters,
150
+ [RegexFactory.filename_matcher(name)],
151
+ is_negated=False,
152
+ )
153
+
154
+ def be_in_path(self, path: Pattern) -> "MatchPatternFileCondition":
155
+ """Assert that files match a path pattern."""
156
+ return MatchPatternFileCondition(
157
+ self._project_path,
158
+ self._filters,
159
+ [RegexFactory.path_matcher(path)],
160
+ is_negated=False,
161
+ )
162
+
163
+ def adhere_to(
164
+ self, condition: CustomFileCondition, message: str
165
+ ) -> "CustomFileCheckableCondition":
166
+ """Assert that files satisfy a custom condition."""
167
+ return CustomFileCheckableCondition(
168
+ self._project_path, self._filters, condition, message, is_negated=False
169
+ )
170
+
171
+
172
+ class NegatedMatchPatternFileConditionBuilder:
173
+ """Negative assertion builder - files SHOULD NOT have certain properties."""
174
+
175
+ def __init__(self, project_path: str | None, filters: list[Filter]) -> None:
176
+ self._project_path = project_path
177
+ self._filters = filters
178
+
179
+ def depend_on_files(self) -> "DependOnFileConditionBuilder":
180
+ """Begin dependency assertion - files SHOULD NOT depend on ..."""
181
+ return DependOnFileConditionBuilder(
182
+ self._project_path, self._filters, is_negated=True
183
+ )
184
+
185
+ def be_in_folder(self, folder: Pattern) -> "MatchPatternFileCondition":
186
+ """Assert that files are NOT in a certain folder."""
187
+ return MatchPatternFileCondition(
188
+ self._project_path,
189
+ self._filters,
190
+ [RegexFactory.folder_matcher(folder)],
191
+ is_negated=True,
192
+ )
193
+
194
+ def have_name(self, name: Pattern) -> "MatchPatternFileCondition":
195
+ """Assert that files do NOT match a name pattern."""
196
+ return MatchPatternFileCondition(
197
+ self._project_path,
198
+ self._filters,
199
+ [RegexFactory.filename_matcher(name)],
200
+ is_negated=True,
201
+ )
202
+
203
+ def be_in_path(self, path: Pattern) -> "MatchPatternFileCondition":
204
+ """Assert that files do NOT match a path pattern."""
205
+ return MatchPatternFileCondition(
206
+ self._project_path,
207
+ self._filters,
208
+ [RegexFactory.path_matcher(path)],
209
+ is_negated=True,
210
+ )
211
+
212
+ def adhere_to(
213
+ self, condition: CustomFileCondition, message: str
214
+ ) -> "CustomFileCheckableCondition":
215
+ """Assert that files do NOT satisfy a custom condition."""
216
+ return CustomFileCheckableCondition(
217
+ self._project_path, self._filters, condition, message, is_negated=True
218
+ )
219
+
220
+
221
+ class DependOnFileConditionBuilder:
222
+ """Configure dependency target patterns."""
223
+
224
+ def __init__(
225
+ self, project_path: str | None, filters: list[Filter], is_negated: bool
226
+ ) -> None:
227
+ self._project_path = project_path
228
+ self._filters = filters
229
+ self._is_negated = is_negated
230
+ self._object_filters: list[Filter] = []
231
+
232
+ def with_name(self, name: Pattern) -> "DependOnFileCondition":
233
+ """Target files matching a name pattern."""
234
+ self._object_filters.append(RegexFactory.filename_matcher(name))
235
+ return DependOnFileCondition(
236
+ self._project_path,
237
+ self._filters,
238
+ list(self._object_filters),
239
+ self._is_negated,
240
+ )
241
+
242
+ def in_folder(self, folder: Pattern) -> "DependOnFileCondition":
243
+ """Target files in a folder pattern."""
244
+ self._object_filters.append(RegexFactory.folder_matcher(folder))
245
+ return DependOnFileCondition(
246
+ self._project_path,
247
+ self._filters,
248
+ list(self._object_filters),
249
+ self._is_negated,
250
+ )
251
+
252
+ def in_path(self, path: Pattern) -> "DependOnFileCondition":
253
+ """Target files matching a path pattern."""
254
+ self._object_filters.append(RegexFactory.path_matcher(path))
255
+ return DependOnFileCondition(
256
+ self._project_path,
257
+ self._filters,
258
+ list(self._object_filters),
259
+ self._is_negated,
260
+ )
261
+
262
+
263
+ def _get_filtered_nodes(
264
+ project_path: str | None,
265
+ filters: list[Filter],
266
+ options: CheckOptions | None,
267
+ ) -> list[ProjectedNode]:
268
+ """Extract graph and get nodes matching filters."""
269
+ graph = extract_graph(project_path, options=options)
270
+ nodes = project_to_nodes(graph)
271
+ if not filters:
272
+ return nodes
273
+ return [n for n in nodes if matches_all_patterns(n.label, filters)]
274
+
275
+
276
+ def _check_empty_test(
277
+ filtered_items: Sequence[object],
278
+ filters: list[Filter],
279
+ is_negated: bool,
280
+ options: CheckOptions | None,
281
+ ) -> list[Violation] | None:
282
+ """Check for empty test condition."""
283
+ if not filtered_items and not (options and options.allow_empty_tests):
284
+ return [
285
+ EmptyTestViolation(
286
+ filters=filters,
287
+ message="No files found matching the specified patterns",
288
+ is_negated=is_negated,
289
+ )
290
+ ]
291
+ return None
292
+
293
+
294
+ class CycleFreeFileCondition:
295
+ """Checkable that verifies no cycles exist among filtered files."""
296
+
297
+ def __init__(self, project_path: str | None, filters: list[Filter]) -> None:
298
+ self._project_path = project_path
299
+ self._filters = filters
300
+
301
+ def check(self, options: CheckOptions | None = None) -> list[Violation]:
302
+ graph = extract_graph(self._project_path, options=options)
303
+ edges = project_edges(graph, per_internal_edge())
304
+
305
+ # Filter edges to only those involving our filtered files
306
+ if self._filters:
307
+ edges = [
308
+ e
309
+ for e in edges
310
+ if matches_all_patterns(e.source_label, self._filters)
311
+ and matches_all_patterns(e.target_label, self._filters)
312
+ ]
313
+
314
+ empty = _check_empty_test(edges, self._filters, False, options)
315
+ if empty is not None:
316
+ return empty
317
+
318
+ cycles = project_cycles(edges)
319
+ return gather_cycle_violations(cycles)
320
+
321
+
322
+ class DependOnFileCondition:
323
+ """Checkable that verifies file dependency rules."""
324
+
325
+ def __init__(
326
+ self,
327
+ project_path: str | None,
328
+ subject_filters: list[Filter],
329
+ object_filters: list[Filter],
330
+ is_negated: bool,
331
+ ) -> None:
332
+ self._project_path = project_path
333
+ self._subject_filters = subject_filters
334
+ self._object_filters = object_filters
335
+ self._is_negated = is_negated
336
+
337
+ def check(self, options: CheckOptions | None = None) -> list[Violation]:
338
+ graph = extract_graph(self._project_path, options=options)
339
+ edges = project_edges(graph, per_internal_edge())
340
+
341
+ return gather_depend_on_file_violations(
342
+ edges,
343
+ self._subject_filters,
344
+ self._object_filters,
345
+ self._is_negated,
346
+ )
347
+
348
+
349
+ class MatchPatternFileCondition:
350
+ """Checkable that verifies files match/don't match patterns."""
351
+
352
+ def __init__(
353
+ self,
354
+ project_path: str | None,
355
+ pre_filters: list[Filter],
356
+ check_filters: list[Filter],
357
+ is_negated: bool,
358
+ ) -> None:
359
+ self._project_path = project_path
360
+ self._pre_filters = pre_filters
361
+ self._check_filters = check_filters
362
+ self._is_negated = is_negated
363
+
364
+ def check(self, options: CheckOptions | None = None) -> list[Violation]:
365
+ nodes = _get_filtered_nodes(self._project_path, self._pre_filters, options)
366
+
367
+ empty = _check_empty_test(nodes, self._pre_filters, self._is_negated, options)
368
+ if empty is not None:
369
+ return empty
370
+
371
+ return gather_regex_matching_violations(
372
+ nodes, self._check_filters, self._is_negated
373
+ )
374
+
375
+
376
+ class CustomFileCheckableCondition:
377
+ """Checkable that evaluates a custom condition on files."""
378
+
379
+ def __init__(
380
+ self,
381
+ project_path: str | None,
382
+ filters: list[Filter],
383
+ condition: CustomFileCondition,
384
+ message: str,
385
+ is_negated: bool,
386
+ ) -> None:
387
+ self._project_path = project_path
388
+ self._filters = filters
389
+ self._condition = condition
390
+ self._message = message
391
+ self._is_negated = is_negated
392
+
393
+ def check(self, options: CheckOptions | None = None) -> list[Violation]:
394
+ graph = extract_graph(self._project_path, options=options)
395
+ nodes = project_to_nodes(graph)
396
+
397
+ return gather_custom_file_violations(
398
+ nodes,
399
+ self._condition,
400
+ self._message,
401
+ self._is_negated,
402
+ self._filters,
403
+ )
@@ -0,0 +1,3 @@
1
+ from archunitpython.metrics.fluentapi.metrics import metrics
2
+
3
+ __all__ = ["metrics"]
File without changes
@@ -0,0 +1,51 @@
1
+ """Metric threshold violations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ from archunitpython.common.assertion.violation import Violation
8
+ from archunitpython.metrics.common.types import MetricComparison
9
+
10
+
11
+ @dataclass
12
+ class MetricViolation(Violation):
13
+ """A class metric that exceeds its threshold."""
14
+
15
+ class_name: str
16
+ file_path: str
17
+ metric_name: str
18
+ metric_value: float
19
+ threshold: float
20
+ comparison: MetricComparison
21
+
22
+
23
+ @dataclass
24
+ class FileCountViolation(Violation):
25
+ """A file-level metric that exceeds its threshold."""
26
+
27
+ file_path: str
28
+ metric_name: str
29
+ metric_value: float
30
+ threshold: float
31
+ comparison: MetricComparison
32
+
33
+
34
+ def check_threshold(
35
+ value: float, threshold: float, comparison: MetricComparison
36
+ ) -> bool:
37
+ """Check if a value violates a threshold.
38
+
39
+ Returns True if the value is a VIOLATION.
40
+ """
41
+ if comparison == "below":
42
+ return value >= threshold
43
+ elif comparison == "above":
44
+ return value <= threshold
45
+ elif comparison == "equal":
46
+ return abs(value - threshold) > 1e-9
47
+ elif comparison == "below_equal":
48
+ return value > threshold
49
+ elif comparison == "above_equal":
50
+ return value < threshold
51
+ return False
File without changes
@@ -0,0 +1,148 @@
1
+ """Count metrics: method count, field count, lines of code, etc."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ast
6
+
7
+ from archunitpython.metrics.common.types import ClassInfo
8
+
9
+
10
+ class MethodCountMetric:
11
+ """Number of methods in a class."""
12
+
13
+ name = "MethodCount"
14
+ description = "Number of methods in a class"
15
+
16
+ def calculate(self, class_info: ClassInfo) -> float:
17
+ return float(len(class_info.methods))
18
+
19
+
20
+ class FieldCountMetric:
21
+ """Number of fields in a class."""
22
+
23
+ name = "FieldCount"
24
+ description = "Number of fields in a class"
25
+
26
+ def calculate(self, class_info: ClassInfo) -> float:
27
+ return float(len(class_info.fields))
28
+
29
+
30
+ class LinesOfCodeMetric:
31
+ """Number of non-blank, non-comment lines in a file."""
32
+
33
+ name = "LinesOfCode"
34
+ description = "Number of non-blank, non-comment lines"
35
+
36
+ def calculate_from_file(self, file_path: str) -> float:
37
+ try:
38
+ with open(file_path, "r", encoding="utf-8", errors="replace") as f:
39
+ lines = f.readlines()
40
+ except (OSError, IOError):
41
+ return 0.0
42
+
43
+ count = 0
44
+ for line in lines:
45
+ stripped = line.strip()
46
+ if stripped and not stripped.startswith("#"):
47
+ count += 1
48
+ return float(count)
49
+
50
+
51
+ class StatementCountMetric:
52
+ """Number of AST statements in a file."""
53
+
54
+ name = "StatementCount"
55
+ description = "Number of statements in a file"
56
+
57
+ def calculate_from_file(self, file_path: str) -> float:
58
+ try:
59
+ with open(file_path, "r", encoding="utf-8", errors="replace") as f:
60
+ source = f.read()
61
+ except (OSError, IOError):
62
+ return 0.0
63
+
64
+ try:
65
+ tree = ast.parse(source)
66
+ except SyntaxError:
67
+ return 0.0
68
+
69
+ count = 0
70
+ for node in ast.walk(tree):
71
+ if isinstance(node, ast.stmt):
72
+ count += 1
73
+ return float(count)
74
+
75
+
76
+ class ImportCountMetric:
77
+ """Number of import statements in a file."""
78
+
79
+ name = "ImportCount"
80
+ description = "Number of import statements in a file"
81
+
82
+ def calculate_from_file(self, file_path: str) -> float:
83
+ try:
84
+ with open(file_path, "r", encoding="utf-8", errors="replace") as f:
85
+ source = f.read()
86
+ except (OSError, IOError):
87
+ return 0.0
88
+
89
+ try:
90
+ tree = ast.parse(source)
91
+ except SyntaxError:
92
+ return 0.0
93
+
94
+ count = 0
95
+ for node in ast.walk(tree):
96
+ if isinstance(node, (ast.Import, ast.ImportFrom)):
97
+ count += 1
98
+ return float(count)
99
+
100
+
101
+ class ClassCountMetric:
102
+ """Number of class definitions in a file."""
103
+
104
+ name = "ClassCount"
105
+ description = "Number of class definitions in a file"
106
+
107
+ def calculate_from_file(self, file_path: str) -> float:
108
+ try:
109
+ with open(file_path, "r", encoding="utf-8", errors="replace") as f:
110
+ source = f.read()
111
+ except (OSError, IOError):
112
+ return 0.0
113
+
114
+ try:
115
+ tree = ast.parse(source)
116
+ except SyntaxError:
117
+ return 0.0
118
+
119
+ count = 0
120
+ for node in ast.walk(tree):
121
+ if isinstance(node, ast.ClassDef):
122
+ count += 1
123
+ return float(count)
124
+
125
+
126
+ class FunctionCountMetric:
127
+ """Number of top-level function definitions in a file."""
128
+
129
+ name = "FunctionCount"
130
+ description = "Number of top-level function definitions in a file"
131
+
132
+ def calculate_from_file(self, file_path: str) -> float:
133
+ try:
134
+ with open(file_path, "r", encoding="utf-8", errors="replace") as f:
135
+ source = f.read()
136
+ except (OSError, IOError):
137
+ return 0.0
138
+
139
+ try:
140
+ tree = ast.parse(source)
141
+ except SyntaxError:
142
+ return 0.0
143
+
144
+ count = 0
145
+ for node in tree.body:
146
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
147
+ count += 1
148
+ return float(count)