phlo-api 0.3.1__tar.gz → 0.4.0__tar.gz
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.
- {phlo_api-0.3.1 → phlo_api-0.4.0}/PKG-INFO +1 -1
- {phlo_api-0.3.1 → phlo_api-0.4.0}/pyproject.toml +1 -1
- {phlo_api-0.3.1 → phlo_api-0.4.0}/src/phlo_api/observatory_api/v2.py +59 -212
- phlo_api-0.4.0/src/phlo_api/observatory_api/v2_cache.py +37 -0
- {phlo_api-0.3.1 → phlo_api-0.4.0}/src/phlo_api/observatory_api/v2_capabilities.py +6 -0
- phlo_api-0.4.0/src/phlo_api/observatory_api/v2_saved_queries.py +94 -0
- phlo_api-0.4.0/src/phlo_api/observatory_api/v2_search.py +124 -0
- phlo_api-0.4.0/src/phlo_api/observatory_api/v2_services.py +438 -0
- phlo_api-0.4.0/src/phlo_api/observatory_api/v2_workflow_wizard.py +1135 -0
- {phlo_api-0.3.1 → phlo_api-0.4.0}/src/phlo_api.egg-info/PKG-INFO +1 -1
- {phlo_api-0.3.1 → phlo_api-0.4.0}/src/phlo_api.egg-info/SOURCES.txt +7 -0
- {phlo_api-0.3.1 → phlo_api-0.4.0}/tests/test_observatory_v2_api.py +2 -0
- {phlo_api-0.3.1 → phlo_api-0.4.0}/tests/test_observatory_v2_capabilities.py +1 -0
- phlo_api-0.4.0/tests/test_observatory_v2_read_models.py +76 -0
- phlo_api-0.4.0/tests/test_observatory_v2_workflow_wizard.py +291 -0
- {phlo_api-0.3.1 → phlo_api-0.4.0}/README.md +0 -0
- {phlo_api-0.3.1 → phlo_api-0.4.0}/setup.cfg +0 -0
- {phlo_api-0.3.1 → phlo_api-0.4.0}/src/phlo_api/Dockerfile +0 -0
- {phlo_api-0.3.1 → phlo_api-0.4.0}/src/phlo_api/__init__.py +0 -0
- {phlo_api-0.3.1 → phlo_api-0.4.0}/src/phlo_api/api/__init__.py +0 -0
- {phlo_api-0.3.1 → phlo_api-0.4.0}/src/phlo_api/api/authentication.py +0 -0
- {phlo_api-0.3.1 → phlo_api-0.4.0}/src/phlo_api/api/authorization.py +0 -0
- {phlo_api-0.3.1 → phlo_api-0.4.0}/src/phlo_api/api/maintenance.py +0 -0
- {phlo_api-0.3.1 → phlo_api-0.4.0}/src/phlo_api/api/observability.py +0 -0
- {phlo_api-0.3.1 → phlo_api-0.4.0}/src/phlo_api/main.py +0 -0
- {phlo_api-0.3.1 → phlo_api-0.4.0}/src/phlo_api/observatory_api/__init__.py +0 -0
- {phlo_api-0.3.1 → phlo_api-0.4.0}/src/phlo_api/observatory_api/contributing.py +0 -0
- {phlo_api-0.3.1 → phlo_api-0.4.0}/src/phlo_api/observatory_api/dagster.py +0 -0
- {phlo_api-0.3.1 → phlo_api-0.4.0}/src/phlo_api/observatory_api/extension_settings.py +0 -0
- {phlo_api-0.3.1 → phlo_api-0.4.0}/src/phlo_api/observatory_api/extensions.py +0 -0
- {phlo_api-0.3.1 → phlo_api-0.4.0}/src/phlo_api/observatory_api/iceberg.py +0 -0
- {phlo_api-0.3.1 → phlo_api-0.4.0}/src/phlo_api/observatory_api/lineage.py +0 -0
- {phlo_api-0.3.1 → phlo_api-0.4.0}/src/phlo_api/observatory_api/loki.py +0 -0
- {phlo_api-0.3.1 → phlo_api-0.4.0}/src/phlo_api/observatory_api/nessie.py +0 -0
- {phlo_api-0.3.1 → phlo_api-0.4.0}/src/phlo_api/observatory_api/quality.py +0 -0
- {phlo_api-0.3.1 → phlo_api-0.4.0}/src/phlo_api/observatory_api/search.py +0 -0
- {phlo_api-0.3.1 → phlo_api-0.4.0}/src/phlo_api/observatory_api/settings.py +0 -0
- {phlo_api-0.3.1 → phlo_api-0.4.0}/src/phlo_api/observatory_api/trino.py +0 -0
- {phlo_api-0.3.1 → phlo_api-0.4.0}/src/phlo_api/observatory_api/trino_sql.py +0 -0
- {phlo_api-0.3.1 → phlo_api-0.4.0}/src/phlo_api/observatory_api/v2_actions.py +0 -0
- {phlo_api-0.3.1 → phlo_api-0.4.0}/src/phlo_api/observatory_api/v2_catalog.py +0 -0
- {phlo_api-0.3.1 → phlo_api-0.4.0}/src/phlo_api/observatory_api/v2_governance.py +0 -0
- {phlo_api-0.3.1 → phlo_api-0.4.0}/src/phlo_api/observatory_api/v2_metadata.py +0 -0
- {phlo_api-0.3.1 → phlo_api-0.4.0}/src/phlo_api/observatory_api/v2_models.py +0 -0
- {phlo_api-0.3.1 → phlo_api-0.4.0}/src/phlo_api/observatory_api/v2_observability.py +0 -0
- {phlo_api-0.3.1 → phlo_api-0.4.0}/src/phlo_api/observatory_api/v2_products.py +0 -0
- {phlo_api-0.3.1 → phlo_api-0.4.0}/src/phlo_api/observatory_api/v2_runs.py +0 -0
- {phlo_api-0.3.1 → phlo_api-0.4.0}/src/phlo_api/observatory_api/v2_storage.py +0 -0
- {phlo_api-0.3.1 → phlo_api-0.4.0}/src/phlo_api/plugin.py +0 -0
- {phlo_api-0.3.1 → phlo_api-0.4.0}/src/phlo_api/regulated_surface_adapter.py +0 -0
- {phlo_api-0.3.1 → phlo_api-0.4.0}/src/phlo_api/service.yaml +0 -0
- {phlo_api-0.3.1 → phlo_api-0.4.0}/src/phlo_api.egg-info/dependency_links.txt +0 -0
- {phlo_api-0.3.1 → phlo_api-0.4.0}/src/phlo_api.egg-info/entry_points.txt +0 -0
- {phlo_api-0.3.1 → phlo_api-0.4.0}/src/phlo_api.egg-info/requires.txt +0 -0
- {phlo_api-0.3.1 → phlo_api-0.4.0}/src/phlo_api.egg-info/top_level.txt +0 -0
- {phlo_api-0.3.1 → phlo_api-0.4.0}/tests/test_api_backend_endpoints.py +0 -0
- {phlo_api-0.3.1 → phlo_api-0.4.0}/tests/test_api_import_boundaries.py +0 -0
- {phlo_api-0.3.1 → phlo_api-0.4.0}/tests/test_authentication_api.py +0 -0
- {phlo_api-0.3.1 → phlo_api-0.4.0}/tests/test_authorization_api.py +0 -0
- {phlo_api-0.3.1 → phlo_api-0.4.0}/tests/test_cors.py +0 -0
- {phlo_api-0.3.1 → phlo_api-0.4.0}/tests/test_integration_api.py +0 -0
- {phlo_api-0.3.1 → phlo_api-0.4.0}/tests/test_lineage_api.py +0 -0
- {phlo_api-0.3.1 → phlo_api-0.4.0}/tests/test_loki_api.py +0 -0
- {phlo_api-0.3.1 → phlo_api-0.4.0}/tests/test_maintenance_api.py +0 -0
- {phlo_api-0.3.1 → phlo_api-0.4.0}/tests/test_observability_api.py +0 -0
- {phlo_api-0.3.1 → phlo_api-0.4.0}/tests/test_observatory_v2_actions.py +0 -0
- {phlo_api-0.3.1 → phlo_api-0.4.0}/tests/test_observatory_v2_catalog.py +0 -0
- {phlo_api-0.3.1 → phlo_api-0.4.0}/tests/test_observatory_v2_observability.py +0 -0
- {phlo_api-0.3.1 → phlo_api-0.4.0}/tests/test_observatory_v2_storage.py +0 -0
- {phlo_api-0.3.1 → phlo_api-0.4.0}/tests/test_observatory_v2_surfaces.py +0 -0
- {phlo_api-0.3.1 → phlo_api-0.4.0}/tests/test_rest_api.py +0 -0
- {phlo_api-0.3.1 → phlo_api-0.4.0}/tests/test_service_correlation_runtime.py +0 -0
- {phlo_api-0.3.1 → phlo_api-0.4.0}/tests/test_service_url_resolution.py +0 -0
- {phlo_api-0.3.1 → phlo_api-0.4.0}/tests/test_trino_defaults.py +0 -0
- {phlo_api-0.3.1 → phlo_api-0.4.0}/tests/test_trino_sql.py +0 -0
|
@@ -15,18 +15,14 @@ import re
|
|
|
15
15
|
import socket
|
|
16
16
|
import subprocess
|
|
17
17
|
import sys
|
|
18
|
-
import threading
|
|
19
|
-
import time
|
|
20
|
-
from datetime import UTC, datetime
|
|
21
18
|
from typing import Any
|
|
22
|
-
from urllib.parse import quote
|
|
23
|
-
from uuid import uuid4
|
|
24
19
|
|
|
25
20
|
from fastapi import APIRouter
|
|
26
21
|
from fastapi import HTTPException
|
|
27
22
|
from pydantic import BaseModel
|
|
28
23
|
|
|
29
24
|
from phlo_api.observatory_api.v2_actions import execute_v2_action
|
|
25
|
+
from phlo_api.observatory_api.v2_cache import ReadModelCache
|
|
30
26
|
from phlo_api.observatory_api.v2_capabilities import build_capability_inventory
|
|
31
27
|
from phlo_api.observatory_api.v2_catalog import load_catalog_items
|
|
32
28
|
from phlo_api.observatory_api.v2_governance import load_governance_items
|
|
@@ -78,7 +74,24 @@ from phlo_api.observatory_api.v2_metadata import safe_metadata as _safe_metadata
|
|
|
78
74
|
from phlo_api.observatory_api.v2_observability import load_observability_items
|
|
79
75
|
from phlo_api.observatory_api.v2_products import load_api_items, load_bi_items
|
|
80
76
|
from phlo_api.observatory_api.v2_runs import load_runs
|
|
77
|
+
from phlo_api.observatory_api.v2_saved_queries import (
|
|
78
|
+
dedupe_saved_queries as _dedupe_saved_queries_impl,
|
|
79
|
+
load_saved_queries as _load_saved_queries_impl,
|
|
80
|
+
save_query as _save_query_impl,
|
|
81
|
+
validate_saved_query_sql as _validate_saved_query_sql_impl,
|
|
82
|
+
write_saved_queries as _write_saved_queries_impl,
|
|
83
|
+
)
|
|
84
|
+
from phlo_api.observatory_api.v2_search import search_results as _search_results_impl
|
|
85
|
+
from phlo_api.observatory_api.v2_services import load_services as _load_services_impl
|
|
81
86
|
from phlo_api.observatory_api.v2_storage import load_storage_items
|
|
87
|
+
from phlo_api.observatory_api.v2_workflow_wizard import (
|
|
88
|
+
V2WorkflowActionRequest,
|
|
89
|
+
V2WorkflowActionResult,
|
|
90
|
+
V2WorkflowProposalRequest,
|
|
91
|
+
apply_workflow_action,
|
|
92
|
+
build_workflow_proposal,
|
|
93
|
+
build_workflow_wizard_payload,
|
|
94
|
+
)
|
|
82
95
|
|
|
83
96
|
router = APIRouter(tags=["observatory-v2"])
|
|
84
97
|
|
|
@@ -98,34 +111,16 @@ _ENV_DEFAULT_RE = re.compile(r"^\$\{[^}:]+:-(?P<default>[^}]+)\}$")
|
|
|
98
111
|
_TABLE_LIST_METADATA_PREFIX_DENYLIST = ("phlo/compiled_sql",)
|
|
99
112
|
_FAST_READ_MODEL_TTL_SECONDS = 30
|
|
100
113
|
_EXPENSIVE_READ_MODEL_TTL_SECONDS = 120
|
|
101
|
-
_READ_MODEL_CACHE
|
|
102
|
-
_READ_MODEL_CACHE_LOCK = threading.RLock()
|
|
114
|
+
_READ_MODEL_CACHE = ReadModelCache(project_key=lambda: str(_project_root()))
|
|
103
115
|
_DOCKER_SOCKET = "/var/run/docker.sock"
|
|
104
116
|
|
|
105
117
|
|
|
106
|
-
def _read_model_cache_key(name: str) -> tuple[str, str]:
|
|
107
|
-
return (str(_project_root()), name)
|
|
108
|
-
|
|
109
|
-
|
|
110
118
|
def _cached_read_model(name: str, ttl_seconds: float, loader: Any) -> Any:
|
|
111
|
-
|
|
112
|
-
now = time.monotonic()
|
|
113
|
-
|
|
114
|
-
with _READ_MODEL_CACHE_LOCK:
|
|
115
|
-
cached = _READ_MODEL_CACHE.get(key)
|
|
116
|
-
if cached is not None:
|
|
117
|
-
expires_at, value = cached
|
|
118
|
-
if expires_at > now:
|
|
119
|
-
return value
|
|
120
|
-
|
|
121
|
-
value = loader()
|
|
122
|
-
_READ_MODEL_CACHE[key] = (time.monotonic() + ttl_seconds, value)
|
|
123
|
-
return value
|
|
119
|
+
return _READ_MODEL_CACHE.cached(name, ttl_seconds, loader)
|
|
124
120
|
|
|
125
121
|
|
|
126
122
|
def _clear_read_model_cache() -> None:
|
|
127
|
-
|
|
128
|
-
_READ_MODEL_CACHE.clear()
|
|
123
|
+
_READ_MODEL_CACHE.clear()
|
|
129
124
|
|
|
130
125
|
|
|
131
126
|
class V2ServiceList(BaseModel):
|
|
@@ -625,56 +620,7 @@ def _service_config_from_definition(service: Any) -> list[V2ServiceConfigEntry]:
|
|
|
625
620
|
|
|
626
621
|
|
|
627
622
|
def _load_services() -> list[V2Service]:
|
|
628
|
-
|
|
629
|
-
try:
|
|
630
|
-
from phlo.plugins.discovery import ServiceDiscovery
|
|
631
|
-
|
|
632
|
-
discovered = ServiceDiscovery().discover().values()
|
|
633
|
-
except Exception:
|
|
634
|
-
discovered = []
|
|
635
|
-
|
|
636
|
-
services: list[V2Service] = []
|
|
637
|
-
containers = _load_docker_containers()
|
|
638
|
-
discovered = list(discovered)
|
|
639
|
-
runtime_statuses = _load_docker_service_statuses({service.name for service in discovered})
|
|
640
|
-
for service in discovered:
|
|
641
|
-
in_stack = service.name in runtime_statuses
|
|
642
|
-
status, health = runtime_statuses.get(
|
|
643
|
-
service.name,
|
|
644
|
-
("unknown", V2Health(state="unknown", message="Runtime status unavailable")),
|
|
645
|
-
)
|
|
646
|
-
services.append(
|
|
647
|
-
V2Service(
|
|
648
|
-
id=service.name,
|
|
649
|
-
name=service.name,
|
|
650
|
-
kind=service.category or "service",
|
|
651
|
-
status=status,
|
|
652
|
-
health=health,
|
|
653
|
-
definition_state="configured" if in_stack else "available",
|
|
654
|
-
runtime_state=status,
|
|
655
|
-
in_stack=in_stack,
|
|
656
|
-
disabled=bool(getattr(service, "disabled", False)),
|
|
657
|
-
profile=_coerce_str(service.profile, "") or None,
|
|
658
|
-
backend="docker" if in_stack else "unknown",
|
|
659
|
-
depends_on=list(service.depends_on or []),
|
|
660
|
-
impacts=[],
|
|
661
|
-
links=_service_links_from_definition(service),
|
|
662
|
-
metadata=_safe_metadata(
|
|
663
|
-
{
|
|
664
|
-
"default": bool(service.default),
|
|
665
|
-
"profile": service.profile,
|
|
666
|
-
"core": bool(getattr(service, "core", False)),
|
|
667
|
-
"description": getattr(service, "description", None),
|
|
668
|
-
}
|
|
669
|
-
),
|
|
670
|
-
)
|
|
671
|
-
)
|
|
672
|
-
|
|
673
|
-
services.extend(
|
|
674
|
-
_runtime_services_from_containers(containers, {service.id for service in services})
|
|
675
|
-
)
|
|
676
|
-
|
|
677
|
-
return sorted(services, key=lambda item: item.id) if services else _fallback_services()
|
|
623
|
+
return _load_services_impl(_project_root(), containers=_load_docker_containers())
|
|
678
624
|
|
|
679
625
|
|
|
680
626
|
def _overview_health_from_services(services: Sequence[V2Service]) -> V2Health:
|
|
@@ -1619,73 +1565,23 @@ def _row_offset(row_id: str) -> int:
|
|
|
1619
1565
|
|
|
1620
1566
|
|
|
1621
1567
|
def _load_saved_queries() -> list[V2SavedQuery]:
|
|
1622
|
-
|
|
1623
|
-
if not path.exists():
|
|
1624
|
-
return []
|
|
1625
|
-
try:
|
|
1626
|
-
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
1627
|
-
except (OSError, json.JSONDecodeError):
|
|
1628
|
-
return []
|
|
1629
|
-
items = payload.get("items") if isinstance(payload, Mapping) else None
|
|
1630
|
-
if not isinstance(items, list):
|
|
1631
|
-
return []
|
|
1632
|
-
queries: list[V2SavedQuery] = []
|
|
1633
|
-
for item in items:
|
|
1634
|
-
if isinstance(item, Mapping):
|
|
1635
|
-
try:
|
|
1636
|
-
queries.append(V2SavedQuery.model_validate(item))
|
|
1637
|
-
except Exception:
|
|
1638
|
-
continue
|
|
1639
|
-
return _dedupe_saved_queries(queries)
|
|
1568
|
+
return _load_saved_queries_impl(_project_root())
|
|
1640
1569
|
|
|
1641
1570
|
|
|
1642
1571
|
def _dedupe_saved_queries(queries: list[V2SavedQuery]) -> list[V2SavedQuery]:
|
|
1643
|
-
|
|
1644
|
-
for query in sorted(queries, key=lambda item: item.updated_at, reverse=True):
|
|
1645
|
-
key = (
|
|
1646
|
-
query.name.strip().casefold(),
|
|
1647
|
-
" ".join(query.sql.split()).casefold(),
|
|
1648
|
-
(query.branch or "main").strip().casefold(),
|
|
1649
|
-
)
|
|
1650
|
-
unique.setdefault(key, query)
|
|
1651
|
-
return list(unique.values())
|
|
1572
|
+
return _dedupe_saved_queries_impl(queries)
|
|
1652
1573
|
|
|
1653
1574
|
|
|
1654
1575
|
def _write_saved_queries(queries: list[V2SavedQuery]) -> None:
|
|
1655
|
-
|
|
1656
|
-
json.dumps({"items": [query.model_dump() for query in queries]}, indent=2),
|
|
1657
|
-
encoding="utf-8",
|
|
1658
|
-
)
|
|
1576
|
+
_write_saved_queries_impl(_project_root(), queries)
|
|
1659
1577
|
|
|
1660
1578
|
|
|
1661
1579
|
def _save_query(request: V2SavedQueryRequest) -> V2SavedQuery:
|
|
1662
|
-
|
|
1663
|
-
raise HTTPException(status_code=400, detail="Saved query name is required.")
|
|
1664
|
-
validate_error = _validate_saved_query_sql(request.sql)
|
|
1665
|
-
if validate_error:
|
|
1666
|
-
raise HTTPException(status_code=400, detail=validate_error)
|
|
1667
|
-
|
|
1668
|
-
now = datetime.now(UTC).isoformat()
|
|
1669
|
-
query = V2SavedQuery(
|
|
1670
|
-
id=f"query-{uuid4().hex[:12]}",
|
|
1671
|
-
name=request.name.strip(),
|
|
1672
|
-
sql=request.sql.strip(),
|
|
1673
|
-
branch=request.branch,
|
|
1674
|
-
created_at=now,
|
|
1675
|
-
updated_at=now,
|
|
1676
|
-
metadata=_safe_metadata(request.metadata),
|
|
1677
|
-
)
|
|
1678
|
-
queries = _dedupe_saved_queries([query, *_load_saved_queries()])
|
|
1679
|
-
_write_saved_queries(queries[:100])
|
|
1680
|
-
return query
|
|
1580
|
+
return _save_query_impl(_project_root(), request)
|
|
1681
1581
|
|
|
1682
1582
|
|
|
1683
1583
|
def _validate_saved_query_sql(sql: str) -> str | None:
|
|
1684
|
-
|
|
1685
|
-
return "SQL is required."
|
|
1686
|
-
if _READ_QUERY_RE.match(sql) is None:
|
|
1687
|
-
return "Only read-only SELECT * FROM <known_table> [LIMIT n] queries can be saved."
|
|
1688
|
-
return None
|
|
1584
|
+
return _validate_saved_query_sql_impl(sql)
|
|
1689
1585
|
|
|
1690
1586
|
|
|
1691
1587
|
def _load_stage_diff(source_table_id: str, target_table_id: str) -> V2StageDiff:
|
|
@@ -1797,87 +1693,15 @@ def _load_branch_detail(branch_name: str) -> V2BranchDetail:
|
|
|
1797
1693
|
|
|
1798
1694
|
|
|
1799
1695
|
def _search_results(query: str) -> list[V2SearchResult]:
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
V2SearchResult(
|
|
1810
|
-
id=f"service:{service.id}",
|
|
1811
|
-
label=service.name,
|
|
1812
|
-
kind="service",
|
|
1813
|
-
summary=f"{service.kind} · {service.status}",
|
|
1814
|
-
href="/services",
|
|
1815
|
-
)
|
|
1816
|
-
)
|
|
1817
|
-
|
|
1818
|
-
for asset in _load_assets():
|
|
1819
|
-
haystack = " ".join(
|
|
1820
|
-
[asset.id, asset.name, asset.group or "", asset.description or "", *asset.kinds]
|
|
1821
|
-
).lower()
|
|
1822
|
-
if needle in haystack:
|
|
1823
|
-
results.append(
|
|
1824
|
-
V2SearchResult(
|
|
1825
|
-
id=f"asset:{asset.id}",
|
|
1826
|
-
label=asset.name,
|
|
1827
|
-
kind="asset",
|
|
1828
|
-
summary=asset.description or asset.group,
|
|
1829
|
-
href=f"/asset/{_route_path_segment(asset.id)}",
|
|
1830
|
-
)
|
|
1831
|
-
)
|
|
1832
|
-
|
|
1833
|
-
for table in _load_tables_without_catalog():
|
|
1834
|
-
haystack = " ".join(
|
|
1835
|
-
[table.id, table.name, table.namespace or "", table.format or "", table.branch or ""]
|
|
1836
|
-
).lower()
|
|
1837
|
-
if needle in haystack:
|
|
1838
|
-
results.append(
|
|
1839
|
-
V2SearchResult(
|
|
1840
|
-
id=f"table:{table.id}",
|
|
1841
|
-
label=table.namespace + "." + table.name if table.namespace else table.name,
|
|
1842
|
-
kind="table",
|
|
1843
|
-
summary=f"{table.format or 'table'} · {table.branch or 'main'}",
|
|
1844
|
-
href=f"/table/{_route_path_segment(table.id)}",
|
|
1845
|
-
)
|
|
1846
|
-
)
|
|
1847
|
-
|
|
1848
|
-
for check in _load_quality():
|
|
1849
|
-
haystack = " ".join(
|
|
1850
|
-
[check.id, check.name, check.asset_id, check.status, check.severity or ""]
|
|
1851
|
-
).lower()
|
|
1852
|
-
if needle in haystack:
|
|
1853
|
-
results.append(
|
|
1854
|
-
V2SearchResult(
|
|
1855
|
-
id=f"quality:{check.id}",
|
|
1856
|
-
label=check.name,
|
|
1857
|
-
kind="quality",
|
|
1858
|
-
summary=f"{check.asset_id} · {check.status}",
|
|
1859
|
-
href="/quality",
|
|
1860
|
-
)
|
|
1861
|
-
)
|
|
1862
|
-
|
|
1863
|
-
for extension in _load_extensions():
|
|
1864
|
-
haystack = " ".join([extension.id, extension.name, extension.version or ""]).lower()
|
|
1865
|
-
if needle in haystack:
|
|
1866
|
-
results.append(
|
|
1867
|
-
V2SearchResult(
|
|
1868
|
-
id=f"extension:{extension.id}",
|
|
1869
|
-
label=extension.name,
|
|
1870
|
-
kind="extension",
|
|
1871
|
-
summary=extension.settings_scope or extension.version,
|
|
1872
|
-
href=f"/extension/{_route_path_segment(extension.id)}",
|
|
1873
|
-
)
|
|
1874
|
-
)
|
|
1875
|
-
|
|
1876
|
-
return results[:25]
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
def _route_path_segment(resource_id: str) -> str:
|
|
1880
|
-
return quote(resource_id, safe="")
|
|
1696
|
+
return _search_results_impl(
|
|
1697
|
+
query=query,
|
|
1698
|
+
services=_load_services(),
|
|
1699
|
+
assets=_load_assets(),
|
|
1700
|
+
tables=_load_tables_without_catalog(),
|
|
1701
|
+
operations=_load_operations(),
|
|
1702
|
+
quality=_load_quality(),
|
|
1703
|
+
extensions=_load_extensions(),
|
|
1704
|
+
)
|
|
1881
1705
|
|
|
1882
1706
|
|
|
1883
1707
|
def _load_extension_detail(extension_id: str) -> V2ExtensionDetail:
|
|
@@ -2555,6 +2379,29 @@ def get_v2_settings() -> V2Settings:
|
|
|
2555
2379
|
return _cached_read_model("settings", _EXPENSIVE_READ_MODEL_TTL_SECONDS, _load_settings)
|
|
2556
2380
|
|
|
2557
2381
|
|
|
2382
|
+
@router.get("/workflow-wizard")
|
|
2383
|
+
def get_v2_workflow_wizard() -> dict[str, Any]:
|
|
2384
|
+
"""Return provider-neutral workflow wizard contributions."""
|
|
2385
|
+
|
|
2386
|
+
return build_workflow_wizard_payload()
|
|
2387
|
+
|
|
2388
|
+
|
|
2389
|
+
@router.post("/workflow-wizard/proposals")
|
|
2390
|
+
def post_v2_workflow_wizard_proposal(request: V2WorkflowProposalRequest) -> dict[str, Any]:
|
|
2391
|
+
"""Build a side-effect-free workflow proposal."""
|
|
2392
|
+
|
|
2393
|
+
return build_workflow_proposal(_project_root(), request)
|
|
2394
|
+
|
|
2395
|
+
|
|
2396
|
+
@router.post("/workflow-wizard/actions", response_model=V2WorkflowActionResult)
|
|
2397
|
+
def post_v2_workflow_wizard_action(request: V2WorkflowActionRequest) -> V2WorkflowActionResult:
|
|
2398
|
+
"""Run a guarded workflow wizard apply action."""
|
|
2399
|
+
|
|
2400
|
+
result = apply_workflow_action(_project_root(), request)
|
|
2401
|
+
_clear_read_model_cache()
|
|
2402
|
+
return result
|
|
2403
|
+
|
|
2404
|
+
|
|
2558
2405
|
@router.get("/search", response_model=V2SearchList)
|
|
2559
2406
|
def get_v2_search(q: str) -> V2SearchList:
|
|
2560
2407
|
"""Search provider-neutral Observatory v2 resources."""
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Shared Observatory v2 read model cache helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(slots=True)
|
|
13
|
+
class ReadModelCache:
|
|
14
|
+
"""Small TTL cache scoped by project and read model name."""
|
|
15
|
+
|
|
16
|
+
project_key: Callable[[], str]
|
|
17
|
+
_values: dict[tuple[str, str], tuple[float, Any]] = field(default_factory=dict)
|
|
18
|
+
_lock: threading.RLock = field(default_factory=threading.RLock)
|
|
19
|
+
|
|
20
|
+
def cached(self, name: str, ttl_seconds: float, loader: Callable[[], Any]) -> Any:
|
|
21
|
+
key = (self.project_key(), name)
|
|
22
|
+
now = time.monotonic()
|
|
23
|
+
|
|
24
|
+
with self._lock:
|
|
25
|
+
cached = self._values.get(key)
|
|
26
|
+
if cached is not None:
|
|
27
|
+
expires_at, value = cached
|
|
28
|
+
if expires_at > now:
|
|
29
|
+
return value
|
|
30
|
+
|
|
31
|
+
value = loader()
|
|
32
|
+
self._values[key] = (time.monotonic() + ttl_seconds, value)
|
|
33
|
+
return value
|
|
34
|
+
|
|
35
|
+
def clear(self) -> None:
|
|
36
|
+
with self._lock:
|
|
37
|
+
self._values.clear()
|
|
@@ -50,6 +50,12 @@ ROUTE_REQUIREMENTS = [
|
|
|
50
50
|
required_any=["query_engine", "table_store"],
|
|
51
51
|
reason="Install a query engine or table store to browse data.",
|
|
52
52
|
),
|
|
53
|
+
V2RouteRequirement(
|
|
54
|
+
route_id="workflows",
|
|
55
|
+
label="Workflows",
|
|
56
|
+
path="/workflows/new",
|
|
57
|
+
reason="Core guided workflow creation surface.",
|
|
58
|
+
),
|
|
53
59
|
V2RouteRequirement(
|
|
54
60
|
route_id="assets",
|
|
55
61
|
label="Assets",
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Saved-query read model persistence for Observatory v2."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from datetime import UTC, datetime
|
|
7
|
+
import json
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
import re
|
|
10
|
+
from uuid import uuid4
|
|
11
|
+
|
|
12
|
+
from fastapi import HTTPException
|
|
13
|
+
|
|
14
|
+
from phlo_api.observatory_api.v2_metadata import safe_metadata
|
|
15
|
+
from phlo_api.observatory_api.v2_models import V2SavedQuery, V2SavedQueryRequest
|
|
16
|
+
|
|
17
|
+
READ_QUERY_RE = re.compile(
|
|
18
|
+
r"^\s*select\s+\*\s+from\s+(?P<table>[A-Za-z0-9_.:-]+)(?:\s+limit\s+(?P<limit>\d+))?\s*;?\s*$",
|
|
19
|
+
re.IGNORECASE,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def saved_queries_path(project_root: Path) -> Path:
|
|
24
|
+
state_dir = project_root / ".phlo" / "observatory-v2"
|
|
25
|
+
state_dir.mkdir(parents=True, exist_ok=True)
|
|
26
|
+
return state_dir / "saved_queries.json"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def load_saved_queries(project_root: Path) -> list[V2SavedQuery]:
|
|
30
|
+
path = saved_queries_path(project_root)
|
|
31
|
+
if not path.exists():
|
|
32
|
+
return []
|
|
33
|
+
try:
|
|
34
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
35
|
+
except (OSError, json.JSONDecodeError):
|
|
36
|
+
return []
|
|
37
|
+
items = payload.get("items") if isinstance(payload, Mapping) else None
|
|
38
|
+
if not isinstance(items, list):
|
|
39
|
+
return []
|
|
40
|
+
queries: list[V2SavedQuery] = []
|
|
41
|
+
for item in items:
|
|
42
|
+
if isinstance(item, Mapping):
|
|
43
|
+
try:
|
|
44
|
+
queries.append(V2SavedQuery.model_validate(item))
|
|
45
|
+
except Exception:
|
|
46
|
+
continue
|
|
47
|
+
return dedupe_saved_queries(queries)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def dedupe_saved_queries(queries: list[V2SavedQuery]) -> list[V2SavedQuery]:
|
|
51
|
+
unique: dict[tuple[str, str, str], V2SavedQuery] = {}
|
|
52
|
+
for query in sorted(queries, key=lambda item: item.updated_at, reverse=True):
|
|
53
|
+
key = (
|
|
54
|
+
query.name.strip().casefold(),
|
|
55
|
+
" ".join(query.sql.split()).casefold(),
|
|
56
|
+
(query.branch or "main").strip().casefold(),
|
|
57
|
+
)
|
|
58
|
+
unique.setdefault(key, query)
|
|
59
|
+
return list(unique.values())
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def write_saved_queries(project_root: Path, queries: list[V2SavedQuery]) -> None:
|
|
63
|
+
saved_queries_path(project_root).write_text(
|
|
64
|
+
json.dumps({"items": [query.model_dump() for query in queries]}, indent=2),
|
|
65
|
+
encoding="utf-8",
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def save_query(project_root: Path, request: V2SavedQueryRequest) -> V2SavedQuery:
|
|
70
|
+
if not request.name.strip():
|
|
71
|
+
raise HTTPException(status_code=400, detail="Saved query name is required.")
|
|
72
|
+
validate_error = validate_saved_query_sql(request.sql)
|
|
73
|
+
if validate_error:
|
|
74
|
+
raise HTTPException(status_code=400, detail=validate_error)
|
|
75
|
+
|
|
76
|
+
now = datetime.now(UTC).isoformat()
|
|
77
|
+
query = V2SavedQuery(
|
|
78
|
+
id=f"query-{uuid4().hex[:12]}",
|
|
79
|
+
name=request.name.strip(),
|
|
80
|
+
sql=request.sql.strip(),
|
|
81
|
+
branch=request.branch,
|
|
82
|
+
created_at=now,
|
|
83
|
+
updated_at=now,
|
|
84
|
+
metadata=safe_metadata(request.metadata),
|
|
85
|
+
)
|
|
86
|
+
queries = dedupe_saved_queries([query, *load_saved_queries(project_root)])
|
|
87
|
+
write_saved_queries(project_root, queries[:100])
|
|
88
|
+
return query
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def validate_saved_query_sql(sql: str) -> str | None:
|
|
92
|
+
if READ_QUERY_RE.match(sql):
|
|
93
|
+
return None
|
|
94
|
+
return "Only simple SELECT preview queries can be saved."
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Search result composition for Observatory v2."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
from urllib.parse import quote
|
|
7
|
+
|
|
8
|
+
from phlo_api.observatory_api.v2_models import (
|
|
9
|
+
V2Asset,
|
|
10
|
+
V2Extension,
|
|
11
|
+
V2Operation,
|
|
12
|
+
V2QualityCheck,
|
|
13
|
+
V2SearchResult,
|
|
14
|
+
V2Service,
|
|
15
|
+
V2Table,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def search_results(
|
|
20
|
+
*,
|
|
21
|
+
query: str,
|
|
22
|
+
services: Sequence[V2Service],
|
|
23
|
+
assets: Sequence[V2Asset],
|
|
24
|
+
tables: Sequence[V2Table],
|
|
25
|
+
operations: Sequence[V2Operation],
|
|
26
|
+
quality: Sequence[V2QualityCheck] = (),
|
|
27
|
+
extensions: Sequence[V2Extension] = (),
|
|
28
|
+
) -> list[V2SearchResult]:
|
|
29
|
+
needle = query.strip().lower()
|
|
30
|
+
if not needle:
|
|
31
|
+
return []
|
|
32
|
+
|
|
33
|
+
results: list[V2SearchResult] = []
|
|
34
|
+
for service in services:
|
|
35
|
+
haystack = " ".join([service.id, service.name, service.kind, service.status]).lower()
|
|
36
|
+
if needle in haystack:
|
|
37
|
+
results.append(
|
|
38
|
+
V2SearchResult(
|
|
39
|
+
id=f"service:{service.id}",
|
|
40
|
+
label=service.name,
|
|
41
|
+
kind="service",
|
|
42
|
+
summary=f"{service.kind} · {service.status}",
|
|
43
|
+
href="/services",
|
|
44
|
+
)
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
for asset in assets:
|
|
48
|
+
haystack = " ".join(
|
|
49
|
+
[asset.id, asset.name, asset.group or "", asset.description or "", *asset.kinds]
|
|
50
|
+
).lower()
|
|
51
|
+
if needle in haystack:
|
|
52
|
+
results.append(
|
|
53
|
+
V2SearchResult(
|
|
54
|
+
id=f"asset:{asset.id}",
|
|
55
|
+
label=asset.name,
|
|
56
|
+
kind="asset",
|
|
57
|
+
summary=asset.description or asset.group,
|
|
58
|
+
href=f"/asset/{route_path_segment(asset.id)}",
|
|
59
|
+
)
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
for table in tables:
|
|
63
|
+
haystack = " ".join(
|
|
64
|
+
[table.id, table.name, table.namespace or "", table.format or "", table.branch or ""]
|
|
65
|
+
).lower()
|
|
66
|
+
if needle in haystack:
|
|
67
|
+
results.append(
|
|
68
|
+
V2SearchResult(
|
|
69
|
+
id=f"table:{table.id}",
|
|
70
|
+
label=table.namespace + "." + table.name if table.namespace else table.name,
|
|
71
|
+
kind="table",
|
|
72
|
+
summary=f"{table.format or 'table'} · {table.branch or 'main'}",
|
|
73
|
+
href=f"/table/{route_path_segment(table.id)}",
|
|
74
|
+
)
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
for operation in operations:
|
|
78
|
+
haystack = " ".join(
|
|
79
|
+
[operation.id, operation.name, operation.kind, operation.status]
|
|
80
|
+
).lower()
|
|
81
|
+
if needle in haystack:
|
|
82
|
+
results.append(
|
|
83
|
+
V2SearchResult(
|
|
84
|
+
id=f"operation:{operation.id}",
|
|
85
|
+
label=operation.name,
|
|
86
|
+
kind="operation",
|
|
87
|
+
summary=f"{operation.kind} · {operation.status}",
|
|
88
|
+
href=f"/operations/{route_path_segment(operation.id)}",
|
|
89
|
+
)
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
for check in quality:
|
|
93
|
+
haystack = " ".join(
|
|
94
|
+
[check.id, check.name, check.asset_id, check.status, check.severity or ""]
|
|
95
|
+
).lower()
|
|
96
|
+
if needle in haystack:
|
|
97
|
+
results.append(
|
|
98
|
+
V2SearchResult(
|
|
99
|
+
id=f"quality:{check.id}",
|
|
100
|
+
label=check.name,
|
|
101
|
+
kind="quality",
|
|
102
|
+
summary=f"{check.asset_id} · {check.status}",
|
|
103
|
+
href="/quality",
|
|
104
|
+
)
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
for extension in extensions:
|
|
108
|
+
haystack = " ".join([extension.id, extension.name, extension.version or ""]).lower()
|
|
109
|
+
if needle in haystack:
|
|
110
|
+
results.append(
|
|
111
|
+
V2SearchResult(
|
|
112
|
+
id=f"extension:{extension.id}",
|
|
113
|
+
label=extension.name,
|
|
114
|
+
kind="extension",
|
|
115
|
+
summary=extension.settings_scope or extension.version,
|
|
116
|
+
href=f"/extension/{route_path_segment(extension.id)}",
|
|
117
|
+
)
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
return results[:25]
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def route_path_segment(resource_id: str) -> str:
|
|
124
|
+
return quote(resource_id, safe="")
|