flo-lang 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.
Files changed (44) hide show
  1. flo/__init__.py +12 -0
  2. flo/adapters/__init__.py +41 -0
  3. flo/adapters/composition.py +211 -0
  4. flo/adapters/models.py +20 -0
  5. flo/adapters/yaml_loader.py +26 -0
  6. flo/compiler/__init__.py +7 -0
  7. flo/compiler/analysis/__init__.py +20 -0
  8. flo/compiler/analysis/movement.py +451 -0
  9. flo/compiler/analysis/scc.py +121 -0
  10. flo/compiler/compile.py +251 -0
  11. flo/compiler/ir/__init__.py +19 -0
  12. flo/compiler/ir/enums.py +38 -0
  13. flo/compiler/ir/models.py +99 -0
  14. flo/compiler/ir/validate.py +525 -0
  15. flo/core/__init__.py +137 -0
  16. flo/core/cli.py +268 -0
  17. flo/core/cli_args.py +117 -0
  18. flo/export/__init__.py +40 -0
  19. flo/export/ingredients_export.py +7 -0
  20. flo/export/json_export.py +107 -0
  21. flo/export/materials_export.py +100 -0
  22. flo/export/movement_export.py +73 -0
  23. flo/export/options.py +48 -0
  24. flo/pipeline.py +214 -0
  25. flo/render/__init__.py +25 -0
  26. flo/render/_graphviz_dot_common.py +550 -0
  27. flo/render/_graphviz_dot_flow.py +11 -0
  28. flo/render/_graphviz_dot_flowchart.py +58 -0
  29. flo/render/_graphviz_dot_spaghetti.py +551 -0
  30. flo/render/_graphviz_dot_sppm.py +250 -0
  31. flo/render/_graphviz_dot_swimlane.py +180 -0
  32. flo/render/_sppm_themes.py +74 -0
  33. flo/render/graphviz_dot.py +12 -0
  34. flo/render/options.py +139 -0
  35. flo/services/__init__.py +51 -0
  36. flo/services/errors.py +106 -0
  37. flo/services/graphviz.py +60 -0
  38. flo/services/io.py +47 -0
  39. flo/services/logging.py +84 -0
  40. flo/services/telemetry.py +148 -0
  41. flo_lang-0.1.0.dist-info/METADATA +165 -0
  42. flo_lang-0.1.0.dist-info/RECORD +44 -0
  43. flo_lang-0.1.0.dist-info/WHEEL +4 -0
  44. flo_lang-0.1.0.dist-info/entry_points.txt +3 -0
flo/__init__.py ADDED
@@ -0,0 +1,12 @@
1
+ """FLO package public surface.
2
+
3
+ Keep the package import lightweight: importing the top-level package should
4
+ not import CLI-related heavy dependencies (like `click`). Use the console
5
+ entrypoint (`flo`) or import from `flo.core.cli` explicitly where needed,
6
+ instead of exposing CLI modules at package import time.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ __all__: list[str] = []
12
+
@@ -0,0 +1,41 @@
1
+ """Adapters package: YAML/other input adapters for FLO."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Dict
6
+ import yaml
7
+
8
+
9
+ from .yaml_loader import load_adapter_from_yaml
10
+ from .composition import resolve_includes
11
+
12
+
13
+ def parse_adapter(content: str, source_path: str | None = None) -> Dict[str, Any]:
14
+ """Parse adapter content and return a validated mapping.
15
+
16
+ This dispatcher currently supports YAML input via
17
+ `load_adapter_from_yaml`. The loader returns an `AdapterModel`
18
+ which we convert to a plain dict (`model_dump`) so downstream
19
+ compiler code can continue to operate on mappings.
20
+ """
21
+ try:
22
+ model = load_adapter_from_yaml(content)
23
+ except ValueError:
24
+ # If the content is YAML mapping-shaped FLO, return it directly.
25
+ # Otherwise preserve the previous permissive text fallback.
26
+ parsed = yaml.safe_load(content)
27
+ if isinstance(parsed, dict):
28
+ return resolve_includes(parsed, source_path=source_path)
29
+ return {"name": "parsed", "content": content}
30
+
31
+ # `model` supports `model_dump()` for Pydantic compatibility
32
+ try:
33
+ return model.model_dump()
34
+ except Exception:
35
+ # Fallback: if the model does not implement `model_dump`, try
36
+ # converting via `dict()` for compatibility with lightweight
37
+ # fallback models.
38
+ return dict(model)
39
+
40
+
41
+ __all__ = ["load_adapter_from_yaml", "parse_adapter"]
@@ -0,0 +1,211 @@
1
+ """Composition helpers for include-based FLO source documents."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ import yaml
9
+
10
+ _RESOURCE_KEYS = ("materials", "equipment", "locations", "workers")
11
+ _LIST_KEYS = ("steps", "transitions", "edges", "lanes")
12
+
13
+
14
+ def resolve_includes(document: dict[str, Any], source_path: str | None = None) -> dict[str, Any]:
15
+ """Resolve include directives and return a composed mapping.
16
+
17
+ Include syntax:
18
+
19
+ includes:
20
+ - relative/or/absolute/path.yaml
21
+ """
22
+ root_path = Path(source_path).resolve() if source_path else None
23
+ return _compose_document(document=document, current_path=root_path, include_stack=[])
24
+
25
+
26
+ def _compose_document(
27
+ document: dict[str, Any],
28
+ current_path: Path | None,
29
+ include_stack: list[Path],
30
+ ) -> dict[str, Any]:
31
+ include_paths = _normalize_include_entries(document)
32
+ composed: dict[str, Any] = {}
33
+
34
+ for include_ref in include_paths:
35
+ include_path = _resolve_include_path(include_ref=include_ref, current_path=current_path)
36
+ include_doc = _load_include_mapping(include_path=include_path, include_stack=include_stack)
37
+ nested = _compose_document(
38
+ document=include_doc,
39
+ current_path=include_path,
40
+ include_stack=[*include_stack, include_path],
41
+ )
42
+ composed = _merge_documents(base=composed, incoming=nested)
43
+
44
+ local = dict(document)
45
+ local.pop("include", None)
46
+ local.pop("includes", None)
47
+ composed = _merge_documents(base=composed, incoming=local)
48
+
49
+ _validate_unique_ids(composed)
50
+ return composed
51
+
52
+
53
+ def _normalize_include_entries(document: dict[str, Any]) -> list[str]:
54
+ include_value = document.get("includes")
55
+ if include_value is None:
56
+ include_value = document.get("include")
57
+
58
+ if include_value is None:
59
+ return []
60
+
61
+ if isinstance(include_value, str):
62
+ text = include_value.strip()
63
+ return [text] if text else []
64
+
65
+ if not isinstance(include_value, list):
66
+ raise ValueError("include/includes must be a string or list of strings")
67
+
68
+ out: list[str] = []
69
+ for idx, entry in enumerate(include_value):
70
+ if not isinstance(entry, str) or not entry.strip():
71
+ raise ValueError(f"includes[{idx}] must be a non-empty string path")
72
+ out.append(entry.strip())
73
+ return out
74
+
75
+
76
+ def _resolve_include_path(include_ref: str, current_path: Path | None) -> Path:
77
+ raw = Path(include_ref)
78
+ if raw.is_absolute():
79
+ return raw.resolve()
80
+
81
+ base_dir = current_path.parent if current_path is not None else Path.cwd()
82
+ return (base_dir / raw).resolve()
83
+
84
+
85
+ def _load_include_mapping(include_path: Path, include_stack: list[Path]) -> dict[str, Any]:
86
+ if include_path in include_stack:
87
+ chain = " -> ".join(str(path) for path in [*include_stack, include_path])
88
+ raise ValueError(f"include cycle detected: {chain}")
89
+
90
+ if not include_path.exists():
91
+ raise ValueError(f"include file not found: {include_path}")
92
+
93
+ try:
94
+ content = include_path.read_text(encoding="utf-8")
95
+ except OSError as exc:
96
+ raise ValueError(f"unable to read include file '{include_path}': {exc}") from exc
97
+
98
+ parsed = yaml.safe_load(content)
99
+ if not isinstance(parsed, dict):
100
+ raise ValueError(f"include file '{include_path}' must contain a YAML mapping")
101
+
102
+ return parsed
103
+
104
+
105
+ def _merge_documents(base: dict[str, Any], incoming: dict[str, Any]) -> dict[str, Any]:
106
+ merged = dict(base)
107
+
108
+ for key, value in incoming.items():
109
+ if key in _LIST_KEYS:
110
+ merged[key] = _merge_list_values(key=key, base_value=merged.get(key), incoming_value=value)
111
+ continue
112
+
113
+ if key in _RESOURCE_KEYS:
114
+ merged[key] = _merge_resource_values(key=key, base_value=merged.get(key), incoming_value=value)
115
+ continue
116
+
117
+ if key == "process":
118
+ merged[key] = _merge_process(base_value=merged.get(key), incoming_value=value)
119
+ continue
120
+
121
+ if key == "spec_version":
122
+ merged.setdefault(key, value)
123
+ continue
124
+
125
+ merged[key] = value
126
+
127
+ return merged
128
+
129
+
130
+ def _merge_list_values(key: str, base_value: Any, incoming_value: Any) -> list[Any]:
131
+ if incoming_value is None:
132
+ return list(base_value) if isinstance(base_value, list) else []
133
+ if not isinstance(incoming_value, list):
134
+ raise ValueError(f"{key} must be a list")
135
+
136
+ out: list[Any] = []
137
+ if isinstance(base_value, list):
138
+ out.extend(base_value)
139
+ elif base_value is not None:
140
+ raise ValueError(f"{key} must be a list")
141
+
142
+ out.extend(incoming_value)
143
+ return out
144
+
145
+
146
+ def _merge_resource_values(key: str, base_value: Any, incoming_value: Any) -> Any:
147
+ if base_value is None:
148
+ return incoming_value
149
+ if incoming_value is None:
150
+ return base_value
151
+
152
+ if isinstance(base_value, list) and isinstance(incoming_value, list):
153
+ return [*base_value, *incoming_value]
154
+
155
+ if isinstance(base_value, dict) and isinstance(incoming_value, dict):
156
+ merged = dict(base_value)
157
+ for child_key, child_value in incoming_value.items():
158
+ if child_key == "name":
159
+ merged.setdefault("name", child_value)
160
+ continue
161
+ if child_key in merged:
162
+ merged[child_key] = _merge_resource_values(
163
+ key=f"{key}.{child_key}",
164
+ base_value=merged[child_key],
165
+ incoming_value=child_value,
166
+ )
167
+ else:
168
+ merged[child_key] = child_value
169
+ return merged
170
+
171
+ raise ValueError(f"{key} include merge type mismatch: expected list/list or dict/dict")
172
+
173
+
174
+ def _merge_process(base_value: Any, incoming_value: Any) -> Any:
175
+ if base_value is None:
176
+ return incoming_value
177
+ if incoming_value is None:
178
+ return base_value
179
+ if not isinstance(base_value, dict) or not isinstance(incoming_value, dict):
180
+ raise ValueError("process must be an object when present")
181
+
182
+ merged = dict(base_value)
183
+ for key, value in incoming_value.items():
184
+ if key == "metadata" and isinstance(merged.get("metadata"), dict) and isinstance(value, dict):
185
+ merged["metadata"] = {**merged["metadata"], **value}
186
+ continue
187
+ merged[key] = value
188
+ return merged
189
+
190
+
191
+ def _validate_unique_ids(document: dict[str, Any]) -> None:
192
+ _ensure_unique_id_list(document=document, key="steps")
193
+ _ensure_unique_id_list(document=document, key="lanes")
194
+
195
+
196
+ def _ensure_unique_id_list(document: dict[str, Any], key: str) -> None:
197
+ value = document.get(key)
198
+ if not isinstance(value, list):
199
+ return
200
+
201
+ seen: set[str] = set()
202
+ for idx, item in enumerate(value):
203
+ if not isinstance(item, dict):
204
+ continue
205
+ item_id = item.get("id")
206
+ if item_id is None:
207
+ continue
208
+ item_id_text = str(item_id)
209
+ if item_id_text in seen:
210
+ raise ValueError(f"duplicate {key[:-1]} id '{item_id_text}' detected at {key}[{idx}]")
211
+ seen.add(item_id_text)
flo/adapters/models.py ADDED
@@ -0,0 +1,20 @@
1
+ """Adapter models for FLO inputs.
2
+
3
+ Prefer Pydantic v2 models for runtime validation and parsing. If
4
+ Pydantic isn't available the module will still import but some helper
5
+ callers may choose a dict-based fallback.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from pydantic import BaseModel
11
+
12
+
13
+ class AdapterModel(BaseModel):
14
+ """Pydantic-backed AdapterModel used for adapter inputs."""
15
+
16
+ name: str
17
+ content: str
18
+
19
+ # Pydantic v2 provides `model_validate` and `model_dump` so callers
20
+ # can use the same API as before without needing a runtime fallback.
@@ -0,0 +1,26 @@
1
+ """YAML adapter loader.
2
+
3
+ Parses YAML content and returns an `AdapterModel` instance when
4
+ Pydantic is available, otherwise returns a simple fallback model.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import yaml
9
+
10
+ from .models import AdapterModel
11
+
12
+
13
+ def load_adapter_from_yaml(content: str) -> AdapterModel:
14
+ """Parse YAML content and return an `AdapterModel`.
15
+
16
+ Expects the YAML to be a mapping containing at least `name` and
17
+ `content` keys. Returns an `AdapterModel` instance via the
18
+ model-compatible `model_validate` API (works with Pydantic and
19
+ our fallback model).
20
+ """
21
+ data = yaml.safe_load(content)
22
+ if not isinstance(data, dict):
23
+ raise ValueError("YAML content must be a mapping with keys 'name' and 'content'")
24
+
25
+ # Both the Pydantic model and our fallback implement `model_validate`.
26
+ return AdapterModel.model_validate(data)
@@ -0,0 +1,7 @@
1
+ """Compiler package: transforms adapter models to canonical IR."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .compile import compile_adapter
6
+
7
+ __all__ = ["compile_adapter"]
@@ -0,0 +1,20 @@
1
+ """Analysis package nested under the compiler layer."""
2
+ from .scc import scc_condense
3
+ from .movement import (
4
+ infer_material_movements,
5
+ aggregate_material_movements,
6
+ infer_people_movements,
7
+ aggregate_people_movements,
8
+ aggregate_people_movements_by_worker,
9
+ extract_location_spatial_index,
10
+ )
11
+
12
+ __all__ = [
13
+ "scc_condense",
14
+ "infer_material_movements",
15
+ "aggregate_material_movements",
16
+ "infer_people_movements",
17
+ "aggregate_people_movements",
18
+ "aggregate_people_movements_by_worker",
19
+ "extract_location_spatial_index",
20
+ ]