cloudmap 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.
- cloudmap/__init__.py +3 -0
- cloudmap/__main__.py +6 -0
- cloudmap/adapters/__init__.py +91 -0
- cloudmap/ask/__init__.py +111 -0
- cloudmap/ask/intent.py +133 -0
- cloudmap/ask/narration.py +54 -0
- cloudmap/ask/queries.py +296 -0
- cloudmap/cli.py +534 -0
- cloudmap/extract/__init__.py +0 -0
- cloudmap/extract/extractors.py +653 -0
- cloudmap/extract/llm.py +89 -0
- cloudmap/graph.py +208 -0
- cloudmap/ingest/__init__.py +0 -0
- cloudmap/ingest/azure.py +380 -0
- cloudmap/ingest/fixture.py +15 -0
- cloudmap/interactive.py +227 -0
- cloudmap/local_model.py +49 -0
- cloudmap/model.py +43 -0
- cloudmap/render/__init__.py +0 -0
- cloudmap/render/azure_icons.py +72 -0
- cloudmap/render/csv_export.py +47 -0
- cloudmap/render/drawio.py +149 -0
- cloudmap/render/html.py +579 -0
- cloudmap/render/json_out.py +52 -0
- cloudmap/render/mermaid.py +25 -0
- cloudmap/scrub.py +300 -0
- cloudmap-1.0.0.dist-info/METADATA +340 -0
- cloudmap-1.0.0.dist-info/RECORD +31 -0
- cloudmap-1.0.0.dist-info/WHEEL +4 -0
- cloudmap-1.0.0.dist-info/entry_points.txt +2 -0
- cloudmap-1.0.0.dist-info/licenses/LICENSE +21 -0
cloudmap/__init__.py
ADDED
cloudmap/__main__.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Adapters: turn a raw export into the neutral cloudmap Graph.
|
|
2
|
+
|
|
3
|
+
The rest of the tool never learns which cloud (or file shape) the data came from
|
|
4
|
+
- that knowledge is quarantined here. Adding a cloud = adding one adapter.
|
|
5
|
+
The neutral shape and accepted inputs are documented in FORMAT.md.
|
|
6
|
+
|
|
7
|
+
Today:
|
|
8
|
+
- AzureAdapter raw Azure Resource Graph -> Graph (runs extraction rules)
|
|
9
|
+
- neutral graph JSON a file cloudmap wrote -> Graph (loaded back, no re-extract)
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
|
|
14
|
+
from ..graph import build_graph
|
|
15
|
+
from ..model import Edge, Graph, Node
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class AzureAdapter:
|
|
19
|
+
"""Raw Azure Resource Graph (a list of resources, each with `properties`) ->
|
|
20
|
+
neutral Graph. The Azure-specific rules live in extract/extractors.py; this
|
|
21
|
+
just names the seam and delegates."""
|
|
22
|
+
|
|
23
|
+
name = "azure"
|
|
24
|
+
|
|
25
|
+
@staticmethod
|
|
26
|
+
def matches(data):
|
|
27
|
+
resources = _azure_resources(data)
|
|
28
|
+
return bool(resources) and any(
|
|
29
|
+
str(r.get("type", "")).lower().startswith("microsoft.") for r in resources
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
@staticmethod
|
|
33
|
+
def to_graph(data):
|
|
34
|
+
return build_graph(_azure_resources(data))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _azure_resources(data):
|
|
38
|
+
if isinstance(data, dict):
|
|
39
|
+
return data.get("data") or data.get("resources") or []
|
|
40
|
+
if isinstance(data, list):
|
|
41
|
+
return data
|
|
42
|
+
return []
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _looks_neutral(data):
|
|
46
|
+
"""A graph cloudmap itself wrote: a dict carrying both `nodes` and `edges`."""
|
|
47
|
+
return isinstance(data, dict) and isinstance(data.get("nodes"), list) \
|
|
48
|
+
and isinstance(data.get("edges"), list)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def graph_from_neutral(data):
|
|
52
|
+
"""Reconstruct a Graph from cloudmap's own JSON output (post-extraction, so no
|
|
53
|
+
rules run). Provenance (origin/evidence), hop distances and the map's own meta
|
|
54
|
+
(seed, complete, read_gaps) are preserved - a reloaded map keeps its caveats."""
|
|
55
|
+
nodes = {}
|
|
56
|
+
for n in data.get("nodes", []):
|
|
57
|
+
nodes[n["id"]] = Node(
|
|
58
|
+
id=n["id"],
|
|
59
|
+
name=n.get("name", ""),
|
|
60
|
+
type=n.get("type", ""),
|
|
61
|
+
resource_group=n.get("resourceGroup", ""),
|
|
62
|
+
location=n.get("location", ""),
|
|
63
|
+
external=bool(n.get("external")),
|
|
64
|
+
note=n.get("note", ""),
|
|
65
|
+
)
|
|
66
|
+
edges = [
|
|
67
|
+
Edge(
|
|
68
|
+
source=e["source"],
|
|
69
|
+
target=e["target"],
|
|
70
|
+
kind=e.get("kind", ""),
|
|
71
|
+
origin=e.get("origin", "extracted"),
|
|
72
|
+
evidence=e.get("evidence", ""),
|
|
73
|
+
)
|
|
74
|
+
for e in data.get("edges", [])
|
|
75
|
+
]
|
|
76
|
+
distances = {n["id"]: n["hops"] for n in data.get("nodes", []) if n.get("hops") is not None}
|
|
77
|
+
meta = dict(data.get("meta") or {})
|
|
78
|
+
if data.get("seed"):
|
|
79
|
+
meta.setdefault("seed", data["seed"])
|
|
80
|
+
return Graph(nodes=nodes, edges=edges, distances=distances, meta=meta)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def load_graph(path):
|
|
84
|
+
"""Read a file and return a neutral Graph, auto-detecting the input shape:
|
|
85
|
+
a neutral cloudmap graph is loaded as-is; anything else is treated as a raw
|
|
86
|
+
cloud export and sent through the matching adapter (Azure today)."""
|
|
87
|
+
with open(path, encoding="utf-8") as f:
|
|
88
|
+
data = json.load(f)
|
|
89
|
+
if _looks_neutral(data):
|
|
90
|
+
return graph_from_neutral(data)
|
|
91
|
+
return AzureAdapter.to_graph(data)
|
cloudmap/ask/__init__.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Ask: answer questions about a map that has already been verified.
|
|
2
|
+
|
|
3
|
+
Read-only by construction, and that is this layer's whole trust story. The facts
|
|
4
|
+
in an answer are computed from the graph (`queries.py`); a local model may at most
|
|
5
|
+
route an unusual phrasing (`intent.py`) or put the computed facts into prose
|
|
6
|
+
(`narration.py`). Because the model never produces the facts, it cannot turn a guess
|
|
7
|
+
into a fact - the worst it can do is choose the wrong query, which the printed
|
|
8
|
+
query name and subject make immediately obvious.
|
|
9
|
+
|
|
10
|
+
An answer also inherits the map's honesty: if the artifact says it is incomplete,
|
|
11
|
+
every answer drawn from it carries that warning.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from ..graph import HIGH_LEVEL_PREFIXES, is_high_level
|
|
15
|
+
from . import queries
|
|
16
|
+
from .intent import SUPPORTED, llm_parse, parse
|
|
17
|
+
from .narration import narrate
|
|
18
|
+
|
|
19
|
+
__all__ = ["SUPPORTED", "answer", "narrate", "warnings"]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def answer(graph, question, allow_llm_intent=False, max_hops=None, model=None):
|
|
23
|
+
"""Route `question` to one deterministic query and run it against `graph`."""
|
|
24
|
+
plan = parse(question, graph)
|
|
25
|
+
if plan is None and allow_llm_intent:
|
|
26
|
+
plan = llm_parse(question, graph, model=model)
|
|
27
|
+
if plan is None:
|
|
28
|
+
result = _error("I could not turn that into a query over this map.")
|
|
29
|
+
else:
|
|
30
|
+
result = _run(graph, plan, max_hops=max_hops)
|
|
31
|
+
result["question"] = question
|
|
32
|
+
result["warnings"] = warnings(graph)
|
|
33
|
+
return result
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _run(graph, plan, max_hops=None):
|
|
37
|
+
query = plan.get("query")
|
|
38
|
+
subject, target = plan.get("subject"), plan.get("target")
|
|
39
|
+
|
|
40
|
+
if query in ("impact", "depends") and not subject:
|
|
41
|
+
return _error(_no_subject(graph, "Name a resource from this map in the question."),
|
|
42
|
+
query=query)
|
|
43
|
+
if query == "paths" and not (subject and target):
|
|
44
|
+
return _error(_no_subject(graph, "Name two resources: 'how does <resource> reach "
|
|
45
|
+
"<other>'."), query=query)
|
|
46
|
+
|
|
47
|
+
if query == "impact":
|
|
48
|
+
return queries.impact(graph, subject, max_hops=max_hops)
|
|
49
|
+
if query == "depends":
|
|
50
|
+
return queries.depends(graph, subject, max_hops=max_hops)
|
|
51
|
+
if query == "paths":
|
|
52
|
+
return queries.paths(graph, subject, target)
|
|
53
|
+
if query == "shared":
|
|
54
|
+
return queries.shared(graph)
|
|
55
|
+
if query == "guesses":
|
|
56
|
+
return queries.guesses(graph)
|
|
57
|
+
if query == "summary":
|
|
58
|
+
return queries.summary(graph)
|
|
59
|
+
return _error(f"Unknown query '{query}'.")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _no_subject(graph, default):
|
|
63
|
+
"""Why the resource was not found matters. In a collapsed map the instance is
|
|
64
|
+
usually right there on the diagram, inside a group box - saying "name a
|
|
65
|
+
resource" sends the reader looking for a typo that does not exist."""
|
|
66
|
+
if not is_high_level(graph):
|
|
67
|
+
return default
|
|
68
|
+
groups = sorted({n.name for n in graph.nodes.values()
|
|
69
|
+
if str(n.id).startswith(HIGH_LEVEL_PREFIXES)})
|
|
70
|
+
return ("This map is the high-level view: resources are grouped into one box per "
|
|
71
|
+
"type, so instance names do not exist in it. Ask about a group instead "
|
|
72
|
+
f"({', '.join(groups[:6])}{', ...' if len(groups) > 6 else ''}), or re-run "
|
|
73
|
+
"the trace with --level detail to keep instance names.")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _error(message, query=None):
|
|
77
|
+
return {"query": query, "subject": None, "subject_name": None, "error": message,
|
|
78
|
+
"headline": message, "findings": [], "facts": {}, "supported": list(SUPPORTED)}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def warnings(graph):
|
|
82
|
+
"""What the reader must know before believing the answer: the map's own limits."""
|
|
83
|
+
meta = getattr(graph, "meta", None) or {}
|
|
84
|
+
out = []
|
|
85
|
+
if meta.get("complete") is False:
|
|
86
|
+
out.append("This map is marked INCOMPLETE - resources and edges may be missing, "
|
|
87
|
+
"so this answer can be missing them too.")
|
|
88
|
+
if meta.get("truncated"):
|
|
89
|
+
out.append("The scan behind this map hit its pagination cap.")
|
|
90
|
+
for gap in meta.get("read_gaps") or []:
|
|
91
|
+
out.append(f"read gap in the source scan: {gap}")
|
|
92
|
+
# a class of edge the scan never went looking for: an empty answer here means
|
|
93
|
+
# "not looked for", not "nothing found"
|
|
94
|
+
for spot in meta.get("blind_spots") or []:
|
|
95
|
+
out.append(f"blind spot in the source scan: {spot}")
|
|
96
|
+
|
|
97
|
+
grouped = sum(1 for nid in graph.nodes if str(nid).startswith(HIGH_LEVEL_PREFIXES))
|
|
98
|
+
if grouped:
|
|
99
|
+
out.append(f"This is the high-level view: {grouped} box(es) each stand for every "
|
|
100
|
+
"instance of a resource type, so counts and names here are per type, "
|
|
101
|
+
"not per instance.")
|
|
102
|
+
|
|
103
|
+
model_edges = sum(1 for e in graph.edges if e.origin != "extracted")
|
|
104
|
+
if model_edges:
|
|
105
|
+
out.append(f"{model_edges} edge(s) in this map are model-proposed guesses; findings "
|
|
106
|
+
"that rely on them are marked unverified.")
|
|
107
|
+
external = sum(1 for n in graph.nodes.values() if n.external)
|
|
108
|
+
if external:
|
|
109
|
+
out.append(f"{external} node(s) are referenced but were never verified as real "
|
|
110
|
+
"resources.")
|
|
111
|
+
return out
|
cloudmap/ask/intent.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""Turn a developer's question into ONE deterministic query.
|
|
2
|
+
|
|
3
|
+
Rules first, deliberately: the phrasings people actually type are keyword-shaped,
|
|
4
|
+
a rule match costs nothing, and it works on a machine with no model installed. A
|
|
5
|
+
local model is only a FALLBACK for phrasings the rules miss (opt-in, `--llm`), and
|
|
6
|
+
even then it may do exactly two things - name a query from a fixed list and name a
|
|
7
|
+
resource - both of which are validated against this graph before anything runs.
|
|
8
|
+
The model picks a route; it never supplies an answer. A wrong route is visible,
|
|
9
|
+
because the query and subject it chose are printed with the answer.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
|
|
14
|
+
from ..local_model import generate_json
|
|
15
|
+
from .queries import QUERIES
|
|
16
|
+
|
|
17
|
+
SUPPORTED = (
|
|
18
|
+
"what breaks if I touch <resource> - what depends on it (blast radius)",
|
|
19
|
+
"what does <resource> depend on - what it needs to work",
|
|
20
|
+
"how does <resource> reach <other> - the paths between two resources",
|
|
21
|
+
"what is shared in this map - resources several others depend on",
|
|
22
|
+
"what should I not trust - the model-proposed guesses",
|
|
23
|
+
"explain this map - a summary of what was traced",
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
# Checked in order: the specific, whole-map questions first, so "which paths cross
|
|
27
|
+
# a shared vault" is understood as a question about sharing, not as a path lookup.
|
|
28
|
+
_RULES = (
|
|
29
|
+
("guesses", (r"(not trust|untrusted|unverified|guess|how sure|confidence|"
|
|
30
|
+
r"model[- ]proposed|reliable)")),
|
|
31
|
+
("shared", (r"(shared|in common|common dependenc|several (apps|resources|teams)|"
|
|
32
|
+
r"more than one (app|resource|team))")),
|
|
33
|
+
("impact", (r"(what breaks|breaks if|what depends|who depends|dependents|blast radius|"
|
|
34
|
+
r"impact|affected|if i (touch|change|delete|remove|restart|move|redeploy|"
|
|
35
|
+
r"rotate|break))")),
|
|
36
|
+
# The verbs of "I am about to change this": whatever the sentence around them
|
|
37
|
+
# looks like, the question is always who else feels it. Catching them with a
|
|
38
|
+
# rule keeps the common real-world phrasings off the model entirely.
|
|
39
|
+
("impact", (r"\b(rotat\w*|restart\w*|delet\w*|decommission\w*|redeploy\w*|resiz\w*|"
|
|
40
|
+
r"migrat\w*|patch\w*|upgrad\w*|drain\w*|scal\w*|reboot\w*)\b")),
|
|
41
|
+
("depends", r"(depends? on|dependenc(y|ies)|what does .*(need|use)|needs|uses|downstream)"),
|
|
42
|
+
("paths", r"(paths?|reach|route|how does .*(talk|connect|get) )"),
|
|
43
|
+
("summary", r"(explain|summar|overview|what is (this|in this)|describe|shape of)"),
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
_NEEDS_SUBJECT = ("impact", "depends")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def parse(question, graph):
|
|
50
|
+
"""Rule-based routing. Returns a plan dict, or None if no rule recognised it."""
|
|
51
|
+
text = (question or "").strip()
|
|
52
|
+
if not text:
|
|
53
|
+
return None
|
|
54
|
+
subjects = match_nodes(graph, text)
|
|
55
|
+
for query, pattern in _RULES:
|
|
56
|
+
if re.search(pattern, text, re.I):
|
|
57
|
+
return _plan(query, subjects)
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _plan(query, subjects):
|
|
62
|
+
plan = {"query": query}
|
|
63
|
+
if query == "paths":
|
|
64
|
+
if subjects:
|
|
65
|
+
plan["subject"] = subjects[0]
|
|
66
|
+
if len(subjects) > 1:
|
|
67
|
+
plan["target"] = subjects[1]
|
|
68
|
+
elif query in _NEEDS_SUBJECT and subjects:
|
|
69
|
+
plan["subject"] = subjects[0]
|
|
70
|
+
return plan
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def match_nodes(graph, text):
|
|
74
|
+
"""Resource names mentioned in `text`, in the order they appear.
|
|
75
|
+
|
|
76
|
+
Longest name wins on overlap, so a question about `myapi` is not read as a
|
|
77
|
+
question about `api` - the same label-boundary discipline the extractors use.
|
|
78
|
+
"""
|
|
79
|
+
low = text.lower()
|
|
80
|
+
hits = []
|
|
81
|
+
for nid, node in graph.nodes.items():
|
|
82
|
+
name = (node.name or "").lower()
|
|
83
|
+
if len(name) < 3 or name not in low:
|
|
84
|
+
continue
|
|
85
|
+
hits.append((low.index(name), len(name), nid))
|
|
86
|
+
|
|
87
|
+
hits.sort(key=lambda h: (-h[1], h[0]))
|
|
88
|
+
claimed, chosen = [], []
|
|
89
|
+
for pos, length, nid in hits:
|
|
90
|
+
span = (pos, pos + length)
|
|
91
|
+
if any(span[0] >= c[0] and span[1] <= c[1] for c in claimed):
|
|
92
|
+
continue
|
|
93
|
+
claimed.append(span)
|
|
94
|
+
chosen.append((pos, nid))
|
|
95
|
+
chosen.sort()
|
|
96
|
+
return [nid for _pos, nid in chosen]
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
_PROMPT = """You route a question to ONE query over a cloud dependency map.
|
|
100
|
+
Output ONLY JSON: {{"query":"<name>","subject":"<resource name or empty>","target":"<resource name or empty>"}}
|
|
101
|
+
Allowed query names and what they mean:
|
|
102
|
+
- impact: what breaks if the subject changes (what depends on it)
|
|
103
|
+
- depends: what the subject itself depends on
|
|
104
|
+
- paths: how subject reaches target
|
|
105
|
+
- shared: resources that several others depend on
|
|
106
|
+
- guesses: which parts of the map are unverified
|
|
107
|
+
- summary: explain the whole map
|
|
108
|
+
Use ONLY resource names from this list, copied exactly: {names}
|
|
109
|
+
Question: {question}
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def llm_parse(question, graph, model=None, timeout=120):
|
|
114
|
+
"""Fallback routing by the local model. Anything it returns is validated: an
|
|
115
|
+
unknown query name is refused, and a resource it invents is dropped because it
|
|
116
|
+
resolves against this graph or not at all."""
|
|
117
|
+
names = sorted({n.name for n in graph.nodes.values() if n.name})
|
|
118
|
+
out = generate_json(
|
|
119
|
+
_PROMPT.format(names=", ".join(names[:200]), question=question),
|
|
120
|
+
model=model, timeout=timeout)
|
|
121
|
+
|
|
122
|
+
query = str(out.get("query", "")).strip().lower()
|
|
123
|
+
if query not in QUERIES:
|
|
124
|
+
return None
|
|
125
|
+
plan = {"query": query}
|
|
126
|
+
for key in ("subject", "target"):
|
|
127
|
+
hint = str(out.get(key) or "").strip()
|
|
128
|
+
if not hint:
|
|
129
|
+
continue
|
|
130
|
+
resolved = match_nodes(graph, hint)
|
|
131
|
+
if resolved:
|
|
132
|
+
plan[key] = resolved[0]
|
|
133
|
+
return plan
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Optional prose over an answer the graph already produced.
|
|
2
|
+
|
|
3
|
+
The model is handed the computed FACTS only - names, relationships, hop counts,
|
|
4
|
+
trust labels - and told to add nothing. The deterministic answer is printed above
|
|
5
|
+
the narration either way, so if the prose drifts, what the reader sees is a
|
|
6
|
+
narration disagreeing with the facts printed right above it, not a wrong answer.
|
|
7
|
+
|
|
8
|
+
Empty string on any failure: no ollama means no prose, never a missing answer.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
|
|
13
|
+
from ..local_model import generate
|
|
14
|
+
|
|
15
|
+
_PROMPT = """You explain a cloud dependency answer to a developer, in 2-4 sentences.
|
|
16
|
+
Rules:
|
|
17
|
+
- Use ONLY the facts in the JSON. Never add a resource, relationship or cause that is not there.
|
|
18
|
+
- Say plainly which findings are unverified guesses, if any are.
|
|
19
|
+
- No headings, no bullet lists, no restating the JSON field names.
|
|
20
|
+
|
|
21
|
+
Question: {question}
|
|
22
|
+
|
|
23
|
+
Facts:
|
|
24
|
+
{facts}
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def narrate(result, model=None, timeout=120, max_findings=20):
|
|
29
|
+
"""Prose for a deterministic answer. Returns "" if there is nothing to narrate
|
|
30
|
+
or the local model is unavailable."""
|
|
31
|
+
if not result or result.get("error"):
|
|
32
|
+
return ""
|
|
33
|
+
|
|
34
|
+
findings = [
|
|
35
|
+
{
|
|
36
|
+
"name": f.get("name"),
|
|
37
|
+
"type": f.get("type"),
|
|
38
|
+
"hops": f.get("hops"),
|
|
39
|
+
"trust": f.get("trust"),
|
|
40
|
+
"relationships": [h.get("kind") for h in (f.get("path") or [])],
|
|
41
|
+
}
|
|
42
|
+
for f in (result.get("findings") or [])[:max_findings]
|
|
43
|
+
]
|
|
44
|
+
facts = {
|
|
45
|
+
"query": result.get("query"),
|
|
46
|
+
"subject": result.get("subject_name"),
|
|
47
|
+
"headline": result.get("headline"),
|
|
48
|
+
"counts": result.get("facts"),
|
|
49
|
+
"findings": findings,
|
|
50
|
+
"caveats": result.get("warnings") or [],
|
|
51
|
+
}
|
|
52
|
+
prompt = _PROMPT.format(question=result.get("question", ""),
|
|
53
|
+
facts=json.dumps(facts, indent=2))
|
|
54
|
+
return generate(prompt, model=model, timeout=timeout, json_format=False).strip()
|