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,25 @@
1
+ """CLI command: runmark scan."""
2
+
3
+ from pathlib import Path
4
+
5
+ import typer
6
+
7
+ from runmark.core.scanner import Scanner
8
+ from runmark.output.tables import render_scan
9
+ from runmark.output.terminal import term
10
+ from runmark.storage.paths import find_project_root
11
+
12
+
13
+ def scan_command(
14
+ path: Path = typer.Option(None, "--path", "-p", help="Target project root directory"),
15
+ json_output: bool = typer.Option(False, "--json", help="Output result as JSON"),
16
+ ) -> None:
17
+ """Inspect and display the complete runtime, dependency, and service state."""
18
+ root = find_project_root(path)
19
+ scanner = Scanner(root)
20
+ state = scanner.scan()
21
+
22
+ if json_output:
23
+ term.print_json(state)
24
+ else:
25
+ render_scan(state, term.console)
@@ -0,0 +1,104 @@
1
+ """CLI command: runmark share."""
2
+
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ import typer
7
+
8
+ from runmark.core.reporter import Reporter
9
+ from runmark.output.terminal import term
10
+ from runmark.security.export_sanitizer import SecurityViolationError
11
+ from runmark.storage.paths import find_project_root
12
+
13
+
14
+ def share_command(
15
+ output: Path | None = typer.Option(
16
+ None,
17
+ "--output",
18
+ "-o",
19
+ help="Target output report path (e.g. report.md, report.json)",
20
+ ),
21
+ json_output: bool = typer.Option(
22
+ False,
23
+ "--json",
24
+ help="Generate report in structured JSON format instead of Markdown",
25
+ ),
26
+ stdout: bool = typer.Option(
27
+ False,
28
+ "--stdout",
29
+ help="Print only the raw sanitized report content directly to stdout",
30
+ ),
31
+ force: bool = typer.Option(
32
+ False,
33
+ "--force",
34
+ "-f",
35
+ help="Overwrite existing output file if it exists",
36
+ ),
37
+ path: Path | None = typer.Option(
38
+ None,
39
+ "--path",
40
+ "-p",
41
+ help="Target project root directory (defaults to current directory)",
42
+ ),
43
+ ) -> None:
44
+ """Generate a sanitized, portable diagnostic report to safely share environment state."""
45
+ if path is not None and not path.exists():
46
+ term.print_error(f"Project directory '{path}' not found.")
47
+ raise typer.Exit(code=2)
48
+ root = find_project_root(path)
49
+
50
+ try:
51
+ report = Reporter.generate_report(root)
52
+ except SecurityViolationError as sec_err:
53
+ term.print_error(str(sec_err))
54
+ raise typer.Exit(code=4) from None
55
+ except Exception as exc:
56
+ term.print_error(f"Failed to generate diagnostic report: {exc}")
57
+ raise typer.Exit(code=3) from None
58
+
59
+ if stdout:
60
+ try:
61
+ if json_output:
62
+ content = Reporter.render_json(report)
63
+ else:
64
+ content = Reporter.render_markdown(report)
65
+ sys.stdout.write(content + "\n")
66
+ sys.stdout.flush()
67
+ return
68
+ except SecurityViolationError as sec_err:
69
+ term.print_error(str(sec_err))
70
+ raise typer.Exit(code=4) from None
71
+ except Exception as exc:
72
+ term.print_error(f"Failed to render report: {exc}")
73
+ raise typer.Exit(code=3) from None
74
+
75
+ dest = output
76
+ if dest is None:
77
+ default_name = "runmark-report.json" if json_output else "runmark-report.md"
78
+ dest = root / default_name
79
+
80
+ try:
81
+ out_path = Reporter.export_to_file(
82
+ report=report,
83
+ destination=dest,
84
+ as_json=json_output,
85
+ force=force,
86
+ )
87
+ term.print_success(f"Sanitized diagnostic report written to: {out_path}")
88
+ term.print(
89
+ f"[dim]Report ID: {report.metadata.report_id} | "
90
+ f"Fingerprint: {report.metadata.environment_fingerprint}[/dim]"
91
+ )
92
+ if not json_output:
93
+ term.print(
94
+ "[dim]You can now safely attach this Markdown report to GitHub Issues, Slack, or Discussions.[/dim]"
95
+ )
96
+ except SecurityViolationError as sec_err:
97
+ term.print_error(str(sec_err))
98
+ raise typer.Exit(code=4) from None
99
+ except FileExistsError as file_err:
100
+ term.print_error(str(file_err))
101
+ raise typer.Exit(code=2) from None
102
+ except Exception as exc:
103
+ term.print_error(f"Failed to export report: {exc}")
104
+ raise typer.Exit(code=3) from None
@@ -0,0 +1,39 @@
1
+ """CLI command: runmark snapshot."""
2
+
3
+ from pathlib import Path
4
+
5
+ import typer
6
+ from rich.panel import Panel
7
+
8
+ from runmark.core.snapshot import SnapshotManager
9
+ from runmark.output.terminal import term
10
+ from runmark.storage.paths import find_project_root
11
+
12
+
13
+ def snapshot_command(
14
+ message: str | None = typer.Option(
15
+ None, "--message", "-m", help="Optional description of the snapshot"
16
+ ),
17
+ path: Path = typer.Option(None, "--path", "-p", help="Target project root directory"),
18
+ json_output: bool = typer.Option(False, "--json", help="Output result as JSON"),
19
+ ) -> None:
20
+ """Capture and persist the current environment state into an immutable baseline snapshot."""
21
+ root = find_project_root(path)
22
+ mgr = SnapshotManager(root)
23
+ state = mgr.create_snapshot(message=message)
24
+
25
+ if json_output:
26
+ term.print_json(state)
27
+ else:
28
+ term.print(
29
+ Panel(
30
+ f"[bold green]✓ Snapshot Captured & Saved[/bold green]\n\n"
31
+ f"• Snapshot ID: [bold cyan]{state.runmark.id}[/bold cyan]\n"
32
+ f"• Fingerprint: [bold green]{state.runmark.environment_fingerprint}[/bold green]\n"
33
+ f"• Tool Version: [dim]{state.runmark.tool_version}[/dim]\n"
34
+ f"• Project: [bold]{state.project.name}[/bold]\n"
35
+ f"• Git Commit: [dim]{state.git.commit or 'none'}[/dim]\n"
36
+ f"{f'• Note: [italic]{state.runmark.message}[/italic]' if state.runmark.message else ''}",
37
+ border_style="green",
38
+ )
39
+ )
@@ -0,0 +1,75 @@
1
+ """CLI command: runmark verify."""
2
+
3
+ from pathlib import Path
4
+
5
+ import typer
6
+
7
+ from runmark.core.scanner import Scanner
8
+ from runmark.core.snapshot import SnapshotManager
9
+ from runmark.core.verifier import Verifier
10
+ from runmark.models.runmark import RunmarkState
11
+ from runmark.output.tables import render_verify
12
+ from runmark.output.terminal import term
13
+ from runmark.storage.paths import find_project_root
14
+
15
+
16
+ def verify_command(
17
+ snapshot: str | None = typer.Option(
18
+ None,
19
+ "--snapshot",
20
+ "-s",
21
+ help="Target baseline snapshot ID (defaults to current active snapshot)",
22
+ ),
23
+ strict: bool = typer.Option(
24
+ False,
25
+ "--strict",
26
+ help="Treat warnings as verification failures",
27
+ ),
28
+ path: Path = typer.Option(None, "--path", "-p", help="Target project root directory"),
29
+ json_output: bool = typer.Option(False, "--json", help="Output result as JSON"),
30
+ ) -> None:
31
+ """Verify that current environment matches expected baseline snapshot."""
32
+ root = find_project_root(path)
33
+ mgr = SnapshotManager(root)
34
+
35
+ expected_state: RunmarkState
36
+ if snapshot:
37
+ try:
38
+ expected_state = mgr.get_snapshot(snapshot)
39
+ except FileNotFoundError:
40
+ term.print_error(f"Snapshot '{snapshot}' not found.")
41
+ raise typer.Exit(code=Verifier.EXIT_CODE_INVALID_USAGE) from None
42
+ else:
43
+ loaded_exp = mgr.get_current()
44
+ if not loaded_exp:
45
+ term.print_error(
46
+ "No baseline snapshot found. Create one with 'runmark snapshot' or specify --snapshot."
47
+ )
48
+ raise typer.Exit(code=Verifier.EXIT_CODE_INVALID_USAGE)
49
+ expected_state = loaded_exp
50
+
51
+ try:
52
+ scanner = Scanner(root)
53
+ current_state = scanner.scan()
54
+ except Exception as exc:
55
+ term.print_error(f"Scanning failed during verification: {exc}")
56
+ raise typer.Exit(code=Verifier.EXIT_CODE_INTERNAL_ERROR) from exc
57
+
58
+ verifier = Verifier(strict=strict)
59
+ result = verifier.verify(expected_state, current_state)
60
+
61
+ if json_output:
62
+ term.print_json(
63
+ {
64
+ "status": result.status.value,
65
+ "exit_code": result.exit_code,
66
+ "expected_fingerprint": expected_state.runmark.environment_fingerprint,
67
+ "current_fingerprint": current_state.runmark.environment_fingerprint,
68
+ "reasons": result.reasons,
69
+ "diff_items_count": len(result.diff.environment_items),
70
+ }
71
+ )
72
+ else:
73
+ render_verify(result, term.console)
74
+
75
+ raise typer.Exit(code=result.exit_code)
@@ -0,0 +1,36 @@
1
+ """CLI command: runmark version."""
2
+
3
+ import platform
4
+
5
+ import typer
6
+ from rich.panel import Panel
7
+
8
+ from runmark import __schema_version__, __version__
9
+ from runmark.output.terminal import term
10
+
11
+
12
+ def version_command(
13
+ json_output: bool = typer.Option(False, "--json", help="Output result as JSON"),
14
+ ) -> None:
15
+ """Display Runmark tool version, schema version, and platform information."""
16
+ info = {
17
+ "tool_version": __version__,
18
+ "schema_version": __schema_version__,
19
+ "python_version": platform.python_version(),
20
+ "platform": platform.system(),
21
+ "architecture": platform.machine(),
22
+ }
23
+
24
+ if json_output:
25
+ term.print_json(info)
26
+ else:
27
+ term.print(
28
+ Panel(
29
+ f"[bold cyan]Runmark[/bold cyan] [bold]{__version__}[/bold]\n\n"
30
+ f"• Schema Version: [cyan]{__schema_version__}[/cyan]\n"
31
+ f"• Python Runtime: [dim]{platform.python_version()}[/dim]\n"
32
+ f"• OS / Platform: [dim]{platform.system()} ({platform.machine()})[/dim]\n\n"
33
+ f"[italic dim]Git tracks your code. Runmark tracks what makes your code run.[/italic dim]",
34
+ border_style="cyan",
35
+ )
36
+ )
@@ -0,0 +1,60 @@
1
+ """Runmark environment contracts package."""
2
+
3
+ from runmark.contracts.canonicalizer import ContractCanonicalizer
4
+ from runmark.contracts.diff import (
5
+ ContractChange,
6
+ ContractChangeKind,
7
+ ContractDiffEngine,
8
+ ContractDiffResult,
9
+ ContractDiffSummary,
10
+ )
11
+ from runmark.contracts.discovery import find_contract_file
12
+ from runmark.contracts.evaluator import ContractEvaluator
13
+ from runmark.contracts.evidence import (
14
+ EvidenceCollector,
15
+ EvidenceLevel,
16
+ EvidenceSignal,
17
+ ProjectEvidence,
18
+ )
19
+ from runmark.contracts.generator import ContractGenerator
20
+ from runmark.contracts.parser import ContractParseError, ContractParser
21
+ from runmark.contracts.security import (
22
+ ContractSanitizer,
23
+ ContractSecurityError,
24
+ )
25
+ from runmark.contracts.validator import (
26
+ ContractValidationError,
27
+ ContractValidator,
28
+ )
29
+ from runmark.contracts.version_constraints import (
30
+ ParsedVersion,
31
+ SingleClause,
32
+ VersionConstraint,
33
+ parse_version,
34
+ )
35
+
36
+ __all__ = [
37
+ "ContractCanonicalizer",
38
+ "ContractChange",
39
+ "ContractChangeKind",
40
+ "ContractDiffEngine",
41
+ "ContractDiffResult",
42
+ "ContractDiffSummary",
43
+ "ContractEvaluator",
44
+ "ContractGenerator",
45
+ "ContractParseError",
46
+ "ContractParser",
47
+ "ContractSanitizer",
48
+ "ContractSecurityError",
49
+ "ContractValidationError",
50
+ "ContractValidator",
51
+ "EvidenceCollector",
52
+ "EvidenceLevel",
53
+ "EvidenceSignal",
54
+ "ParsedVersion",
55
+ "ProjectEvidence",
56
+ "SingleClause",
57
+ "VersionConstraint",
58
+ "find_contract_file",
59
+ "parse_version",
60
+ ]
@@ -0,0 +1,119 @@
1
+ """Contract canonicalization and deterministic fingerprinting."""
2
+
3
+ import hashlib
4
+ import json
5
+ from typing import Any
6
+
7
+ from runmark.models.contract import RunmarkContract
8
+
9
+
10
+ class ContractCanonicalizer:
11
+ """Transforms a RunmarkContract into a normalized, canonical data structure and digest."""
12
+
13
+ @classmethod
14
+ def canonicalize(cls, contract: RunmarkContract) -> dict[str, Any]:
15
+ """Produce a normalized dictionary representing the semantic contract requirements.
16
+
17
+ Excludes informational fields ($schema), normalizes ordering of unordered lists,
18
+ and standardizes key structures.
19
+ """
20
+ canonical: dict[str, Any] = {
21
+ "version": contract.version,
22
+ }
23
+
24
+ # Project identity
25
+ if contract.project.name:
26
+ canonical["project"] = {"name": contract.project.name}
27
+ else:
28
+ canonical["project"] = {}
29
+
30
+ # Platform requirements (lowercased, deduplicated, sorted)
31
+ clean_os = sorted(
32
+ {os_name.lower().strip() for os_name in contract.platform.os if os_name.strip()}
33
+ )
34
+ clean_arch = sorted(
35
+ {arch.lower().strip() for arch in contract.platform.architecture if arch.strip()}
36
+ )
37
+ canonical["platform"] = {
38
+ "architecture": clean_arch,
39
+ "os": clean_os,
40
+ }
41
+
42
+ # Runtimes (sorted by name, cleaned expressions)
43
+ clean_runtimes: dict[str, str] = {}
44
+ for rt_name in sorted(contract.runtime.keys()):
45
+ clean_runtimes[rt_name.lower().strip()] = contract.runtime[rt_name].strip()
46
+ canonical["runtime"] = clean_runtimes
47
+
48
+ # Dependencies
49
+ clean_py_deps = {
50
+ k.lower().strip(): v.strip() for k, v in sorted(contract.dependencies.python.items())
51
+ }
52
+ clean_node_deps = {
53
+ k.lower().strip(): v.strip() for k, v in sorted(contract.dependencies.node.items())
54
+ }
55
+ canonical["dependencies"] = {
56
+ "node": clean_node_deps,
57
+ "python": clean_py_deps,
58
+ }
59
+
60
+ # Services (sorted by name)
61
+ clean_services: dict[str, dict[str, Any]] = {}
62
+ for svc_name in sorted(contract.services.keys()):
63
+ svc_req = contract.services[svc_name]
64
+ clean_services[svc_name.lower().strip()] = {
65
+ "required": svc_req.required,
66
+ "version": svc_req.version.strip(),
67
+ }
68
+ canonical["services"] = clean_services
69
+
70
+ # Environment variables (sorted, deduplicated)
71
+ clean_required = sorted({v.strip() for v in contract.environment.required if v.strip()})
72
+ clean_optional = sorted({v.strip() for v in contract.environment.optional if v.strip()})
73
+ canonical["environment"] = {
74
+ "optional": clean_optional,
75
+ "required": clean_required,
76
+ }
77
+
78
+ # Network ports (sorted numerically)
79
+ sorted_port_keys = sorted(contract.network.ports.keys(), key=lambda p: int(p))
80
+ clean_ports: dict[str, dict[str, Any]] = {}
81
+ for p_key in sorted_port_keys:
82
+ p_req = contract.network.ports[p_key]
83
+ clean_ports[str(int(p_key))] = {
84
+ "protocol": p_req.protocol.lower().strip(),
85
+ "required": p_req.required,
86
+ }
87
+ canonical["network"] = {"ports": clean_ports}
88
+
89
+ # Containers
90
+ clean_containers: dict[str, Any] = {}
91
+ if contract.containers.docker is not None:
92
+ clean_containers["docker"] = {"required": contract.containers.docker.required}
93
+ if contract.containers.compose is not None:
94
+ clean_containers["compose"] = {"required": contract.containers.compose.required}
95
+ canonical["containers"] = clean_containers
96
+
97
+ return canonical
98
+
99
+ @classmethod
100
+ def to_canonical_json(cls, contract: RunmarkContract) -> str:
101
+ """Serialize the canonical representation to a deterministic JSON string."""
102
+ canonical_dict = cls.canonicalize(contract)
103
+ return json.dumps(canonical_dict, sort_keys=True, separators=(",", ":"))
104
+
105
+ @classmethod
106
+ def to_json(cls, contract: RunmarkContract) -> str:
107
+ """Alias for to_canonical_json."""
108
+ return cls.to_canonical_json(contract)
109
+
110
+ @classmethod
111
+ def compute_fingerprint(cls, contract: RunmarkContract) -> str:
112
+ """Compute deterministic SHA-256 digest of the canonical contract representation."""
113
+ canonical_bytes = cls.to_canonical_json(contract).encode("utf-8")
114
+ return hashlib.sha256(canonical_bytes).hexdigest()
115
+
116
+ @classmethod
117
+ def fingerprint(cls, contract: RunmarkContract) -> str:
118
+ """Alias for compute_fingerprint."""
119
+ return cls.compute_fingerprint(contract)