flowglyph 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.
- flowglyph/__init__.py +70 -0
- flowglyph/__main__.py +5 -0
- flowglyph/_internal/__init__.py +1 -0
- flowglyph/_internal/archives.py +44 -0
- flowglyph/_internal/graph.py +45 -0
- flowglyph/_internal/hashing.py +16 -0
- flowglyph/_internal/markdown.py +209 -0
- flowglyph/_internal/paths.py +20 -0
- flowglyph/_internal/search.py +67 -0
- flowglyph/_internal/text.py +42 -0
- flowglyph/_internal/xml.py +47 -0
- flowglyph/adapters/__init__.py +123 -0
- flowglyph/adapters/bpmn/__init__.py +226 -0
- flowglyph/adapters/bpmn/graph.py +85 -0
- flowglyph/adapters/bpmn/model.py +277 -0
- flowglyph/adapters/bpmn/rules.py +78 -0
- flowglyph/adapters/n8n/__init__.py +410 -0
- flowglyph/adapters/n8n/nodes.py +104 -0
- flowglyph/adapters/openapi/__init__.py +305 -0
- flowglyph/adapters/openapi/refs.py +69 -0
- flowglyph/adapters/sap/__init__.py +367 -0
- flowglyph/adapters/sap/edmx.py +295 -0
- flowglyph/adapters/sap_cpi/__init__.py +219 -0
- flowglyph/adapters/sap_cpi/bundle.py +99 -0
- flowglyph/adapters/sap_cpi/discovery.py +40 -0
- flowglyph/adapters/sap_cpi/graph.py +105 -0
- flowglyph/adapters/sap_cpi/iflow.py +221 -0
- flowglyph/adapters/sap_cpi/linking.py +102 -0
- flowglyph/adapters/sap_cpi/rules.py +184 -0
- flowglyph/adapters/sbpa/__init__.py +375 -0
- flowglyph/adapters/sbpa/package.py +139 -0
- flowglyph/adapters/uipath/__init__.py +253 -0
- flowglyph/adapters/uipath/graph.py +255 -0
- flowglyph/adapters/uipath/parser.py +244 -0
- flowglyph/adapters/uipath/project.py +76 -0
- flowglyph/adapters/uipath/taxonomy.py +113 -0
- flowglyph/adapters/uipath/taxonomy.yaml +142 -0
- flowglyph/adapters/uipath/template.py +94 -0
- flowglyph/adapters/uipath/xaml.py +148 -0
- flowglyph/catalog/__init__.py +25 -0
- flowglyph/catalog/extraction.py +108 -0
- flowglyph/catalog/lock.py +163 -0
- flowglyph/catalog/model.py +205 -0
- flowglyph/catalog/overrides.py +54 -0
- flowglyph/catalog/versioning.py +151 -0
- flowglyph/cli/__init__.py +14 -0
- flowglyph/cli/_shared.py +109 -0
- flowglyph/cli/commands/__init__.py +1 -0
- flowglyph/cli/commands/catalog.py +130 -0
- flowglyph/cli/commands/compile.py +72 -0
- flowglyph/cli/commands/context.py +64 -0
- flowglyph/cli/commands/enrich.py +76 -0
- flowglyph/cli/commands/explore.py +172 -0
- flowglyph/cli/commands/gateway.py +143 -0
- flowglyph/cli/commands/plan.py +77 -0
- flowglyph/cli/commands/skill.py +24 -0
- flowglyph/cli/commands/wiki.py +76 -0
- flowglyph/cli/main.py +62 -0
- flowglyph/compilers/__init__.py +87 -0
- flowglyph/compilers/_layout.py +77 -0
- flowglyph/compilers/bpmn.py +257 -0
- flowglyph/compilers/xaml.py +300 -0
- flowglyph/context.py +495 -0
- flowglyph/enrichment.py +268 -0
- flowglyph/errors.py +29 -0
- flowglyph/gateway/__init__.py +31 -0
- flowglyph/gateway/audit.py +29 -0
- flowglyph/gateway/executors/__init__.py +62 -0
- flowglyph/gateway/executors/_http.py +133 -0
- flowglyph/gateway/executors/auth.py +144 -0
- flowglyph/gateway/executors/connections.py +203 -0
- flowglyph/gateway/executors/http.py +100 -0
- flowglyph/gateway/executors/odata.py +288 -0
- flowglyph/gateway/executors/orchestrator.py +156 -0
- flowglyph/gateway/ledger.py +186 -0
- flowglyph/gateway/policy.py +80 -0
- flowglyph/gateway/server.py +297 -0
- flowglyph/gateway/service.py +307 -0
- flowglyph/ir.py +312 -0
- flowglyph/planner/__init__.py +111 -0
- flowglyph/planner/backends.py +77 -0
- flowglyph/planner/claude.py +98 -0
- flowglyph/planner/claude_code.py +31 -0
- flowglyph/planner/command.py +258 -0
- flowglyph/planner/documents.py +191 -0
- flowglyph/planner/model.py +93 -0
- flowglyph/planner/openai_compatible.py +125 -0
- flowglyph/planner/prompt.py +38 -0
- flowglyph/planner/resolver.py +299 -0
- flowglyph/py.typed +0 -0
- flowglyph/report.py +139 -0
- flowglyph/skill/SKILL.md +72 -0
- flowglyph/skill/__init__.py +1 -0
- flowglyph/sources.py +129 -0
- flowglyph/views.py +56 -0
- flowglyph/wiki.py +443 -0
- flowglyph-0.1.0.dist-info/METADATA +401 -0
- flowglyph-0.1.0.dist-info/RECORD +102 -0
- flowglyph-0.1.0.dist-info/WHEEL +4 -0
- flowglyph-0.1.0.dist-info/entry_points.txt +11 -0
- flowglyph-0.1.0.dist-info/licenses/LICENSE +201 -0
- flowglyph-0.1.0.dist-info/licenses/NOTICE +29 -0
flowglyph/__init__.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""FlowGlyph: enterprise automations as a vendor-neutral IR for agents.
|
|
2
|
+
|
|
3
|
+
```python
|
|
4
|
+
import flowglyph
|
|
5
|
+
from flowglyph.views import effects_code
|
|
6
|
+
|
|
7
|
+
catalog = flowglyph.extract("path/to/uipath-project")
|
|
8
|
+
for capability in catalog.search("read configuration from excel", limit=5):
|
|
9
|
+
print(capability.id, effects_code(capability.effects), capability.idempotency.mode)
|
|
10
|
+
catalog.save("out")
|
|
11
|
+
```
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
15
|
+
|
|
16
|
+
from flowglyph.adapters import Adapter, Diagnostic, Extraction, register_adapter
|
|
17
|
+
from flowglyph.catalog import Catalog, Lock, extract, load_catalog
|
|
18
|
+
from flowglyph.catalog.lock import Bump
|
|
19
|
+
from flowglyph.catalog.versioning import Change
|
|
20
|
+
from flowglyph.errors import FlowGlyphError
|
|
21
|
+
from flowglyph.ir import (
|
|
22
|
+
IR_VERSION,
|
|
23
|
+
Capability,
|
|
24
|
+
Edge,
|
|
25
|
+
Effects,
|
|
26
|
+
Idempotency,
|
|
27
|
+
Node,
|
|
28
|
+
Parameter,
|
|
29
|
+
Security,
|
|
30
|
+
Semantics,
|
|
31
|
+
Source,
|
|
32
|
+
Tri,
|
|
33
|
+
Trigger,
|
|
34
|
+
Workflow,
|
|
35
|
+
export_json_schemas,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
try:
|
|
39
|
+
__version__ = version("flowglyph")
|
|
40
|
+
except PackageNotFoundError:
|
|
41
|
+
__version__ = "0.0.0"
|
|
42
|
+
|
|
43
|
+
__all__ = [
|
|
44
|
+
"IR_VERSION",
|
|
45
|
+
"Adapter",
|
|
46
|
+
"Bump",
|
|
47
|
+
"Capability",
|
|
48
|
+
"Catalog",
|
|
49
|
+
"Change",
|
|
50
|
+
"Diagnostic",
|
|
51
|
+
"Edge",
|
|
52
|
+
"Effects",
|
|
53
|
+
"Extraction",
|
|
54
|
+
"FlowGlyphError",
|
|
55
|
+
"Idempotency",
|
|
56
|
+
"Lock",
|
|
57
|
+
"Node",
|
|
58
|
+
"Parameter",
|
|
59
|
+
"Security",
|
|
60
|
+
"Semantics",
|
|
61
|
+
"Source",
|
|
62
|
+
"Tri",
|
|
63
|
+
"Trigger",
|
|
64
|
+
"Workflow",
|
|
65
|
+
"__version__",
|
|
66
|
+
"export_json_schemas",
|
|
67
|
+
"extract",
|
|
68
|
+
"load_catalog",
|
|
69
|
+
"register_adapter",
|
|
70
|
+
]
|
flowglyph/__main__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Helpers shared across FlowGlyph. Not public API: they may change in any release."""
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Reading zip archives from untrusted sources in memory, with limits against zip bombs."""
|
|
2
|
+
|
|
3
|
+
import io
|
|
4
|
+
import zipfile
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from flowglyph.errors import AdapterError
|
|
9
|
+
|
|
10
|
+
MAX_ENTRY_BYTES = 20_000_000
|
|
11
|
+
MAX_TOTAL_BYTES = 200_000_000
|
|
12
|
+
MAX_COMPRESSION_RATIO = 200
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def read_zip(
|
|
16
|
+
source: Path | bytes, name: str, wanted: Callable[[str], bool] = lambda _: True
|
|
17
|
+
) -> dict[str, bytes]:
|
|
18
|
+
"""The entries of a zip archive that `wanted` accepts, read without touching the disk.
|
|
19
|
+
|
|
20
|
+
Raises:
|
|
21
|
+
AdapterError: If the archive is corrupt, or an entry is too large or compresses too well
|
|
22
|
+
to be read safely.
|
|
23
|
+
"""
|
|
24
|
+
try:
|
|
25
|
+
archive = zipfile.ZipFile(io.BytesIO(source) if isinstance(source, bytes) else source)
|
|
26
|
+
except (zipfile.BadZipFile, OSError) as error:
|
|
27
|
+
raise AdapterError(f"{name}: {error}") from error
|
|
28
|
+
files: dict[str, bytes] = {}
|
|
29
|
+
total = 0
|
|
30
|
+
with archive:
|
|
31
|
+
for info in archive.infolist():
|
|
32
|
+
if info.is_dir() or not wanted(info.filename):
|
|
33
|
+
continue
|
|
34
|
+
ratio = info.file_size / max(info.compress_size, 1)
|
|
35
|
+
total += info.file_size
|
|
36
|
+
if info.file_size > MAX_ENTRY_BYTES or ratio > MAX_COMPRESSION_RATIO:
|
|
37
|
+
raise AdapterError(f"{name}: {info.filename} is too large to read safely")
|
|
38
|
+
if total > MAX_TOTAL_BYTES:
|
|
39
|
+
raise AdapterError(f"{name}: the archive is too large to read safely")
|
|
40
|
+
try:
|
|
41
|
+
files[info.filename] = archive.read(info)
|
|
42
|
+
except (zipfile.BadZipFile, OSError, RuntimeError) as error:
|
|
43
|
+
raise AdapterError(f"{name}: {info.filename}: {error}") from error
|
|
44
|
+
return files
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Graph steps every adapter needs: following flows through folded nodes, and marking joins."""
|
|
2
|
+
|
|
3
|
+
from collections import Counter
|
|
4
|
+
from collections.abc import Collection, Iterable, Mapping, Sequence
|
|
5
|
+
|
|
6
|
+
from flowglyph.ir import Edge, Node
|
|
7
|
+
|
|
8
|
+
type Flow = tuple[str, str | None]
|
|
9
|
+
"""Where a flow goes and under which condition, if any."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def follow(outgoing: Mapping[str, Sequence[Flow]], kept: Collection[str]) -> list[Edge]:
|
|
13
|
+
"""Edges between kept nodes, following flows through the nodes that were folded away.
|
|
14
|
+
|
|
15
|
+
A flow keeps the first condition met on its way, so two branches that meet again in a
|
|
16
|
+
folded node still reach the next kept node as two edges, each with its own condition.
|
|
17
|
+
"""
|
|
18
|
+
edges: set[Edge] = set()
|
|
19
|
+
for start in kept:
|
|
20
|
+
seen: set[Flow] = set()
|
|
21
|
+
pending = list(outgoing.get(start, ()))
|
|
22
|
+
while pending:
|
|
23
|
+
target, condition = pending.pop()
|
|
24
|
+
if target in kept:
|
|
25
|
+
edges.add(Edge(source=start, target=target, condition=condition))
|
|
26
|
+
elif (target, condition) not in seen:
|
|
27
|
+
seen.add((target, condition))
|
|
28
|
+
pending.extend(
|
|
29
|
+
(next_target, condition or next_condition)
|
|
30
|
+
for next_target, next_condition in outgoing.get(target, ())
|
|
31
|
+
)
|
|
32
|
+
return sorted(edges, key=lambda edge: (edge.source, edge.target, edge.condition or ""))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def with_joins(
|
|
36
|
+
nodes: Iterable[Node], edges: Iterable[Edge], parallel: Collection[str] = ()
|
|
37
|
+
) -> list[Node]:
|
|
38
|
+
"""Nodes where several edges meet wait for any of them, unless they join parallel branches."""
|
|
39
|
+
incoming = Counter(edge.target for edge in edges)
|
|
40
|
+
return [
|
|
41
|
+
node.model_copy(update={"join": "any"})
|
|
42
|
+
if incoming[node.id] > 1 and node.id not in parallel
|
|
43
|
+
else node
|
|
44
|
+
for node in nodes
|
|
45
|
+
]
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Content hashes for lock files, stable across runs and machines."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def digest(data: bytes) -> str:
|
|
9
|
+
"""Short, stable content hash used in lock files."""
|
|
10
|
+
return "sha256:" + hashlib.sha256(data).hexdigest()[:32]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def fingerprint(value: Any) -> str:
|
|
14
|
+
"""Hash of a JSON-serialisable value, independent of key order."""
|
|
15
|
+
canonical = json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
|
|
16
|
+
return digest(canonical.encode())
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""The L1 representation: Markdown cards for capabilities and the catalog index."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
from collections import defaultdict
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
from flowglyph._internal.text import first_line
|
|
10
|
+
from flowglyph.ir import Capability, Parameter, Tri, Workflow
|
|
11
|
+
from flowglyph.views import effects_code, retry_advice
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from flowglyph.catalog import Catalog
|
|
15
|
+
|
|
16
|
+
_UNSAFE_FILENAME = re.compile(r"[^\w.-]+")
|
|
17
|
+
_EFFECTS_LEGEND = (
|
|
18
|
+
"Effects codes: R reads, W mutates state, D destructive, X external. "
|
|
19
|
+
"Upper case means yes, lower case means unknown."
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def card_filename(capability: Capability) -> str:
|
|
24
|
+
"""File name of the Markdown card of `capability`."""
|
|
25
|
+
return _UNSAFE_FILENAME.sub("_", capability.id).strip("_") + ".md"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _cell(value: object) -> str:
|
|
29
|
+
return str(value).replace("|", "\\|").replace("\n", " ")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _effects_line(capability: Capability) -> str:
|
|
33
|
+
effects = capability.effects
|
|
34
|
+
parts = [
|
|
35
|
+
f"{name}: {value.value}"
|
|
36
|
+
for name, value in (
|
|
37
|
+
("reads", effects.reads),
|
|
38
|
+
("mutates state", effects.mutates_state),
|
|
39
|
+
("destructive", effects.destructive),
|
|
40
|
+
("external", effects.external),
|
|
41
|
+
)
|
|
42
|
+
]
|
|
43
|
+
return ", ".join(parts)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _trigger_line(capability: Capability) -> str:
|
|
47
|
+
trigger = capability.trigger
|
|
48
|
+
if trigger is None:
|
|
49
|
+
return ""
|
|
50
|
+
kind = {"call": "a call", "schedule": "a schedule", "event": "an event"}[trigger.kind]
|
|
51
|
+
via = f" through {trigger.channel}" if trigger.channel else ""
|
|
52
|
+
at = f" at `{trigger.address}`" if trigger.address else ""
|
|
53
|
+
return f"{kind}{via}{at}"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _idempotency_line(capability: Capability) -> str:
|
|
57
|
+
idempotency = capability.idempotency
|
|
58
|
+
return (
|
|
59
|
+
f"{idempotency.mode}, {retry_advice(capability.idempotency)} "
|
|
60
|
+
f"({idempotency.provenance}, confidence {idempotency.confidence:.1f})"
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _parameter_table(parameters: list[Parameter], *, inputs: bool) -> list[str]:
|
|
65
|
+
if not parameters:
|
|
66
|
+
return ["None."]
|
|
67
|
+
header = (
|
|
68
|
+
"| Name | Type | Required | Description |" if inputs else "| Name | Type | Description |"
|
|
69
|
+
)
|
|
70
|
+
rows = [header, "|---|---|---|---|" if inputs else "|---|---|---|"]
|
|
71
|
+
for parameter in parameters:
|
|
72
|
+
description = _cell(parameter.description or "")
|
|
73
|
+
if inputs:
|
|
74
|
+
required = "yes" if parameter.required else "no"
|
|
75
|
+
rows.append(f"| `{parameter.name}` | {_type(parameter)} | {required} | {description} |")
|
|
76
|
+
else:
|
|
77
|
+
rows.append(f"| `{parameter.name}` | {_type(parameter)} | {description} |")
|
|
78
|
+
return rows
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _graph_section(workflow: Workflow, capabilities: Mapping[str, Capability]) -> list[str]:
|
|
82
|
+
lines = ["## Steps", ""]
|
|
83
|
+
for position, node in enumerate(workflow.nodes, start=1):
|
|
84
|
+
target = capabilities.get(node.capability) if node.capability else None
|
|
85
|
+
reference = f" -> [{target.title}]({card_filename(target)})" if target else ""
|
|
86
|
+
join = ", runs if any predecessor ran" if node.join == "any" else ""
|
|
87
|
+
lines.append(f"{position}. **{_cell(node.label)}** ({node.kind}{join}){reference}")
|
|
88
|
+
if node.note:
|
|
89
|
+
lines.append(f" {_cell(first_line(node.note, 240))}")
|
|
90
|
+
conditional = [edge for edge in workflow.edges if edge.condition]
|
|
91
|
+
if conditional:
|
|
92
|
+
lines += ["", "Branches:", ""]
|
|
93
|
+
lines += [f"- `{e.source}` -> `{e.target}` when `{e.condition}`" for e in conditional]
|
|
94
|
+
if workflow.unresolved_calls:
|
|
95
|
+
lines += ["", "Unresolved invocations:", ""]
|
|
96
|
+
lines += [f"- `{call}`" for call in workflow.unresolved_calls]
|
|
97
|
+
return [*lines, ""]
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def render_capability(
|
|
101
|
+
capability: Capability,
|
|
102
|
+
workflows: Mapping[str, Workflow],
|
|
103
|
+
capabilities: Mapping[str, Capability],
|
|
104
|
+
) -> str:
|
|
105
|
+
"""The L1 card of a capability: what an agent reads before filling its arguments."""
|
|
106
|
+
front_matter = {
|
|
107
|
+
"id": capability.id,
|
|
108
|
+
"kind": capability.kind,
|
|
109
|
+
"category": capability.category,
|
|
110
|
+
"version": capability.version,
|
|
111
|
+
"effects": effects_code(capability.effects) or "none",
|
|
112
|
+
"idempotency": capability.idempotency.mode,
|
|
113
|
+
"invocable": capability.invocable,
|
|
114
|
+
}
|
|
115
|
+
lines = ["---", *(f"{key}: {json.dumps(value)}" for key, value in front_matter.items()), "---"]
|
|
116
|
+
lines += ["", f"# {capability.title}", ""]
|
|
117
|
+
if capability.description:
|
|
118
|
+
lines += [capability.description, ""]
|
|
119
|
+
if capability.generated:
|
|
120
|
+
written = " and ".join(capability.generated)
|
|
121
|
+
lines += [f"_The {written} were written by a language model: the source did not say._", ""]
|
|
122
|
+
lines += [
|
|
123
|
+
f"- **Source:** `{capability.source.location}` ({capability.source.provider})",
|
|
124
|
+
f"- **Effects:** {_effects_line(capability)}",
|
|
125
|
+
f"- **Idempotency:** {_idempotency_line(capability)}",
|
|
126
|
+
]
|
|
127
|
+
if capability.trigger is not None:
|
|
128
|
+
lines.append(f"- **Started by:** {_trigger_line(capability)}")
|
|
129
|
+
if capability.configuration:
|
|
130
|
+
names = ", ".join(f"`{name}`" for name in capability.configuration)
|
|
131
|
+
lines.append(f"- **Configuration:** {names}")
|
|
132
|
+
if capability.deprecated:
|
|
133
|
+
lines.append("- **Deprecated** in its source")
|
|
134
|
+
if capability.security.approval_required:
|
|
135
|
+
lines.append("- **Approval:** required before every run")
|
|
136
|
+
lines.append("")
|
|
137
|
+
if capability.notes:
|
|
138
|
+
lines += ["> **Notes**", *(f"> - {note}" for note in capability.notes), ""]
|
|
139
|
+
lines += ["## Inputs", "", *_parameter_table(capability.inputs, inputs=True), ""]
|
|
140
|
+
if capability.kind != "activity":
|
|
141
|
+
lines += ["## Outputs", "", *_parameter_table(capability.outputs, inputs=False), ""]
|
|
142
|
+
workflow = workflows.get(capability.id)
|
|
143
|
+
if workflow is not None:
|
|
144
|
+
lines += _graph_section(workflow, capabilities)
|
|
145
|
+
if capability.used_in:
|
|
146
|
+
lines += ["## Used in", "", *(f"- `{location}`" for location in capability.used_in), ""]
|
|
147
|
+
lines += [
|
|
148
|
+
"## Contract",
|
|
149
|
+
"",
|
|
150
|
+
"```json",
|
|
151
|
+
json.dumps(
|
|
152
|
+
{"input": capability.input_schema, "output": capability.output_schema},
|
|
153
|
+
indent=2,
|
|
154
|
+
ensure_ascii=False,
|
|
155
|
+
),
|
|
156
|
+
"```",
|
|
157
|
+
"",
|
|
158
|
+
]
|
|
159
|
+
return "\n".join(lines)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def render_index(catalog: "Catalog") -> str:
|
|
163
|
+
"""The catalog's front page: every workflow and activity at a glance."""
|
|
164
|
+
workflows = sorted(
|
|
165
|
+
(c for c in catalog if c.kind != "activity"), key=lambda capability: capability.id
|
|
166
|
+
)
|
|
167
|
+
activities = sorted((c for c in catalog if c.kind == "activity"), key=lambda c: c.title)
|
|
168
|
+
lines = [
|
|
169
|
+
"# Catalog",
|
|
170
|
+
"",
|
|
171
|
+
f"{len(workflows)} invocable capabilities, {len(activities)} activity types.",
|
|
172
|
+
"",
|
|
173
|
+
"## Invocable",
|
|
174
|
+
"",
|
|
175
|
+
"| Capability | Description | Inputs | Effects | Idempotency | Version |",
|
|
176
|
+
"|---|---|---|---|---|---|",
|
|
177
|
+
]
|
|
178
|
+
for capability in workflows:
|
|
179
|
+
inputs = ", ".join(f"`{parameter.name}`" for parameter in capability.inputs) or "none"
|
|
180
|
+
lines.append(
|
|
181
|
+
f"| [{capability.title}](capabilities/{card_filename(capability)}) "
|
|
182
|
+
f"| {_cell(first_line(capability.description, 100))} | {inputs} "
|
|
183
|
+
f"| {effects_code(capability.effects) or 'none'} | {capability.idempotency.mode} "
|
|
184
|
+
f"| {capability.version} |"
|
|
185
|
+
)
|
|
186
|
+
by_category: defaultdict[str, list[Capability]] = defaultdict(list)
|
|
187
|
+
for capability in activities:
|
|
188
|
+
by_category[capability.category].append(capability)
|
|
189
|
+
if by_category:
|
|
190
|
+
lines += ["", "## Activity types", ""]
|
|
191
|
+
for category in sorted(by_category):
|
|
192
|
+
lines += [f"### {category}", ""]
|
|
193
|
+
for capability in by_category[category]:
|
|
194
|
+
flag = " (side effects)" if capability.effects.has_side_effects else ""
|
|
195
|
+
mutates = capability.effects.mutates_state
|
|
196
|
+
certainty = " (unknown)" if mutates is Tri.UNKNOWN else ""
|
|
197
|
+
lines.append(
|
|
198
|
+
f"- [{capability.title}](capabilities/{card_filename(capability)}){flag}{certainty}"
|
|
199
|
+
)
|
|
200
|
+
lines.append("")
|
|
201
|
+
lines += [
|
|
202
|
+
_EFFECTS_LEGEND,
|
|
203
|
+
"",
|
|
204
|
+
]
|
|
205
|
+
return "\n".join(lines)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _type(parameter: Parameter) -> str:
|
|
209
|
+
return f"{parameter.type} or null" if parameter.nullable else parameter.type
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Short names that tell apart artifacts sharing a name in different folders."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from pathlib import PurePosixPath
|
|
5
|
+
|
|
6
|
+
_UNSAFE = re.compile(r"[^A-Za-z0-9.-]+")
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def distinguishing_names(paths: list[str]) -> list[str]:
|
|
10
|
+
"""The shortest path segment that tells each of several files apart.
|
|
11
|
+
|
|
12
|
+
`["v2/API/API.edmx", "v4/API/API.edmx"]` gives `["v2", "v4"]`; files in one folder fall back
|
|
13
|
+
to their names.
|
|
14
|
+
"""
|
|
15
|
+
parts = [PurePosixPath(path).with_suffix("").parts for path in paths]
|
|
16
|
+
for index in range(max(len(p) for p in parts)):
|
|
17
|
+
segments = [p[index] if index < len(p) else "" for p in parts]
|
|
18
|
+
if len(set(segments)) == len(segments):
|
|
19
|
+
return [_UNSAFE.sub("_", segment).strip("_") for segment in segments]
|
|
20
|
+
return [_UNSAFE.sub("_", "/".join(p)).strip("_") for p in parts]
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""BM25 ranking over capabilities, with weighted fields and no dependencies."""
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
from collections import Counter
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
|
|
8
|
+
from flowglyph._internal.text import tokenize
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class Document:
|
|
13
|
+
"""A searchable text with its fields weighted by repetition."""
|
|
14
|
+
|
|
15
|
+
key: str
|
|
16
|
+
fields: Sequence[tuple[str, int]]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class BM25Index:
|
|
21
|
+
"""Okapi BM25 over a fixed set of documents.
|
|
22
|
+
|
|
23
|
+
Small enough to avoid a dependency and fast enough for catalogs of tens of thousands of
|
|
24
|
+
capabilities.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
documents: Sequence[Document]
|
|
28
|
+
k1: float = 1.5
|
|
29
|
+
b: float = 0.75
|
|
30
|
+
_frequencies: list[Counter[str]] = field(init=False, default_factory=list[Counter[str]])
|
|
31
|
+
_idf: dict[str, float] = field(init=False, default_factory=dict[str, float])
|
|
32
|
+
_average_length: float = field(init=False, default=0.0)
|
|
33
|
+
|
|
34
|
+
def __post_init__(self) -> None:
|
|
35
|
+
for document in self.documents:
|
|
36
|
+
counts: Counter[str] = Counter()
|
|
37
|
+
for text, weight in document.fields:
|
|
38
|
+
for token in tokenize(text):
|
|
39
|
+
counts[token] += weight
|
|
40
|
+
self._frequencies.append(counts)
|
|
41
|
+
total = len(self._frequencies) or 1
|
|
42
|
+
self._average_length = sum(sum(c.values()) for c in self._frequencies) / total
|
|
43
|
+
document_frequency: Counter[str] = Counter()
|
|
44
|
+
for counts in self._frequencies:
|
|
45
|
+
document_frequency.update(counts.keys())
|
|
46
|
+
self._idf = {
|
|
47
|
+
token: math.log(1 + (total - seen + 0.5) / (seen + 0.5))
|
|
48
|
+
for token, seen in document_frequency.items()
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
def search(self, query: str, limit: int = 10) -> list[tuple[str, float]]:
|
|
52
|
+
"""Keys of the best matching documents with their scores, best first."""
|
|
53
|
+
terms = tokenize(query)
|
|
54
|
+
scored: list[tuple[str, float]] = []
|
|
55
|
+
for document, counts in zip(self.documents, self._frequencies, strict=True):
|
|
56
|
+
length = sum(counts.values())
|
|
57
|
+
score = 0.0
|
|
58
|
+
for term in terms:
|
|
59
|
+
frequency = counts.get(term, 0)
|
|
60
|
+
if not frequency:
|
|
61
|
+
continue
|
|
62
|
+
normaliser = self.k1 * (1 - self.b + self.b * length / (self._average_length or 1))
|
|
63
|
+
score += self._idf[term] * frequency * (self.k1 + 1) / (frequency + normaliser)
|
|
64
|
+
if score > 0:
|
|
65
|
+
scored.append((document.key, score))
|
|
66
|
+
scored.sort(key=lambda item: (-item[1], item[0]))
|
|
67
|
+
return scored[:limit]
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Tokenizing and trimming text for search and summaries."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
_CAMEL_BOUNDARY = re.compile(r"(?<=[a-z0-9])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])")
|
|
6
|
+
_WORD = re.compile(r"[^\W_]+")
|
|
7
|
+
_ARGUMENT_PREFIX = re.compile(r"^(in|out|io)_")
|
|
8
|
+
_SHORTEST_PLURAL = 4
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def tokenize(text: str) -> list[str]:
|
|
12
|
+
"""Lower-case search tokens, splitting camelCase and snake_case identifiers.
|
|
13
|
+
|
|
14
|
+
UiPath argument prefixes (`in_`, `out_`, `io_`) are dropped so that `in_ConfigFile`
|
|
15
|
+
matches a search for "config file".
|
|
16
|
+
"""
|
|
17
|
+
tokens: list[str] = []
|
|
18
|
+
for raw in text.split():
|
|
19
|
+
for word in _WORD.findall(_ARGUMENT_PREFIX.sub("", raw)):
|
|
20
|
+
tokens.extend(
|
|
21
|
+
_singular(part.lower()) for part in _CAMEL_BOUNDARY.split(word) if len(part) > 1
|
|
22
|
+
)
|
|
23
|
+
return tokens
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _singular(token: str) -> str:
|
|
27
|
+
"""Fold simple English plurals so "duplicates" matches "Duplicate"."""
|
|
28
|
+
if len(token) > _SHORTEST_PLURAL and token.endswith("ies"):
|
|
29
|
+
return token[:-3] + "y"
|
|
30
|
+
if (
|
|
31
|
+
len(token) >= _SHORTEST_PLURAL
|
|
32
|
+
and token.endswith("s")
|
|
33
|
+
and not token.endswith(("ss", "us", "is"))
|
|
34
|
+
):
|
|
35
|
+
return token[:-1]
|
|
36
|
+
return token
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def first_line(text: str, limit: int = 120) -> str:
|
|
40
|
+
"""The first line of `text`, cut to `limit` characters."""
|
|
41
|
+
line = text.strip().split("\n", 1)[0].strip()
|
|
42
|
+
return line if len(line) <= limit else line[: limit - 1].rstrip() + "…"
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""XML parsing for untrusted sources: documents that declare a DTD are refused."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from xml.etree import ElementTree as ET
|
|
5
|
+
from xml.parsers import expat
|
|
6
|
+
|
|
7
|
+
from flowglyph.errors import AdapterError
|
|
8
|
+
|
|
9
|
+
_LAST_CHUNK = True
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def load_xml(path: Path) -> ET.Element:
|
|
13
|
+
"""Parse an XML file, refusing documents that declare a DTD.
|
|
14
|
+
|
|
15
|
+
Raises:
|
|
16
|
+
AdapterError: If the file is not well-formed XML or declares a DTD.
|
|
17
|
+
"""
|
|
18
|
+
return parse_xml(path.read_bytes(), str(path))
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def parse_xml(data: bytes, name: str) -> ET.Element:
|
|
22
|
+
"""Parse XML bytes, refusing documents that declare a DTD.
|
|
23
|
+
|
|
24
|
+
Raises:
|
|
25
|
+
AdapterError: If the data is not well-formed XML or declares a DTD.
|
|
26
|
+
"""
|
|
27
|
+
_refuse_dtd(data, name)
|
|
28
|
+
try:
|
|
29
|
+
# ElementTree never resolves external entities, and with DTDs refused there are no
|
|
30
|
+
# internal entities to expand, so this input is safe to parse.
|
|
31
|
+
return ET.fromstring(data) # noqa: S314
|
|
32
|
+
except ET.ParseError as error:
|
|
33
|
+
raise AdapterError(f"{name}: {error}") from error
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _refuse_dtd(data: bytes, name: str) -> None:
|
|
37
|
+
"""Scan the whole document with expat, which reports a DOCTYPE wherever it appears."""
|
|
38
|
+
|
|
39
|
+
def refuse(*_: object) -> None:
|
|
40
|
+
raise AdapterError(f"{name}: XML documents must not declare a DTD")
|
|
41
|
+
|
|
42
|
+
scanner = expat.ParserCreate()
|
|
43
|
+
scanner.StartDoctypeDeclHandler = refuse
|
|
44
|
+
try:
|
|
45
|
+
scanner.Parse(data, _LAST_CHUNK)
|
|
46
|
+
except expat.ExpatError as error:
|
|
47
|
+
raise AdapterError(f"{name}: {error}") from error
|