propt 0.1.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.
propt/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
propt/audit.py ADDED
@@ -0,0 +1,57 @@
1
+ """
2
+ S3 audit logging.
3
+
4
+ Every impact analysis propt performs gets written to S3 as a timestamped
5
+ JSON record -- an auditable history of what was proposed, what evidence
6
+ was found, and whether the engineer chose to proceed. This matches the
7
+ "security/trust principles" in the original project spec: expose
8
+ evidence, keep an auditable trail, never silently change policy.
9
+
10
+ Uses boto3 (the AWS SDK) with the caller's existing AWS CLI credentials
11
+ -- no keys are stored or handled by propt itself.
12
+ """
13
+ import json
14
+ import os
15
+ import socket
16
+ from datetime import datetime, timezone
17
+
18
+ BUCKET = os.environ.get("PROPT_AUDIT_BUCKET", "propt-audit-logs-sid-2026")
19
+
20
+
21
+ def log_decision(change, impact, executed: bool):
22
+ """
23
+ Fire-and-forget: failures here must NEVER block or crash the actual
24
+ kubectl operation. Audit logging is a bonus, not a dependency.
25
+ """
26
+ try:
27
+ import boto3
28
+ except ImportError:
29
+ return # boto3 not installed -- skip silently, propt still works
30
+
31
+ try:
32
+ record = {
33
+ "timestamp": datetime.now(timezone.utc).isoformat(),
34
+ "hostname": socket.gethostname(),
35
+ "command": " ".join(change.raw_args),
36
+ "target": change.target(),
37
+ "namespace": change.namespace,
38
+ "impact_level": impact.level,
39
+ "evidence": impact.evidence,
40
+ "context": impact.context,
41
+ "consequence": impact.consequence,
42
+ "unavailable_signals": impact.unavailable_signals,
43
+ "executed": executed,
44
+ }
45
+ key = f"decisions/{record['timestamp']}.json"
46
+ s3 = boto3.client("s3")
47
+ s3.put_object(
48
+ Bucket=BUCKET,
49
+ Key=key,
50
+ Body=json.dumps(record, indent=2).encode("utf-8"),
51
+ ContentType="application/json",
52
+ )
53
+ except Exception:
54
+ # Never let audit logging break the actual workflow -- this is
55
+ # the same "honest degradation" principle used for Popeye and
56
+ # metrics-server: missing signal, not a crash.
57
+ pass
propt/cli.py ADDED
@@ -0,0 +1,60 @@
1
+ """
2
+ propt CLI entrypoint.
3
+
4
+ Usage:
5
+ propt kubectl delete pod payment-api-7d9f
6
+ propt kubectl get pods <- passes straight through, no analysis
7
+
8
+ The `propt` prefix is transparent: whatever command follows it runs
9
+ exactly as normal (after an optional confirmation for mutating ops).
10
+ """
11
+ import subprocess
12
+ import sys
13
+
14
+ from . import parser as change_parser
15
+ from . import engine, explain, audit
16
+
17
+
18
+ def main():
19
+ argv = sys.argv[1:]
20
+ if not argv:
21
+ print("Usage: propt <command> [args...] e.g. propt kubectl delete pod foo")
22
+ sys.exit(1)
23
+
24
+ tool = argv[0]
25
+ rest = argv[1:]
26
+
27
+ if tool != "kubectl":
28
+ # Pass through untouched for non-kubectl commands (v1 scope is k8s-only)
29
+ _run(argv)
30
+ return
31
+
32
+ change = change_parser.parse_kubectl(rest)
33
+
34
+ if not change.is_mutating:
35
+ # Read-only command: don't slow the engineer down, just run it.
36
+ _run(argv)
37
+ return
38
+
39
+ impact = engine.analyze(change)
40
+ print(explain.format_impact(impact))
41
+ print()
42
+
43
+ answer = input("Execute? [y/N]: ").strip().lower()
44
+ executed = answer == "y"
45
+ audit.log_decision(change, impact, executed)
46
+
47
+ if executed:
48
+ _run(argv)
49
+ else:
50
+ print("Aborted. No changes made.")
51
+ sys.exit(0)
52
+
53
+
54
+ def _run(argv):
55
+ result = subprocess.run(argv)
56
+ sys.exit(result.returncode)
57
+
58
+
59
+ if __name__ == "__main__":
60
+ main()
propt/engine.py ADDED
@@ -0,0 +1,107 @@
1
+ """
2
+ Impact Inference Engine.
3
+
4
+ Combines:
5
+ - the parsed Change
6
+ - the live dependency graph
7
+ - live context (replica counts, pod phase)
8
+ - real Popeye findings, filtered to the affected chain
9
+ - real metrics, if available
10
+
11
+ ...into a structured Impact result. No hardcoded per-command risk
12
+ table -- risk comes from what's actually true in the cluster right now.
13
+ """
14
+ from dataclasses import dataclass, field
15
+ from typing import List
16
+
17
+ from . import k8s_graph, popeye_runner, metrics
18
+
19
+
20
+ @dataclass
21
+ class Impact:
22
+ level: str # "HIGH", "MEDIUM", "LOW", "NONE", "UNKNOWN"
23
+ target: str
24
+ affected: List[str] = field(default_factory=list)
25
+ evidence: List[str] = field(default_factory=list)
26
+ context: List[str] = field(default_factory=list)
27
+ consequence: str = ""
28
+ unavailable_signals: List[str] = field(default_factory=list)
29
+
30
+
31
+ def analyze(change) -> Impact:
32
+ target_node = f"{change.resource_type}/{change.resource_name}" if change.resource_type and change.resource_name else None
33
+
34
+ if not change.is_mutating or not target_node:
35
+ return Impact(level="NONE", target=target_node or "unknown",
36
+ consequence="Read-only or unrecognized operation; no impact analysis needed.")
37
+
38
+ try:
39
+ graph = k8s_graph.build_graph(change.namespace)
40
+ except Exception as e:
41
+ return Impact(level="UNKNOWN", target=target_node,
42
+ consequence=f"Could not reach cluster: {e}",
43
+ unavailable_signals=["cluster"])
44
+
45
+ if not graph.has_node(target_node):
46
+ return Impact(level="UNKNOWN", target=target_node,
47
+ consequence="Resource not found in current cluster state.")
48
+
49
+ affected_nodes = k8s_graph.affected_chain(graph, target_node)
50
+ evidence = [] # signals that actually indicate risk -> drive the level
51
+ context = [] # informational signals -> shown, but never drive the level
52
+ unavailable = []
53
+
54
+ # 1. Live replica/context evidence -- a real risk signal
55
+ for node in [target_node] + affected_nodes:
56
+ ctx = k8s_graph.get_context(graph, node)
57
+ if ctx.get("kind") == "deployment":
58
+ desired = ctx.get("desired_replicas")
59
+ available = ctx.get("available_replicas")
60
+ if desired is not None and available is not None and available < desired:
61
+ evidence.append(
62
+ f"{node}: available replicas ({available}) below desired ({desired})"
63
+ )
64
+
65
+ # 2. Popeye findings, filtered to affected chain -- Warning/Error are risk
66
+ # signals; Info/OK-level notes are informational context only.
67
+ popeye_findings = popeye_runner.run_popeye(change.namespace)
68
+ if popeye_findings.get("_unavailable"):
69
+ unavailable.append(f"popeye ({popeye_findings.get('_reason')})")
70
+ else:
71
+ for node in [target_node] + affected_nodes:
72
+ for finding in popeye_findings.get(node, []):
73
+ line = f"Popeye [{finding['level']}] {node}: {finding['message']}"
74
+ if str(finding.get("level", "")).lower() in ("warning", "error", "critical"):
75
+ evidence.append(line)
76
+ else:
77
+ context.append(line)
78
+
79
+ # 3. Metrics: informational context only -- we don't have a resource
80
+ # limit to compare against, so a raw usage number is context, not
81
+ # a risk signal by itself.
82
+ if change.resource_type == "pod":
83
+ if metrics.metrics_available():
84
+ m = metrics.get_pod_metrics(change.namespace, change.resource_name)
85
+ if m.get("available"):
86
+ context.append(f"{target_node}: {m['cpu_millicores']}m CPU usage observed")
87
+ else:
88
+ unavailable.append("metrics-server")
89
+
90
+ level = "NONE"
91
+ consequence = "No significant impact detected from currently available signals."
92
+ if evidence:
93
+ level = "HIGH" if any("below desired" in e or "error" in e.lower() or "critical" in e.lower() for e in evidence) else "MEDIUM"
94
+ consequence = (
95
+ f"Potential reduction in capacity or degraded state propagating to: "
96
+ f"{', '.join(affected_nodes) if affected_nodes else target_node}."
97
+ )
98
+
99
+ return Impact(
100
+ level=level,
101
+ target=target_node,
102
+ affected=affected_nodes,
103
+ evidence=evidence,
104
+ context=context,
105
+ consequence=consequence,
106
+ unavailable_signals=unavailable,
107
+ )
propt/explain.py ADDED
@@ -0,0 +1,29 @@
1
+ from .engine import Impact
2
+
3
+
4
+ def format_impact(impact: Impact) -> str:
5
+ lines = []
6
+ header = f"{impact.level} POTENTIAL IMPACT" if impact.level != "NONE" else "NO SIGNIFICANT IMPACT DETECTED"
7
+ lines.append(header)
8
+ lines.append("")
9
+ lines.append(f"Target: {impact.target}")
10
+ if impact.affected:
11
+ lines.append(f"Affected: {', '.join(impact.affected)}")
12
+ if impact.evidence:
13
+ lines.append("")
14
+ lines.append("Evidence:")
15
+ for e in impact.evidence:
16
+ lines.append(f" • {e}")
17
+ if impact.context:
18
+ lines.append("")
19
+ lines.append("Context:")
20
+ for c in impact.context:
21
+ lines.append(f" • {c}")
22
+ if impact.unavailable_signals:
23
+ lines.append("")
24
+ lines.append("Unknown / missing context:")
25
+ for u in impact.unavailable_signals:
26
+ lines.append(f" • {u} not available")
27
+ lines.append("")
28
+ lines.append(f"Potential consequence: {impact.consequence}")
29
+ return "\n".join(lines)
propt/k8s_graph.py ADDED
@@ -0,0 +1,114 @@
1
+ """
2
+ Live Kubernetes discovery + dependency graph.
3
+
4
+ Builds a networkx.DiGraph of the current cluster state (in a given
5
+ namespace) using real ownerReferences and label-selector matching --
6
+ no hardcoded relationships. Edges point from a resource to the thing
7
+ that depends on it (child -> parent-of-traffic direction), i.e.:
8
+
9
+ Pod --owned_by--> ReplicaSet --owned_by--> Deployment
10
+ Pod --selected_by--> Service --routed_by--> Ingress
11
+
12
+ We traverse "upward" (toward things that would be affected) when
13
+ analyzing impact of a change to a lower-level resource.
14
+ """
15
+ import networkx as nx
16
+ from kubernetes import client, config
17
+
18
+
19
+ def load_cluster_client():
20
+ """Load kubeconfig (works with minikube's default context)."""
21
+ config.load_kube_config()
22
+ return client.CoreV1Api(), client.AppsV1Api(), client.NetworkingV1Api()
23
+
24
+
25
+ def build_graph(namespace: str = "default") -> nx.DiGraph:
26
+ core, apps, net = load_cluster_client()
27
+ g = nx.DiGraph()
28
+
29
+ pods = core.list_namespaced_pod(namespace).items
30
+ rsets = apps.list_namespaced_replica_set(namespace).items
31
+ deployments = apps.list_namespaced_deployment(namespace).items
32
+ services = core.list_namespaced_service(namespace).items
33
+ try:
34
+ ingresses = net.list_namespaced_ingress(namespace).items
35
+ except Exception:
36
+ ingresses = []
37
+
38
+ # Nodes
39
+ for p in pods:
40
+ g.add_node(f"pod/{p.metadata.name}", kind="pod", raw=p)
41
+ for r in rsets:
42
+ g.add_node(f"replicaset/{r.metadata.name}", kind="replicaset", raw=r)
43
+ for d in deployments:
44
+ g.add_node(f"deployment/{d.metadata.name}", kind="deployment", raw=d)
45
+ for s in services:
46
+ g.add_node(f"service/{s.metadata.name}", kind="service", raw=s)
47
+ for i in ingresses:
48
+ g.add_node(f"ingress/{i.metadata.name}", kind="ingress", raw=i)
49
+
50
+ # Pod -> ReplicaSet (ownerReferences)
51
+ for p in pods:
52
+ for owner in (p.metadata.owner_references or []):
53
+ if owner.kind == "ReplicaSet":
54
+ node = f"replicaset/{owner.name}"
55
+ if g.has_node(node):
56
+ g.add_edge(f"pod/{p.metadata.name}", node, relation="owned_by")
57
+
58
+ # ReplicaSet -> Deployment (ownerReferences)
59
+ for r in rsets:
60
+ for owner in (r.metadata.owner_references or []):
61
+ if owner.kind == "Deployment":
62
+ node = f"deployment/{owner.name}"
63
+ if g.has_node(node):
64
+ g.add_edge(f"replicaset/{r.metadata.name}", node, relation="owned_by")
65
+
66
+ # Pod -> Service (label selector matching)
67
+ for s in services:
68
+ selector = s.spec.selector or {}
69
+ if not selector:
70
+ continue
71
+ for p in pods:
72
+ labels = p.metadata.labels or {}
73
+ if all(labels.get(k) == v for k, v in selector.items()):
74
+ g.add_edge(f"pod/{p.metadata.name}", f"service/{s.metadata.name}", relation="selected_by")
75
+
76
+ # Service -> Ingress (backend rules)
77
+ for i in ingresses:
78
+ rules = i.spec.rules or []
79
+ for rule in rules:
80
+ http = getattr(rule, "http", None)
81
+ if not http:
82
+ continue
83
+ for path in (http.paths or []):
84
+ backend = path.backend
85
+ svc = getattr(getattr(backend, "service", None), "name", None)
86
+ if svc and g.has_node(f"service/{svc}"):
87
+ g.add_edge(f"service/{svc}", f"ingress/{i.metadata.name}", relation="routed_by")
88
+
89
+ return g
90
+
91
+
92
+ def affected_chain(g: nx.DiGraph, target_node: str) -> list:
93
+ """Everything reachable upward (toward services/ingress) from target."""
94
+ if not g.has_node(target_node):
95
+ return []
96
+ return list(nx.descendants(g, target_node))
97
+
98
+
99
+ def get_context(g: nx.DiGraph, node: str) -> dict:
100
+ """Pull live status fields for a node (replicas, ready state, etc.)."""
101
+ if not g.has_node(node):
102
+ return {}
103
+ kind = g.nodes[node]["kind"]
104
+ raw = g.nodes[node]["raw"]
105
+ ctx = {"kind": kind}
106
+ if raw is None:
107
+ return ctx
108
+ if kind == "deployment":
109
+ ctx["desired_replicas"] = raw.spec.replicas
110
+ ctx["available_replicas"] = raw.status.available_replicas or 0
111
+ ctx["ready_replicas"] = raw.status.ready_replicas or 0
112
+ if kind == "pod":
113
+ ctx["phase"] = raw.status.phase
114
+ return ctx
propt/metrics.py ADDED
@@ -0,0 +1,49 @@
1
+ """
2
+ Optional metrics-server integration.
3
+
4
+ Pulls real CPU usage for pods via the Kubernetes metrics API, if
5
+ metrics-server is installed in the cluster. If it isn't, we say so
6
+ explicitly rather than inventing a "traffic" number -- consistent with
7
+ the project's own principle of distinguishing observed facts from
8
+ missing context.
9
+ """
10
+ from kubernetes import client, config
11
+
12
+
13
+ def metrics_available() -> bool:
14
+ try:
15
+ config.load_kube_config()
16
+ api = client.CustomObjectsApi()
17
+ api.list_cluster_custom_object("metrics.k8s.io", "v1beta1", "pods")
18
+ return True
19
+ except Exception:
20
+ return False
21
+
22
+
23
+ def get_pod_metrics(namespace: str, pod_name: str) -> dict:
24
+ try:
25
+ config.load_kube_config()
26
+ api = client.CustomObjectsApi()
27
+ data = api.get_namespaced_custom_object(
28
+ "metrics.k8s.io", "v1beta1", namespace, "pods", pod_name
29
+ )
30
+ containers = data.get("containers", [])
31
+ cpu_total = 0
32
+ for c in containers:
33
+ cpu_str = c.get("usage", {}).get("cpu", "0")
34
+ cpu_total += _parse_cpu(cpu_str)
35
+ return {"cpu_millicores": cpu_total, "available": True}
36
+ except Exception as e:
37
+ return {"available": False, "reason": str(e)}
38
+
39
+
40
+ def _parse_cpu(cpu_str: str) -> int:
41
+ """Convert k8s cpu strings ('100m', '1') to millicores."""
42
+ if cpu_str.endswith("n"):
43
+ return int(cpu_str[:-1]) // 1_000_000
44
+ if cpu_str.endswith("m"):
45
+ return int(cpu_str[:-1])
46
+ try:
47
+ return int(float(cpu_str) * 1000)
48
+ except ValueError:
49
+ return 0
propt/parser.py ADDED
@@ -0,0 +1,116 @@
1
+ """
2
+ Common Change Model + kubectl command parser.
3
+
4
+ Turns a raw kubectl command (as a list of argv tokens, minus the
5
+ leading `propt`) into a normalized Change object the rest of the
6
+ engine can reason about.
7
+ """
8
+ from dataclasses import dataclass, field
9
+ from typing import Optional
10
+
11
+ # Operations we intercept for risk analysis. Anything else (get,
12
+ # describe, logs, top, explain, ...) is treated as a read and passed
13
+ # straight through without analysis.
14
+ MUTATING_OPS = {"delete", "scale", "apply", "patch", "edit", "replace", "rollout"}
15
+
16
+ # kubectl resource shorthands we normalize to a canonical singular form.
17
+ RESOURCE_ALIASES = {
18
+ "po": "pod", "pods": "pod", "pod": "pod",
19
+ "deploy": "deployment", "deployments": "deployment", "deployment": "deployment",
20
+ "svc": "service", "services": "service", "service": "service",
21
+ "ing": "ingress", "ingresses": "ingress", "ingress": "ingress",
22
+ "rs": "replicaset", "replicasets": "replicaset", "replicaset": "replicaset",
23
+ "sts": "statefulset", "statefulsets": "statefulset", "statefulset": "statefulset",
24
+ "ds": "daemonset", "daemonsets": "daemonset", "daemonset": "daemonset",
25
+ }
26
+
27
+
28
+ @dataclass
29
+ class Change:
30
+ actor: str
31
+ provider: str
32
+ operation: str
33
+ resource_type: Optional[str]
34
+ resource_name: Optional[str]
35
+ namespace: str
36
+ parameters: dict = field(default_factory=dict)
37
+ raw_args: list = field(default_factory=list)
38
+ is_mutating: bool = False
39
+
40
+ def target(self) -> str:
41
+ if self.resource_type and self.resource_name:
42
+ return f"{self.resource_type}/{self.resource_name}"
43
+ return self.resource_type or "unknown"
44
+
45
+
46
+ def _extract_namespace(tokens: list) -> str:
47
+ for flag in ("-n", "--namespace"):
48
+ if flag in tokens:
49
+ idx = tokens.index(flag)
50
+ if idx + 1 < len(tokens):
51
+ return tokens[idx + 1]
52
+ return "default"
53
+
54
+
55
+ def _extract_replicas(tokens: list) -> Optional[int]:
56
+ for tok in tokens:
57
+ if tok.startswith("--replicas"):
58
+ if "=" in tok:
59
+ try:
60
+ return int(tok.split("=", 1)[1])
61
+ except ValueError:
62
+ return None
63
+ return None
64
+
65
+
66
+ def parse_kubectl(args: list) -> Change:
67
+ """
68
+ args: the kubectl invocation WITHOUT the leading 'kubectl' token,
69
+ e.g. ["delete", "pod", "payment-api-7d9f"]
70
+ """
71
+ if not args:
72
+ return Change(
73
+ actor="user", provider="kubernetes", operation="unknown",
74
+ resource_type=None, resource_name=None, namespace="default",
75
+ raw_args=args, is_mutating=False,
76
+ )
77
+
78
+ op = args[0].lower()
79
+ rest = [a for a in args[1:] if not a.startswith("-")]
80
+ # crude flag-value stripping: drop flag tokens and the value that follows
81
+ # a flag we recognize (namespace, replicas) so they don't get mistaken
82
+ # for resource type/name.
83
+ namespace = _extract_namespace(args)
84
+
85
+ resource_type = None
86
+ resource_name = None
87
+
88
+ if rest:
89
+ first = rest[0]
90
+ if "/" in first:
91
+ # e.g. `pod/payment-api-7d9f`
92
+ resource_type, resource_name = first.split("/", 1)
93
+ else:
94
+ resource_type = first
95
+ if len(rest) > 1:
96
+ resource_name = rest[1]
97
+
98
+ if resource_type:
99
+ resource_type = RESOURCE_ALIASES.get(resource_type.lower(), resource_type.lower())
100
+
101
+ parameters = {}
102
+ replicas = _extract_replicas(args)
103
+ if replicas is not None:
104
+ parameters["replicas"] = replicas
105
+
106
+ return Change(
107
+ actor="user",
108
+ provider="kubernetes",
109
+ operation=op,
110
+ resource_type=resource_type,
111
+ resource_name=resource_name,
112
+ namespace=namespace,
113
+ parameters=parameters,
114
+ raw_args=args,
115
+ is_mutating=op in MUTATING_OPS,
116
+ )
propt/popeye_runner.py ADDED
@@ -0,0 +1,68 @@
1
+ """
2
+ Popeye integration.
3
+
4
+ Runs the open-source Popeye cluster sanitizer (https://github.com/derailed/popeye)
5
+ against the live cluster and parses its JSON output into findings keyed
6
+ by resource, so the impact engine can filter to just the resources in
7
+ a change's dependency chain.
8
+
9
+ Popeye must be installed and on PATH as `popeye`.
10
+ """
11
+ import json
12
+ import shutil
13
+ import subprocess
14
+
15
+
16
+ def popeye_available() -> bool:
17
+ return shutil.which("popeye") is not None
18
+
19
+
20
+ def run_popeye(namespace: str = "default") -> dict:
21
+ """
22
+ Returns {"pod/name": [finding, ...], "deployment/name": [...], ...}
23
+ Falls back to an empty dict (with a note) if popeye isn't installed
24
+ or the scan fails -- we never fabricate findings.
25
+ """
26
+ if not popeye_available():
27
+ return {"_unavailable": True, "_reason": "popeye not found on PATH"}
28
+
29
+ try:
30
+ result = subprocess.run(
31
+ ["popeye", "-n", namespace, "-o", "json", "--force-exit-zero"],
32
+ capture_output=True, text=True, timeout=60,
33
+ )
34
+ data = json.loads(result.stdout)
35
+ except Exception as e:
36
+ return {"_unavailable": True, "_reason": str(e)}
37
+
38
+ findings = {}
39
+ try:
40
+ sanitizers = data.get("popeye", {}).get("sanitizers", [])
41
+ for section in sanitizers:
42
+ gvr = section.get("sanitizer", "")
43
+ for issue_group in section.get("issues", {}).items():
44
+ resource_name, issues = issue_group
45
+ # Popeye keys issues by "namespace/name"
46
+ short_name = resource_name.split("/")[-1]
47
+ kind = _gvr_to_kind(gvr)
48
+ key = f"{kind}/{short_name}"
49
+ findings.setdefault(key, [])
50
+ for issue in issues:
51
+ findings[key].append({
52
+ "level": issue.get("level"),
53
+ "message": issue.get("message"),
54
+ })
55
+ except Exception:
56
+ pass
57
+
58
+ return findings
59
+
60
+
61
+ def _gvr_to_kind(sanitizer_name: str) -> str:
62
+ mapping = {
63
+ "pod": "pod", "po": "pod",
64
+ "deployment": "deployment", "dp": "deployment",
65
+ "service": "service", "svc": "service",
66
+ "replicaset": "replicaset", "rs": "replicaset",
67
+ }
68
+ return mapping.get(sanitizer_name.lower(), sanitizer_name.lower())
@@ -0,0 +1,10 @@
1
+ Metadata-Version: 2.4
2
+ Name: propt
3
+ Version: 0.1.0
4
+ Summary: Pre-action operational intelligence layer for infrastructure changes.
5
+ Requires-Python: >=3.9
6
+ Requires-Dist: kubernetes>=29.0.0
7
+ Requires-Dist: networkx>=3.0
8
+ Requires-Dist: click>=8.0
9
+ Requires-Dist: requests>=2.28
10
+ Requires-Dist: pyyaml>=6.0
@@ -0,0 +1,14 @@
1
+ propt/__init__.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
2
+ propt/audit.py,sha256=nNcnipjuewUFLRJrUQhJXXtvhG6Bwv6pthPD0ApJvjY,2091
3
+ propt/cli.py,sha256=n8aNzOz2dzEhxYLegHjqYWWP7h1EDcn665U00i_U57w,1389
4
+ propt/engine.py,sha256=wvigf3EOBAQ7ZpO_nQurIvZdSnYXB4r3FFFMhvu9JBY,4411
5
+ propt/explain.py,sha256=1n4aokc0CZyqSlcm-vxnyJGogAIu1VhFT5xnCAIWQAQ,1005
6
+ propt/k8s_graph.py,sha256=7DbmAlBU-7T6QMXgP2XSiH_k2aT7e9IT-KqOeiUD2po,4284
7
+ propt/metrics.py,sha256=2zN1aTUPoD8letmC7PHfppQnx-46JOBWaodpW5PyYtA,1578
8
+ propt/parser.py,sha256=4GPFPwiaxaudMoX5Jcf6W7EWaAEnbOtCBMN7OoHjflg,3791
9
+ propt/popeye_runner.py,sha256=p6S3wJTFEdbzhKMeuXUYh7vRxd8NAgxIpCwpeyQE1yc,2289
10
+ propt-0.1.0.dist-info/METADATA,sha256=dnKszc7i8sX6GqZ3QoHIwMhP53zvCpLOhBvXwKo1B0s,307
11
+ propt-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
12
+ propt-0.1.0.dist-info/entry_points.txt,sha256=vThvx1V0s1ejRgU4JMNcKikTf_rebHslrLwN_RqHiv0,41
13
+ propt-0.1.0.dist-info/top_level.txt,sha256=Gr1nnzwiH4AJl7RyB6pWCwMFTsMget0VEwrx2MSpksk,6
14
+ propt-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ propt = propt.cli:main
@@ -0,0 +1 @@
1
+ propt