runmark 0.2.1__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 (105) hide show
  1. runmark/__init__.py +7 -0
  2. runmark/__main__.py +8 -0
  3. runmark/cli/__init__.py +5 -0
  4. runmark/cli/app.py +74 -0
  5. runmark/cli/commands/__init__.py +27 -0
  6. runmark/cli/commands/check.py +54 -0
  7. runmark/cli/commands/contract.py +236 -0
  8. runmark/cli/commands/diff.py +90 -0
  9. runmark/cli/commands/doctor.py +73 -0
  10. runmark/cli/commands/history.py +25 -0
  11. runmark/cli/commands/init.py +65 -0
  12. runmark/cli/commands/scan.py +25 -0
  13. runmark/cli/commands/share.py +104 -0
  14. runmark/cli/commands/snapshot.py +39 -0
  15. runmark/cli/commands/verify.py +75 -0
  16. runmark/cli/commands/version.py +36 -0
  17. runmark/contracts/__init__.py +60 -0
  18. runmark/contracts/canonicalizer.py +119 -0
  19. runmark/contracts/diff.py +390 -0
  20. runmark/contracts/discovery.py +33 -0
  21. runmark/contracts/evaluator.py +724 -0
  22. runmark/contracts/evidence.py +448 -0
  23. runmark/contracts/generator.py +107 -0
  24. runmark/contracts/parser.py +60 -0
  25. runmark/contracts/security.py +11 -0
  26. runmark/contracts/validator.py +157 -0
  27. runmark/contracts/version_constraints.py +234 -0
  28. runmark/core/__init__.py +32 -0
  29. runmark/core/contract_check.py +66 -0
  30. runmark/core/contract_diff.py +65 -0
  31. runmark/core/contract_init.py +80 -0
  32. runmark/core/diff.py +522 -0
  33. runmark/core/doctor.py +240 -0
  34. runmark/core/reporter.py +163 -0
  35. runmark/core/scanner.py +173 -0
  36. runmark/core/snapshot.py +34 -0
  37. runmark/core/verifier.py +83 -0
  38. runmark/detectors/__init__.py +86 -0
  39. runmark/detectors/base.py +66 -0
  40. runmark/detectors/containers/__init__.py +5 -0
  41. runmark/detectors/containers/compose.py +82 -0
  42. runmark/detectors/dependencies/__init__.py +9 -0
  43. runmark/detectors/dependencies/node.py +185 -0
  44. runmark/detectors/dependencies/python.py +162 -0
  45. runmark/detectors/environment/__init__.py +5 -0
  46. runmark/detectors/environment/env.py +89 -0
  47. runmark/detectors/git/__init__.py +5 -0
  48. runmark/detectors/git/git.py +81 -0
  49. runmark/detectors/network/__init__.py +5 -0
  50. runmark/detectors/network/ports.py +90 -0
  51. runmark/detectors/project/__init__.py +11 -0
  52. runmark/detectors/project/docker.py +48 -0
  53. runmark/detectors/project/node.py +95 -0
  54. runmark/detectors/project/python.py +97 -0
  55. runmark/detectors/registry.py +39 -0
  56. runmark/detectors/runtimes/__init__.py +13 -0
  57. runmark/detectors/runtimes/docker.py +59 -0
  58. runmark/detectors/runtimes/git.py +59 -0
  59. runmark/detectors/runtimes/node.py +59 -0
  60. runmark/detectors/runtimes/python.py +62 -0
  61. runmark/detectors/services/__init__.py +9 -0
  62. runmark/detectors/services/postgres.py +104 -0
  63. runmark/detectors/services/redis.py +104 -0
  64. runmark/detectors/system/__init__.py +5 -0
  65. runmark/detectors/system/system.py +40 -0
  66. runmark/models/__init__.py +81 -0
  67. runmark/models/common.py +36 -0
  68. runmark/models/container.py +14 -0
  69. runmark/models/contract.py +122 -0
  70. runmark/models/contract_result.py +84 -0
  71. runmark/models/dependency.py +19 -0
  72. runmark/models/diagnostic.py +133 -0
  73. runmark/models/environment.py +32 -0
  74. runmark/models/git.py +16 -0
  75. runmark/models/network.py +17 -0
  76. runmark/models/project.py +20 -0
  77. runmark/models/runmark.py +58 -0
  78. runmark/models/runtime.py +19 -0
  79. runmark/models/service.py +21 -0
  80. runmark/models/system.py +13 -0
  81. runmark/output/__init__.py +34 -0
  82. runmark/output/contract.py +337 -0
  83. runmark/output/json.py +16 -0
  84. runmark/output/markdown.py +172 -0
  85. runmark/output/tables.py +300 -0
  86. runmark/output/terminal.py +70 -0
  87. runmark/security/__init__.py +29 -0
  88. runmark/security/contract_sanitizer.py +38 -0
  89. runmark/security/export_sanitizer.py +142 -0
  90. runmark/security/redactor.py +75 -0
  91. runmark/security/sanitizer.py +105 -0
  92. runmark/security/secret_patterns.py +116 -0
  93. runmark/storage/__init__.py +6 -0
  94. runmark/storage/filesystem.py +133 -0
  95. runmark/storage/paths.py +60 -0
  96. runmark/utils/__init__.py +20 -0
  97. runmark/utils/commands.py +117 -0
  98. runmark/utils/hashing.py +240 -0
  99. runmark/utils/platform.py +36 -0
  100. runmark-0.2.1.dist-info/METADATA +149 -0
  101. runmark-0.2.1.dist-info/RECORD +105 -0
  102. runmark-0.2.1.dist-info/WHEEL +5 -0
  103. runmark-0.2.1.dist-info/entry_points.txt +2 -0
  104. runmark-0.2.1.dist-info/licenses/LICENSE +21 -0
  105. runmark-0.2.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,390 @@
1
+ """Semantic contract diffing engine comparing environment requirements."""
2
+
3
+ from enum import Enum
4
+ from typing import Any
5
+
6
+ from runmark.models.common import RunmarkBaseModel
7
+ from runmark.models.contract import RunmarkContract
8
+
9
+
10
+ class ContractChangeKind(str, Enum):
11
+ """Classification of requirement differences."""
12
+
13
+ ADDED = "ADDED"
14
+ REMOVED = "REMOVED"
15
+ CHANGED = "CHANGED"
16
+ UNCHANGED = "UNCHANGED"
17
+
18
+
19
+ class ContractChange(RunmarkBaseModel):
20
+ """A discrete difference between two contract definitions."""
21
+
22
+ category: str
23
+ name: str
24
+ change: ContractChangeKind
25
+ before: Any | None = None
26
+ after: Any | None = None
27
+ details: dict[str, Any] = {}
28
+
29
+
30
+ class ContractDiffSummary(RunmarkBaseModel):
31
+ """Aggregated counters for contract changes."""
32
+
33
+ added: int = 0
34
+ removed: int = 0
35
+ changed: int = 0
36
+ unchanged: int = 0
37
+
38
+
39
+ class ContractDiffResult(RunmarkBaseModel):
40
+ """Root model for semantic contract comparison results."""
41
+
42
+ status: str # "CHANGED" or "UNCHANGED"
43
+ summary: ContractDiffSummary
44
+ changes: list[ContractChange]
45
+
46
+ @property
47
+ def has_changes(self) -> bool:
48
+ """Return True if any requirements were added, removed, or changed."""
49
+ return self.summary.added > 0 or self.summary.removed > 0 or self.summary.changed > 0
50
+
51
+
52
+ class ContractDiffEngine:
53
+ """Performs semantic comparison between two RunmarkContract definitions."""
54
+
55
+ @classmethod
56
+ def diff(cls, before: RunmarkContract, after: RunmarkContract) -> ContractDiffResult:
57
+ """Compare two contracts and produce a normalized list of semantic changes."""
58
+ changes: list[ContractChange] = []
59
+
60
+ # 1. Project identity
61
+ b_name = before.project.name
62
+ a_name = after.project.name
63
+ if b_name != a_name:
64
+ changes.append(
65
+ ContractChange(
66
+ category="project",
67
+ name="name",
68
+ change=ContractChangeKind.CHANGED,
69
+ before=b_name,
70
+ after=a_name,
71
+ )
72
+ )
73
+
74
+ # 2. Platform OS
75
+ b_os = set(before.platform.os)
76
+ a_os = set(after.platform.os)
77
+ for os_name in sorted(a_os - b_os):
78
+ changes.append(
79
+ ContractChange(
80
+ category="platform",
81
+ name=f"os:{os_name}",
82
+ change=ContractChangeKind.ADDED,
83
+ before=None,
84
+ after=os_name,
85
+ )
86
+ )
87
+ for os_name in sorted(b_os - a_os):
88
+ changes.append(
89
+ ContractChange(
90
+ category="platform",
91
+ name=f"os:{os_name}",
92
+ change=ContractChangeKind.REMOVED,
93
+ before=os_name,
94
+ after=None,
95
+ )
96
+ )
97
+
98
+ # Platform Architecture
99
+ b_arch = set(before.platform.architecture)
100
+ a_arch = set(after.platform.architecture)
101
+ for arch_name in sorted(a_arch - b_arch):
102
+ changes.append(
103
+ ContractChange(
104
+ category="platform",
105
+ name=f"arch:{arch_name}",
106
+ change=ContractChangeKind.ADDED,
107
+ before=None,
108
+ after=arch_name,
109
+ )
110
+ )
111
+ for arch_name in sorted(b_arch - a_arch):
112
+ changes.append(
113
+ ContractChange(
114
+ category="platform",
115
+ name=f"arch:{arch_name}",
116
+ change=ContractChangeKind.REMOVED,
117
+ before=arch_name,
118
+ after=None,
119
+ )
120
+ )
121
+
122
+ # 3. Runtimes
123
+ all_runtimes = sorted(set(before.runtime.keys()) | set(after.runtime.keys()))
124
+ for r_name in all_runtimes:
125
+ b_val = before.runtime.get(r_name)
126
+ a_val = after.runtime.get(r_name)
127
+ if b_val is None and a_val is not None:
128
+ changes.append(
129
+ ContractChange(
130
+ category="runtime",
131
+ name=r_name,
132
+ change=ContractChangeKind.ADDED,
133
+ before=None,
134
+ after=a_val,
135
+ )
136
+ )
137
+ elif b_val is not None and a_val is None:
138
+ changes.append(
139
+ ContractChange(
140
+ category="runtime",
141
+ name=r_name,
142
+ change=ContractChangeKind.REMOVED,
143
+ before=b_val,
144
+ after=None,
145
+ )
146
+ )
147
+ elif b_val != a_val:
148
+ changes.append(
149
+ ContractChange(
150
+ category="runtime",
151
+ name=r_name,
152
+ change=ContractChangeKind.CHANGED,
153
+ before=b_val,
154
+ after=a_val,
155
+ )
156
+ )
157
+
158
+ # 4. Dependencies
159
+ cls._diff_deps(
160
+ "dependency:python", before.dependencies.python, after.dependencies.python, changes
161
+ )
162
+ cls._diff_deps(
163
+ "dependency:node", before.dependencies.node, after.dependencies.node, changes
164
+ )
165
+
166
+ # 5. Services
167
+ all_services = sorted(set(before.services.keys()) | set(after.services.keys()))
168
+ for s_name in all_services:
169
+ b_svc = before.services.get(s_name)
170
+ a_svc = after.services.get(s_name)
171
+ if b_svc is None and a_svc is not None:
172
+ changes.append(
173
+ ContractChange(
174
+ category="service",
175
+ name=s_name,
176
+ change=ContractChangeKind.ADDED,
177
+ before=None,
178
+ after=a_svc.version,
179
+ details={"required": a_svc.required},
180
+ )
181
+ )
182
+ elif b_svc is not None and a_svc is None:
183
+ changes.append(
184
+ ContractChange(
185
+ category="service",
186
+ name=s_name,
187
+ change=ContractChangeKind.REMOVED,
188
+ before=b_svc.version,
189
+ after=None,
190
+ details={"required": b_svc.required},
191
+ )
192
+ )
193
+ elif b_svc is not None and a_svc is not None:
194
+ if b_svc.version != a_svc.version or b_svc.required != a_svc.required:
195
+ changes.append(
196
+ ContractChange(
197
+ category="service",
198
+ name=s_name,
199
+ change=ContractChangeKind.CHANGED,
200
+ before=b_svc.version,
201
+ after=a_svc.version,
202
+ details={
203
+ "before_required": b_svc.required,
204
+ "after_required": a_svc.required,
205
+ },
206
+ )
207
+ )
208
+
209
+ # 6. Environment variables
210
+ b_req = set(before.environment.required)
211
+ a_req = set(after.environment.required)
212
+ for var in sorted(a_req - b_req):
213
+ changes.append(
214
+ ContractChange(
215
+ category="environment",
216
+ name=var,
217
+ change=ContractChangeKind.ADDED,
218
+ before=None,
219
+ after="required",
220
+ )
221
+ )
222
+ for var in sorted(b_req - a_req):
223
+ changes.append(
224
+ ContractChange(
225
+ category="environment",
226
+ name=var,
227
+ change=ContractChangeKind.REMOVED,
228
+ before="required",
229
+ after=None,
230
+ )
231
+ )
232
+
233
+ b_opt = set(before.environment.optional)
234
+ a_opt = set(after.environment.optional)
235
+ for var in sorted(a_opt - b_opt):
236
+ changes.append(
237
+ ContractChange(
238
+ category="environment",
239
+ name=var,
240
+ change=ContractChangeKind.ADDED,
241
+ before=None,
242
+ after="optional",
243
+ )
244
+ )
245
+ for var in sorted(b_opt - a_opt):
246
+ changes.append(
247
+ ContractChange(
248
+ category="environment",
249
+ name=var,
250
+ change=ContractChangeKind.REMOVED,
251
+ before="optional",
252
+ after=None,
253
+ )
254
+ )
255
+
256
+ # 7. Network Ports
257
+ all_ports = sorted(
258
+ set(before.network.ports.keys()) | set(after.network.ports.keys()),
259
+ key=lambda x: int(x) if x.isdigit() else x,
260
+ )
261
+ for p_str in all_ports:
262
+ b_p = before.network.ports.get(p_str)
263
+ a_p = after.network.ports.get(p_str)
264
+ if b_p is None and a_p is not None:
265
+ changes.append(
266
+ ContractChange(
267
+ category="network",
268
+ name=f"port:{p_str}/{a_p.protocol}",
269
+ change=ContractChangeKind.ADDED,
270
+ before=None,
271
+ after=f"{p_str}/{a_p.protocol}",
272
+ )
273
+ )
274
+ elif b_p is not None and a_p is None:
275
+ changes.append(
276
+ ContractChange(
277
+ category="network",
278
+ name=f"port:{p_str}/{b_p.protocol}",
279
+ change=ContractChangeKind.REMOVED,
280
+ before=f"{p_str}/{b_p.protocol}",
281
+ after=None,
282
+ )
283
+ )
284
+ elif b_p is not None and a_p is not None:
285
+ if b_p.protocol != a_p.protocol or b_p.required != a_p.required:
286
+ changes.append(
287
+ ContractChange(
288
+ category="network",
289
+ name=f"port:{p_str}",
290
+ change=ContractChangeKind.CHANGED,
291
+ before=f"{b_p.protocol} (required={b_p.required})",
292
+ after=f"{a_p.protocol} (required={a_p.required})",
293
+ )
294
+ )
295
+
296
+ # 8. Containers
297
+ b_docker = before.containers.docker.required if before.containers.docker else False
298
+ a_docker = after.containers.docker.required if after.containers.docker else False
299
+ if b_docker != a_docker:
300
+ changes.append(
301
+ ContractChange(
302
+ category="container",
303
+ name="docker",
304
+ change=ContractChangeKind.CHANGED,
305
+ before=f"required={b_docker}",
306
+ after=f"required={a_docker}",
307
+ )
308
+ )
309
+
310
+ b_compose = before.containers.compose.required if before.containers.compose else False
311
+ a_compose = after.containers.compose.required if after.containers.compose else False
312
+ if b_compose != a_compose:
313
+ changes.append(
314
+ ContractChange(
315
+ category="container",
316
+ name="compose",
317
+ change=ContractChangeKind.CHANGED,
318
+ before=f"required={b_compose}",
319
+ after=f"required={a_compose}",
320
+ )
321
+ )
322
+
323
+ # Compute summary
324
+ added_count = sum(1 for c in changes if c.change == ContractChangeKind.ADDED)
325
+ removed_count = sum(1 for c in changes if c.change == ContractChangeKind.REMOVED)
326
+ changed_count = sum(1 for c in changes if c.change == ContractChangeKind.CHANGED)
327
+ unchanged_count = sum(1 for c in changes if c.change == ContractChangeKind.UNCHANGED)
328
+
329
+ summary = ContractDiffSummary(
330
+ added=added_count,
331
+ removed=removed_count,
332
+ changed=changed_count,
333
+ unchanged=unchanged_count,
334
+ )
335
+
336
+ status = (
337
+ "CHANGED"
338
+ if (added_count > 0 or removed_count > 0 or changed_count > 0)
339
+ else "UNCHANGED"
340
+ )
341
+
342
+ return ContractDiffResult(
343
+ status=status,
344
+ summary=summary,
345
+ changes=changes,
346
+ )
347
+
348
+ @classmethod
349
+ def _diff_deps(
350
+ cls,
351
+ cat: str,
352
+ before_deps: dict[str, str],
353
+ after_deps: dict[str, str],
354
+ changes: list[ContractChange],
355
+ ) -> None:
356
+ """Helper to diff package dependencies."""
357
+ all_pkgs = sorted(set(before_deps.keys()) | set(after_deps.keys()))
358
+ for pkg in all_pkgs:
359
+ b_v = before_deps.get(pkg)
360
+ a_v = after_deps.get(pkg)
361
+ if b_v is None and a_v is not None:
362
+ changes.append(
363
+ ContractChange(
364
+ category=cat,
365
+ name=pkg,
366
+ change=ContractChangeKind.ADDED,
367
+ before=None,
368
+ after=a_v,
369
+ )
370
+ )
371
+ elif b_v is not None and a_v is None:
372
+ changes.append(
373
+ ContractChange(
374
+ category=cat,
375
+ name=pkg,
376
+ change=ContractChangeKind.REMOVED,
377
+ before=b_v,
378
+ after=None,
379
+ )
380
+ )
381
+ elif b_v != a_v:
382
+ changes.append(
383
+ ContractChange(
384
+ category=cat,
385
+ name=pkg,
386
+ change=ContractChangeKind.CHANGED,
387
+ before=b_v,
388
+ after=a_v,
389
+ )
390
+ )
@@ -0,0 +1,33 @@
1
+ """Contract file discovery utilities for Runmark."""
2
+
3
+ from pathlib import Path
4
+
5
+ DEFAULT_CONTRACT_FILENAME = "runmark.json"
6
+
7
+
8
+ def find_contract_file(target_path: Path | str | None = None) -> Path:
9
+ """Locate the runmark.json contract file in the target directory or path.
10
+
11
+ Raises FileNotFoundError if runmark.json does not exist.
12
+ """
13
+ if target_path is not None:
14
+ p = Path(target_path).resolve()
15
+ if p.is_file() and p.exists():
16
+ return p
17
+ if p.is_dir():
18
+ candidate = p / DEFAULT_CONTRACT_FILENAME
19
+ if candidate.exists() and candidate.is_file():
20
+ return candidate
21
+ raise FileNotFoundError(
22
+ f"Contract file '{DEFAULT_CONTRACT_FILENAME}' not found in '{p}'."
23
+ )
24
+ raise FileNotFoundError(f"Target path '{p}' does not exist.")
25
+
26
+ # Search in current working directory
27
+ cwd_candidate = Path.cwd() / DEFAULT_CONTRACT_FILENAME
28
+ if cwd_candidate.exists() and cwd_candidate.is_file():
29
+ return cwd_candidate
30
+
31
+ raise FileNotFoundError(
32
+ f"Contract file '{DEFAULT_CONTRACT_FILENAME}' not found in current directory ({Path.cwd()})."
33
+ )