diff --git a/.aida/validation_policy.yaml b/.aida/validation_policy.yaml index 9bd144a71..6c21cf595 100644 --- a/.aida/validation_policy.yaml +++ b/.aida/validation_policy.yaml @@ -2,7 +2,6 @@ # AIDA validation policy # # This file wires domains -> pipelines -> steps (command_id + processor_id). -# Initially empty (validate may no-op until you configure routes). version: 1 validation_policy: codegen: @@ -26,22 +25,16 @@ validation_policy: pipelines: package-fast: steps: - - command_id: package-lint - processor_id: passthrough - - command_id: package-type-check - processor_id: passthrough - - command_id: package-test-py314 - processor_id: pytest + - command_id: package-validate + processor_id: external_json + repo-fast: + steps: + - command_id: package-validate-no-tests + processor_id: external_json api-client-fast: steps: - command_id: api-client-tests processor_id: pytest - repo-fast: - steps: - - command_id: workspace-lint - processor_id: passthrough - - command_id: workspace-type-check - processor_id: passthrough aida-config: steps: - command_id: aida-doctor diff --git a/.aida/validation_registry.yaml b/.aida/validation_registry.yaml index 84bd97d18..a2fa3b8c6 100644 --- a/.aida/validation_registry.yaml +++ b/.aida/validation_registry.yaml @@ -6,39 +6,36 @@ version: 1 registry: includes: [] commands: - package-lint: + package-validate: argv: - - make - - -C - - '{root}' - - lint + - bash + - '{workspace_root}/scripts/validate_python.sh' + - --project-path + - '{workspace_root}/{root}' + - --workspace-root + - '{workspace_root}' + - --scope + - '{scope}' + - --auto-fix + - "true" cwd: '{workspace_root}' - package-type-check: + timeout_sec: 900 + package-validate-no-tests: argv: - - make - - -C - - '{root}' - - type-check - cwd: '{workspace_root}' - package-test-py314: - argv: - - make - - -C - - '{root}' - - test - cwd: '{workspace_root}' - env: - TEST_ENVS: py314 - workspace-lint: - argv: - - make - - lint - cwd: '{workspace_root}' - workspace-type-check: - argv: - - make - - type-check + - bash + - '{workspace_root}/scripts/validate_python.sh' + - --project-path + - '{workspace_root}/{root}' + - --workspace-root + - '{workspace_root}' + - --scope + - '{scope}' + - --auto-fix + - "true" + - --steps-csv + - format,lint,types cwd: '{workspace_root}' + timeout_sec: 600 api-client-tests: argv: - uv @@ -52,10 +49,4 @@ registry: - aida-mcp - doctor cwd: '{workspace_root}' - processors: - passthrough: - kind: builtin - builtin_id: passthrough - pytest: - kind: builtin - builtin_id: pytest + processors: {} diff --git a/packages/gooddata-sdk/pyproject.toml b/packages/gooddata-sdk/pyproject.toml index a30da72b7..2ea9efb68 100644 --- a/packages/gooddata-sdk/pyproject.toml +++ b/packages/gooddata-sdk/pyproject.toml @@ -34,6 +34,7 @@ dependencies = [ "brotli==1.2.0", "requests~=2.32.0", "python-dotenv>=1.0.0,<2.0.0", + "gooddata-code-convertors", ] classifiers = [ "Development Status :: 5 - Production/Stable", @@ -75,9 +76,6 @@ allowed-unresolved-imports = ["gooddata_api_client.**"] [tool.hatch.build.targets.wheel] packages = ["src/gooddata_sdk"] -include = [ - "src/gooddata_sdk/cli/package.json", -] [tool.coverage.run] source = ["gooddata_sdk"] diff --git a/packages/gooddata-sdk/src/gooddata_sdk/__init__.py b/packages/gooddata-sdk/src/gooddata_sdk/__init__.py index 75175e0af..77397b92d 100644 --- a/packages/gooddata-sdk/src/gooddata_sdk/__init__.py +++ b/packages/gooddata-sdk/src/gooddata_sdk/__init__.py @@ -180,6 +180,25 @@ CatalogWorkspacePermissionAssignment, ) from gooddata_sdk.catalog.validate_by_item import CatalogValidateByItem +from gooddata_sdk.catalog.workspace.aac import ( + aac_attribute_hierarchy_to_declarative, + aac_dashboard_to_declarative, + aac_dataset_to_declarative, + aac_date_dataset_to_declarative, + aac_metric_to_declarative, + aac_plugin_to_declarative, + aac_visualization_to_declarative, + declarative_attribute_hierarchy_to_aac, + declarative_dashboard_to_aac, + declarative_dataset_to_aac, + declarative_date_instance_to_aac, + declarative_metric_to_aac, + declarative_plugin_to_aac, + declarative_visualization_to_aac, + detect_yaml_format, + load_aac_workspace_from_disk, + store_aac_workspace_to_disk, +) from gooddata_sdk.catalog.workspace.content_service import CatalogWorkspaceContent, CatalogWorkspaceContentService from gooddata_sdk.catalog.workspace.declarative_model.workspace.analytics_model.analytics_model import ( CatalogDeclarativeAnalytics, diff --git a/packages/gooddata-sdk/src/gooddata_sdk/catalog/workspace/aac.py b/packages/gooddata-sdk/src/gooddata_sdk/catalog/workspace/aac.py new file mode 100644 index 000000000..59eeffc64 --- /dev/null +++ b/packages/gooddata-sdk/src/gooddata_sdk/catalog/workspace/aac.py @@ -0,0 +1,407 @@ +# (C) 2026 GoodData Corporation +from __future__ import annotations + +from pathlib import Path +from typing import Any, Literal + +from gooddata_code_convertors import ( + declarative_attribute_hierarchy_to_yaml, + declarative_dashboard_to_yaml, + declarative_dataset_to_yaml, + declarative_date_instance_to_yaml, + declarative_metric_to_yaml, + declarative_plugin_to_yaml, + declarative_visualisation_to_yaml, + yaml_attribute_hierarchy_to_declarative, + yaml_dashboard_to_declarative, + yaml_dataset_to_declarative, + yaml_date_dataset_to_declarative, + yaml_metric_to_declarative, + yaml_plugin_to_declarative, + yaml_visualisation_to_declarative, +) + +from gooddata_sdk.catalog.workspace.declarative_model.workspace.workspace import CatalogDeclarativeWorkspaceModel +from gooddata_sdk.utils import read_layout_from_file + +# AAC types that map to visualization objects (all go through yaml_visualisation_to_declarative) +_VISUALIZATION_TYPES = frozenset( + { + "bar_chart", + "column_chart", + "line_chart", + "area_chart", + "pie_chart", + "donut_chart", + "scatter_chart", + "bubble_chart", + "heatmap_chart", + "treemap_chart", + "bullet_chart", + "funnel_chart", + "pyramid_chart", + "waterfall_chart", + "sankey_chart", + "dependency_wheel_chart", + "combo_chart", + "headline_chart", + "geo_chart", + "geo_area_chart", + "repeater_chart", + "table", + } +) + +# All known AAC type values +_ALL_AAC_TYPES = ( + frozenset({"dataset", "date", "metric", "dashboard", "plugin", "attribute_hierarchy"}) | _VISUALIZATION_TYPES +) + +# Types that are "dataset-like" for the entities array +_DATASET_TYPES = frozenset({"dataset", "date"}) + + +# --------------------------------------------------------------------------- +# Entities list builder (needed for cross-reference resolution) +# --------------------------------------------------------------------------- + + +def _build_entities(aac_objects: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Build the entities array expected by convertor functions. + + Some convertor functions (dataset, visualization, dashboard) need access to + all objects for cross-reference resolution (e.g., dataset references). + """ + return [ + { + "id": obj["id"], + "type": obj["type"], + "path": f"{obj['id']}.yaml", + "data": obj, + } + for obj in aac_objects + ] + + +# --------------------------------------------------------------------------- +# Individual object conversions: AAC dict → Declarative dict +# --------------------------------------------------------------------------- + + +def aac_dataset_to_declarative( + aac: dict[str, Any], + entities: list[dict[str, Any]] | None = None, + data_source_id: str | None = None, +) -> dict[str, Any]: + """Convert an AAC dataset dict to declarative format. + + Args: + aac: The AAC dataset dict (parsed YAML). + entities: Optional list of all AAC objects for cross-reference resolution. + data_source_id: Optional data source ID for table-based datasets. + """ + ent = entities if entities is not None else _build_entities([aac]) + args: list[Any] = [ent, aac] + if data_source_id is not None: + args.append(data_source_id) + return yaml_dataset_to_declarative(*args) + + +def aac_date_dataset_to_declarative(aac: dict[str, Any]) -> dict[str, Any]: + """Convert an AAC date dataset dict to declarative format.""" + return yaml_date_dataset_to_declarative(aac) + + +def aac_metric_to_declarative(aac: dict[str, Any]) -> dict[str, Any]: + """Convert an AAC metric dict to declarative format.""" + return yaml_metric_to_declarative(aac) + + +def aac_visualization_to_declarative( + aac: dict[str, Any], + entities: list[dict[str, Any]] | None = None, +) -> dict[str, Any]: + """Convert an AAC visualization dict to declarative format. + + Args: + aac: The AAC visualization dict (parsed YAML). + entities: Optional list of all AAC objects for cross-reference resolution. + """ + ent = entities if entities is not None else _build_entities([aac]) + return yaml_visualisation_to_declarative(ent, aac) + + +def aac_dashboard_to_declarative( + aac: dict[str, Any], + entities: list[dict[str, Any]] | None = None, +) -> dict[str, Any]: + """Convert an AAC dashboard dict to declarative format. + + Args: + aac: The AAC dashboard dict (parsed YAML). + entities: Optional list of all AAC objects for cross-reference resolution. + """ + ent = entities if entities is not None else _build_entities([aac]) + return yaml_dashboard_to_declarative(ent, aac) + + +def aac_plugin_to_declarative(aac: dict[str, Any]) -> dict[str, Any]: + """Convert an AAC plugin dict to declarative format.""" + return yaml_plugin_to_declarative(aac) + + +def aac_attribute_hierarchy_to_declarative(aac: dict[str, Any]) -> dict[str, Any]: + """Convert an AAC attribute hierarchy dict to declarative format.""" + return yaml_attribute_hierarchy_to_declarative(aac) + + +# --------------------------------------------------------------------------- +# Individual object conversions: Declarative dict → AAC dict +# Returns {"json": dict, "content": str} +# --------------------------------------------------------------------------- + + +def declarative_dataset_to_aac( + declarative: dict[str, Any], + profile: dict[str, Any] | None = None, + tables_map: dict[str, list[dict[str, Any]]] | None = None, +) -> dict[str, Any]: + """Convert a declarative dataset dict to AAC format. + + Args: + declarative: The declarative dataset dict. + profile: Optional profile dict with host, token, workspace_id, data_source. + tables_map: Optional mapping of data source ID to list of table definitions. + """ + p = profile if profile is not None else {} + t = tables_map if tables_map is not None else {} + return declarative_dataset_to_yaml(declarative, p, t) + + +def declarative_date_instance_to_aac(declarative: dict[str, Any]) -> dict[str, Any]: + """Convert a declarative date instance dict to AAC format.""" + return declarative_date_instance_to_yaml(declarative) + + +def declarative_metric_to_aac(declarative: dict[str, Any]) -> dict[str, Any]: + """Convert a declarative metric dict to AAC format.""" + return declarative_metric_to_yaml(declarative) + + +def declarative_visualization_to_aac(declarative: dict[str, Any]) -> dict[str, Any]: + """Convert a declarative visualization dict to AAC format.""" + return declarative_visualisation_to_yaml(declarative) + + +def declarative_dashboard_to_aac(declarative: dict[str, Any]) -> dict[str, Any]: + """Convert a declarative dashboard dict to AAC format.""" + return declarative_dashboard_to_yaml(declarative) + + +def declarative_plugin_to_aac(declarative: dict[str, Any]) -> dict[str, Any]: + """Convert a declarative plugin dict to AAC format.""" + return declarative_plugin_to_yaml(declarative) + + +def declarative_attribute_hierarchy_to_aac(declarative: dict[str, Any]) -> dict[str, Any]: + """Convert a declarative attribute hierarchy dict to AAC format.""" + return declarative_attribute_hierarchy_to_yaml(declarative) + + +# --------------------------------------------------------------------------- +# Format detection +# --------------------------------------------------------------------------- + + +def detect_yaml_format(path: Path) -> Literal["aac", "declarative"]: + """Detect whether a directory contains AAC or declarative YAMLs. + + AAC files have a top-level 'type' field (dataset, metric, line_chart, etc.) + and live as flat files or in typed subdirectories (datasets/, metrics/, etc.). + + Declarative files follow a nested folder structure with + workspaces//ldm/ and workspaces//analytics_model/. + """ + # Check for declarative structure markers + if (path / "workspaces").is_dir() or (path / "ldm").is_dir() or (path / "analytics_model").is_dir(): + return "declarative" + + # Check for AAC markers: typed subdirectories + aac_subdirs = {"datasets", "metrics", "visualisations", "dashboards", "plugins", "attributeHierarchies"} + for subdir in aac_subdirs: + if (path / subdir).is_dir(): + return "aac" + + # Check YAML files for top-level 'type' field + for yaml_file in path.glob("*.yaml"): + content = read_layout_from_file(yaml_file) + if isinstance(content, dict) and content.get("type") in _ALL_AAC_TYPES: + return "aac" + break # Only check the first file + + return "declarative" + + +# --------------------------------------------------------------------------- +# Workspace-level: load AAC from disk → CatalogDeclarativeWorkspaceModel +# --------------------------------------------------------------------------- + + +def _collect_aac_yaml_files(source_dir: Path) -> list[Path]: + """Collect all AAC YAML files from source_dir (flat and typed subdirs).""" + files: list[Path] = [] + + # Flat YAML files in source_dir root + files.extend(sorted(source_dir.glob("*.yaml"))) + files.extend(sorted(source_dir.glob("*.yml"))) + + # Typed subdirectories used by gd CLI + for subdir in sorted(source_dir.iterdir()): + if subdir.is_dir() and subdir.name not in { + "data_sources", + "users", + "user_groups", + "workspaces_data_filters", + "workspaces", + }: + files.extend(sorted(subdir.rglob("*.yaml"))) + files.extend(sorted(subdir.rglob("*.yml"))) + + return files + + +def load_aac_workspace_from_disk( + source_dir: Path, + data_source_id: str | None = None, +) -> CatalogDeclarativeWorkspaceModel: + """Load AAC YAML files from source_dir and return a declarative workspace model. + + Reads all .yaml/.yml files, detects object type from the 'type' field, + converts each to declarative format, and assembles into a + CatalogDeclarativeWorkspaceModel. + + Args: + source_dir: Path to directory containing AAC YAML files. + data_source_id: Optional data source ID for table-based datasets. + """ + # First pass: read all AAC objects and build entities list + aac_objects: list[dict[str, Any]] = [] + for yaml_file in _collect_aac_yaml_files(source_dir): + content = read_layout_from_file(yaml_file) + if isinstance(content, dict) and "type" in content: + aac_objects.append(content) + + entities = _build_entities(aac_objects) + + # Second pass: convert each object + datasets: list[dict[str, Any]] = [] + date_instances: list[dict[str, Any]] = [] + metrics: list[dict[str, Any]] = [] + visualization_objects: list[dict[str, Any]] = [] + analytical_dashboards: list[dict[str, Any]] = [] + dashboard_plugins: list[dict[str, Any]] = [] + attribute_hierarchies: list[dict[str, Any]] = [] + + for aac in aac_objects: + aac_type = aac.get("type") + + match aac_type: + case "dataset": + datasets.append(aac_dataset_to_declarative(aac, entities, data_source_id)) + case "date": + date_instances.append(aac_date_dataset_to_declarative(aac)) + case "metric": + metrics.append(aac_metric_to_declarative(aac)) + case "dashboard": + result = aac_dashboard_to_declarative(aac, entities) + # Dashboard convertor may return dashboard + filterContext + if isinstance(result, dict) and "dashboard" in result: + analytical_dashboards.append(result["dashboard"]) + else: + analytical_dashboards.append(result) + case "plugin": + dashboard_plugins.append(aac_plugin_to_declarative(aac)) + case "attribute_hierarchy": + attribute_hierarchies.append(aac_attribute_hierarchy_to_declarative(aac)) + case vis_type if vis_type in _VISUALIZATION_TYPES: + visualization_objects.append(aac_visualization_to_declarative(aac, entities)) + + workspace_model_dict: dict[str, Any] = {} + + ldm: dict[str, Any] = {} + if datasets: + ldm["datasets"] = datasets + if date_instances: + ldm["dateInstances"] = date_instances + if ldm: + workspace_model_dict["ldm"] = ldm + + analytics: dict[str, Any] = {} + if metrics: + analytics["metrics"] = metrics + if visualization_objects: + analytics["visualizationObjects"] = visualization_objects + if analytical_dashboards: + analytics["analyticalDashboards"] = analytical_dashboards + if dashboard_plugins: + analytics["dashboardPlugins"] = dashboard_plugins + if attribute_hierarchies: + analytics["attributeHierarchyObjects"] = attribute_hierarchies + if analytics: + workspace_model_dict["analytics"] = analytics + + return CatalogDeclarativeWorkspaceModel.from_dict(workspace_model_dict) + + +# --------------------------------------------------------------------------- +# Workspace-level: CatalogDeclarativeWorkspaceModel → AAC files on disk +# --------------------------------------------------------------------------- + + +def _write_aac_file(source_dir: Path, subdir: str, obj_id: str, content: str) -> None: + """Write a single AAC YAML file to disk.""" + target_dir = source_dir / subdir + target_dir.mkdir(parents=True, exist_ok=True) + target_file = target_dir / f"{obj_id}.yaml" + target_file.write_text(content, encoding="utf-8") + + +def store_aac_workspace_to_disk(model: CatalogDeclarativeWorkspaceModel, source_dir: Path) -> None: + """Convert a declarative workspace model to AAC YAML files and write to disk. + + Creates typed subdirectories (datasets/, metrics/, visualisations/, dashboards/, + plugins/, attributeHierarchies/) under source_dir. + """ + source_dir.mkdir(parents=True, exist_ok=True) + model_dict = model.to_dict(camel_case=True) + + ldm = model_dict.get("ldm", {}) + analytics = model_dict.get("analytics", {}) + + for dataset in ldm.get("datasets", []): + result = declarative_dataset_to_aac(dataset) + _write_aac_file(source_dir, "datasets", dataset["id"], result["content"]) + + for date_instance in ldm.get("dateInstances", []): + result = declarative_date_instance_to_aac(date_instance) + _write_aac_file(source_dir, "datasets", date_instance["id"], result["content"]) + + for metric in analytics.get("metrics", []): + result = declarative_metric_to_aac(metric) + _write_aac_file(source_dir, "metrics", metric["id"], result["content"]) + + for vis in analytics.get("visualizationObjects", []): + result = declarative_visualization_to_aac(vis) + _write_aac_file(source_dir, "visualisations", vis["id"], result["content"]) + + for dashboard in analytics.get("analyticalDashboards", []): + result = declarative_dashboard_to_aac(dashboard) + _write_aac_file(source_dir, "dashboards", dashboard["id"], result["content"]) + + for plugin in analytics.get("dashboardPlugins", []): + result = declarative_plugin_to_aac(plugin) + _write_aac_file(source_dir, "plugins", plugin["id"], result["content"]) + + for hierarchy in analytics.get("attributeHierarchyObjects", []): + result = declarative_attribute_hierarchy_to_aac(hierarchy) + _write_aac_file(source_dir, "attributeHierarchies", hierarchy["id"], result["content"]) diff --git a/packages/gooddata-sdk/src/gooddata_sdk/catalog/workspace/service.py b/packages/gooddata-sdk/src/gooddata_sdk/catalog/workspace/service.py index 50f5f113e..6378ebb34 100644 --- a/packages/gooddata-sdk/src/gooddata_sdk/catalog/workspace/service.py +++ b/packages/gooddata-sdk/src/gooddata_sdk/catalog/workspace/service.py @@ -20,6 +20,7 @@ from gooddata_sdk import CatalogDeclarativeAutomation from gooddata_sdk.catalog.catalog_service_base import CatalogServiceBase from gooddata_sdk.catalog.permission.service import CatalogPermissionService +from gooddata_sdk.catalog.workspace.aac import load_aac_workspace_from_disk, store_aac_workspace_to_disk from gooddata_sdk.catalog.workspace.declarative_model.workspace.workspace import ( CatalogDeclarativeFilterView, CatalogDeclarativeUserDataFilters, @@ -417,6 +418,32 @@ def load_and_put_declarative_workspace(self, workspace_id: str, layout_root_path ) self.put_declarative_workspace(workspace_id=workspace_id, workspace=declarative_workspace) + # AAC (Analytics-as-Code) methods + + def load_and_put_aac_workspace(self, workspace_id: str, source_dir: Path) -> None: + """Load AAC YAML files from source_dir, convert to declarative, and deploy to workspace. + + Args: + workspace_id (str): + Workspace identification string e.g. "demo" + source_dir (Path): + Path to the directory containing AAC YAML files. + """ + workspace_model = load_aac_workspace_from_disk(source_dir) + self.put_declarative_workspace(workspace_id=workspace_id, workspace=workspace_model) + + def get_and_store_aac_workspace(self, workspace_id: str, source_dir: Path) -> None: + """Get declarative workspace from server, convert to AAC YAML files, and write to disk. + + Args: + workspace_id (str): + Workspace identification string e.g. "demo" + source_dir (Path): + Path to the directory where AAC YAML files will be written. + """ + workspace_model = self.get_declarative_workspace(workspace_id=workspace_id) + store_aac_workspace_to_disk(workspace_model, source_dir) + def clone_workspace( self, source_workspace_id: str, diff --git a/packages/gooddata-sdk/src/gooddata_sdk/cli/clone.py b/packages/gooddata-sdk/src/gooddata_sdk/cli/clone.py index a2fbb2292..2234e7203 100644 --- a/packages/gooddata-sdk/src/gooddata_sdk/cli/clone.py +++ b/packages/gooddata-sdk/src/gooddata_sdk/cli/clone.py @@ -1,49 +1,29 @@ # (C) 2024 GoodData Corporation import argparse -import json import shutil -import subprocess from pathlib import Path -from gooddata_sdk import CatalogDeclarativeWorkspaces, GoodDataSdk +from gooddata_sdk import GoodDataSdk +from gooddata_sdk.catalog.workspace.aac import store_aac_workspace_to_disk from gooddata_sdk.cli.constants import ( - BASE_DIR, CONFIG_FILE, DATA_SOURCES, - GD_COMMAND, USER_GROUPS, USERS, WORKSPACES, WORKSPACES_DATA_FILTERS, ) -from gooddata_sdk.cli.utils import ( - Bcolors, - measure_clone, -) - - -def _call_gd_stream_in(workspace_objects: CatalogDeclarativeWorkspaces, path: Path) -> None: - """ - Call 'gd stream-in' command to create workspaces file structure using Node.js CLI. - """ - workspaces = json.dumps({WORKSPACES: workspace_objects.to_dict()[WORKSPACES]}) - p = subprocess.Popen( - [GD_COMMAND, "stream-in"], - cwd=path, - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - _, err = p.communicate(input=workspaces.encode()) - if err: - print(f"{Bcolors.FAIL}Clone workspaces failed with the following error {err=}.{Bcolors.ENDC}") +from gooddata_sdk.cli.deploy import _get_workspace_id +from gooddata_sdk.cli.utils import measure_clone @measure_clone(step="workspaces") -def _clone_workspaces(sdk: GoodDataSdk, path: Path) -> None: - assert (path / CONFIG_FILE).exists() and (path / BASE_DIR).exists() - workspace_objects = sdk.catalog_workspace.get_declarative_workspaces() - _call_gd_stream_in(workspace_objects, path) +def _clone_workspaces(sdk: GoodDataSdk, path: Path, source_dir: str) -> None: + config_path = path / CONFIG_FILE + workspace_id = _get_workspace_id(config_path) + source_path = path / source_dir + workspace_model = sdk.catalog_workspace.get_declarative_workspace(workspace_id) + store_aac_workspace_to_disk(workspace_model, source_path) @measure_clone(step="data sources") @@ -70,31 +50,29 @@ def _clone_workspace_data_filters(sdk: GoodDataSdk, analytics_root_dir: Path) -> workspace_data_filters.store_to_disk(analytics_root_dir) -def clone_all(path: Path) -> None: - init_file = path / CONFIG_FILE - sdk = GoodDataSdk.create_from_profile(profiles_path=init_file) - analytics_root_dir = path / BASE_DIR +def clone_all(path: Path, source_dir: str) -> None: + config_path = path / CONFIG_FILE + sdk = GoodDataSdk.create_from_profile(profiles_path=config_path) + analytics_root_dir = path / source_dir # clean the directory if analytics_root_dir.exists(): shutil.rmtree(analytics_root_dir) - # create directory analytics_root_dir.mkdir() - print("Cloning the whole organization... ⏲️⏲️️⏲️️") + print("Cloning the whole organization...") _clone_data_sources(sdk, analytics_root_dir) _clone_user_groups(sdk, analytics_root_dir) _clone_users(sdk, analytics_root_dir) _clone_workspace_data_filters(sdk, analytics_root_dir) - _clone_workspaces(sdk, path) - print("Cloning finished 🚀🚀🚀") + _clone_workspaces(sdk, path, source_dir) + print("Cloning finished.") -def clone_granular(path: Path, args: argparse.Namespace) -> None: - init_file = path / CONFIG_FILE - analytics_root_dir = path / "analytics" - config_directory = analytics_root_dir.parent - sdk = GoodDataSdk.create_from_profile(profiles_path=init_file) +def clone_granular(path: Path, source_dir: str, args: argparse.Namespace) -> None: + config_path = path / CONFIG_FILE + analytics_root_dir = path / source_dir + sdk = GoodDataSdk.create_from_profile(profiles_path=config_path) selected_entities = set(args.only) if DATA_SOURCES in selected_entities: _clone_data_sources(sdk, analytics_root_dir) @@ -105,4 +83,4 @@ def clone_granular(path: Path, args: argparse.Namespace) -> None: if WORKSPACES_DATA_FILTERS in selected_entities: _clone_workspace_data_filters(sdk, analytics_root_dir) if WORKSPACES in selected_entities: - _clone_workspaces(sdk, config_directory) + _clone_workspaces(sdk, path, source_dir) diff --git a/packages/gooddata-sdk/src/gooddata_sdk/cli/constants.py b/packages/gooddata-sdk/src/gooddata_sdk/cli/constants.py index d5120047c..7a448b584 100644 --- a/packages/gooddata-sdk/src/gooddata_sdk/cli/constants.py +++ b/packages/gooddata-sdk/src/gooddata_sdk/cli/constants.py @@ -1,5 +1,4 @@ # (C) 2024 GoodData Corporation -from pathlib import Path DATA_SOURCES = "data_sources" USER_GROUPS = "user_groups" @@ -10,8 +9,4 @@ DATA_SOURCE = "data_source" CONFIG_FILE = "gooddata.yaml" -BASE_DIR = "analytics" - -GD_ROOT = Path.home() / ".gooddata" -GD_COMMAND = GD_ROOT / "node_modules/.bin/gd" -GD_PACKAGE_JSON = GD_ROOT / "node_modules/@gooddata/code-cli/package.json" +DEFAULT_SOURCE_DIR = "analytics" diff --git a/packages/gooddata-sdk/src/gooddata_sdk/cli/deploy.py b/packages/gooddata-sdk/src/gooddata_sdk/cli/deploy.py index 657678774..1b14c5db7 100644 --- a/packages/gooddata-sdk/src/gooddata_sdk/cli/deploy.py +++ b/packages/gooddata-sdk/src/gooddata_sdk/cli/deploy.py @@ -1,72 +1,64 @@ # (C) 2024 GoodData Corporation import argparse -import json -import subprocess from pathlib import Path -from typing import Any from gooddata_sdk import ( CatalogDeclarativeDataSources, CatalogDeclarativeUserGroups, CatalogDeclarativeUsers, - CatalogDeclarativeWorkspace, CatalogDeclarativeWorkspaceDataFilters, - CatalogDeclarativeWorkspaces, GoodDataSdk, ) +from gooddata_sdk.catalog.workspace.aac import load_aac_workspace_from_disk from gooddata_sdk.cli.constants import ( - BASE_DIR, CONFIG_FILE, DATA_SOURCES, - GD_COMMAND, USER_GROUPS, USERS, WORKSPACES, WORKSPACES_DATA_FILTERS, ) from gooddata_sdk.cli.utils import measure_deploy +from gooddata_sdk.config import AacConfig +from gooddata_sdk.utils import read_layout_from_file + + +def _get_workspace_id(config_path: Path, profile: str = "default") -> str: + """Extract workspace_id from the config file profile.""" + content = read_layout_from_file(config_path) + if not isinstance(content, dict): + raise ValueError(f"Invalid config file: {config_path}") + config = AacConfig.from_dict(content) + selected_profile = config.default_profile if profile == "default" else profile + if selected_profile not in config.profiles: + raise ValueError(f"Profile '{selected_profile}' not found in config") + p = config.profiles[selected_profile] + if p.workspace_id is None: + raise ValueError( + f"Profile '{selected_profile}' does not have 'workspace_id' set. Required for deploying workspace objects." + ) + return p.workspace_id -def _call_gd_stram_out(path: Path) -> dict[str, Any]: - """ - Call 'gd stream-out' command to read workspaces file structure using Node.js CLI. - """ - assert (path / CONFIG_FILE).exists() and (path / BASE_DIR).exists() - p = subprocess.Popen( - [GD_COMMAND, "stream-out", "--no-validate"], - cwd=path, - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - output, err = p.communicate() - if err: - print(f"Deploy workspaces failed with the following error {err=}.") - data = json.loads(output.decode()) - if WORKSPACES not in data: - raise ValueError("No workspaces found in the output.") - return data +@measure_deploy(step=WORKSPACES) +def _deploy_workspaces(sdk: GoodDataSdk, path: Path, source_dir: str) -> None: + source_path = path / source_dir + config_path = path / CONFIG_FILE + workspace_id = _get_workspace_id(config_path) + workspace_model = load_aac_workspace_from_disk(source_path) + # Preserve workspace data filters from declarative subdirs + wdf = CatalogDeclarativeWorkspaceDataFilters.load_from_disk(source_path) + if wdf.workspace_data_filters: + workspace_model.remove_wdf_refs() -@measure_deploy(step=WORKSPACES) -def _deploy_workspaces_with_filters(sdk: GoodDataSdk, path: Path) -> None: - analytics_root_dir = path / BASE_DIR - data = _call_gd_stram_out(path) - workspaces = [CatalogDeclarativeWorkspace.from_dict(workspace_dict) for workspace_dict in data[WORKSPACES]] - # fetch this information first, so we do not lose them - workspace_data_filters = CatalogDeclarativeWorkspaceDataFilters.load_from_disk(analytics_root_dir) - workspaces_o = CatalogDeclarativeWorkspaces( - workspaces=workspaces, workspace_data_filters=workspace_data_filters.workspace_data_filters - ) - sdk.catalog_workspace.put_declarative_workspaces(workspaces_o) + sdk.catalog_workspace.put_declarative_workspace(workspace_id=workspace_id, workspace=workspace_model) @measure_deploy(step="data sources") -def _deploy_data_sources(sdk: GoodDataSdk, analytics_root_dir: Path) -> None: +def _deploy_data_sources(sdk: GoodDataSdk, analytics_root_dir: Path, config_path: Path) -> None: data_sources = CatalogDeclarativeDataSources.load_from_disk(analytics_root_dir) - sdk.catalog_data_source.put_declarative_data_sources( - data_sources, config_file=analytics_root_dir.parent / "gooddata.yaml" - ) + sdk.catalog_data_source.put_declarative_data_sources(data_sources, config_file=config_path) @measure_deploy(step="user groups") @@ -87,27 +79,26 @@ def _deploy_workspace_data_filters(sdk: GoodDataSdk, analytics_root_dir: Path) - sdk.catalog_workspace.put_declarative_workspace_data_filters(workspace_data_filters) -def deploy_all(path: Path) -> None: - init_file = path / CONFIG_FILE - sdk = GoodDataSdk.create_from_profile(profiles_path=init_file) - - analytics_root_dir = path / BASE_DIR +def deploy_all(path: Path, source_dir: str) -> None: + config_path = path / CONFIG_FILE + sdk = GoodDataSdk.create_from_profile(profiles_path=config_path) + analytics_root_dir = path / source_dir - print("Deploying the whole organization... ⏲️⏲️⏲️") - _deploy_data_sources(sdk, analytics_root_dir) + print("Deploying the whole organization...") + _deploy_data_sources(sdk, analytics_root_dir, config_path) _deploy_user_groups(sdk, analytics_root_dir) _deploy_users(sdk, analytics_root_dir) - _deploy_workspaces_with_filters(sdk, path) - print("Deployed 🚀🚀🚀") + _deploy_workspaces(sdk, path, source_dir) + print("Deployed successfully.") -def deploy_granular(path: Path, args: argparse.Namespace) -> None: - init_file = path / CONFIG_FILE - analytics_root_dir = path / "analytics" +def deploy_granular(path: Path, source_dir: str, args: argparse.Namespace) -> None: + config_path = path / CONFIG_FILE + analytics_root_dir = path / source_dir selected_entities = set(args.only) - sdk = GoodDataSdk.create_from_profile(profiles_path=init_file) + sdk = GoodDataSdk.create_from_profile(profiles_path=config_path) if DATA_SOURCES in selected_entities: - _deploy_data_sources(sdk, analytics_root_dir) + _deploy_data_sources(sdk, analytics_root_dir, config_path) if USER_GROUPS in selected_entities: _deploy_user_groups(sdk, analytics_root_dir) if USERS in selected_entities: @@ -115,4 +106,4 @@ def deploy_granular(path: Path, args: argparse.Namespace) -> None: if WORKSPACES_DATA_FILTERS in selected_entities: _deploy_workspace_data_filters(sdk, analytics_root_dir) if WORKSPACES in selected_entities: - _deploy_workspaces_with_filters(sdk, analytics_root_dir.parent) + _deploy_workspaces(sdk, path, source_dir) diff --git a/packages/gooddata-sdk/src/gooddata_sdk/cli/gdc_core.py b/packages/gooddata-sdk/src/gooddata_sdk/cli/gdc_core.py index a6297b21f..ebb8982ad 100644 --- a/packages/gooddata-sdk/src/gooddata_sdk/cli/gdc_core.py +++ b/packages/gooddata-sdk/src/gooddata_sdk/cli/gdc_core.py @@ -1,113 +1,70 @@ # (C) 2024 GoodData Corporation import argparse -import shutil -import subprocess import sys from pathlib import Path from gooddata_sdk.cli.clone import clone_all, clone_granular -from gooddata_sdk.cli.constants import GD_COMMAND, GD_PACKAGE_JSON, GD_ROOT +from gooddata_sdk.cli.constants import CONFIG_FILE, DEFAULT_SOURCE_DIR from gooddata_sdk.cli.deploy import deploy_all, deploy_granular from gooddata_sdk.cli.utils import _SUPPORTED, Bcolors -from gooddata_sdk.utils import read_json +from gooddata_sdk.config import AacConfig +from gooddata_sdk.utils import read_layout_from_file -_CURRENT_DIR = Path(__file__).parent +def _find_config_file() -> Path: + """Find the gooddata.yaml config file in the current directory or parents.""" + current = Path.cwd() + for directory in [current, *current.parents]: + config_path = directory / CONFIG_FILE + if config_path.exists(): + return config_path + print(f"{Bcolors.FAIL}Config file {CONFIG_FILE} was not found.{Bcolors.ENDC}") + sys.exit(1) -def get_manifest_directory() -> Path: - """ - Get the directory where the manifest file (gooddata.yaml) is located - using the gd stream-manifest-path command. - """ - p = subprocess.Popen( - [GD_COMMAND, "stream-manifest-path"], - cwd=Path().resolve(), - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - output, err = p.communicate() - if err: - print(f"{Bcolors.FAIL}Manifest gooddata.yaml was not found.{Bcolors.ENDC}") - sys.exit(1) - return Path(output.decode()).parent + +def _get_source_dir(config_path: Path) -> str: + """Get source_dir from config file, falling back to default.""" + content = read_layout_from_file(config_path) + if isinstance(content, dict) and AacConfig.can_structure(content): + config = AacConfig.from_dict(content) + if config.source_dir is not None: + return config.source_dir + return DEFAULT_SOURCE_DIR -def _deploy(path: Path, args: argparse.Namespace) -> None: - """ - Handles deploy command use cases. - """ +def _deploy(path: Path, source_dir: str, args: argparse.Namespace) -> None: if not path.is_dir(): raise ValueError(f"Path {path} is not a directory.") - if args.only is None: - deploy_all(path) + deploy_all(path, source_dir) else: - deploy_granular(path, args) + deploy_granular(path, source_dir, args) -def _clone(path: Path, args: argparse.Namespace) -> None: - """ - Handles clone command use cases. - """ +def _clone(path: Path, source_dir: str, args: argparse.Namespace) -> None: if args.only is None: - clone_all(path) + clone_all(path, source_dir) else: - clone_granular(path, args) - - -def _manage_node_cli() -> None: - """ - First, it checks if Node CLI is installed and if it is the correct version. - If not, it installs the correct version. If it is not installed at all then it's installed - locally in ~/.gooddata directory. - """ - requested_version = read_json(_CURRENT_DIR / "package.json")["dependencies"]["@gooddata/code-cli"] - if GD_PACKAGE_JSON.exists(): - current_version = read_json(GD_PACKAGE_JSON)["version"] - if current_version == requested_version: - return - else: - print( - f"Node.js @gooddata/code-cli version '{requested_version}' is required," - f" but version '{current_version}' is installed." - ) - if not GD_ROOT.exists(): - GD_ROOT.mkdir() - shutil.copyfile(_CURRENT_DIR / "package.json", GD_ROOT / "package.json") - print(f"Installing @gooddata/code-cli version '{requested_version}' in {GD_ROOT}...") - p = subprocess.Popen( - ["npm", "i"], - cwd=GD_ROOT, - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - _, err = p.communicate() - if err: - print(f"{Bcolors.FAIL}An error has occurred during installation: {err.decode()}.{Bcolors.ENDC}") - sys.exit(1) + clone_granular(path, source_dir, args) def main() -> None: - """ - The entrypoint for gdc cli. - """ parser = argparse.ArgumentParser( prog="gdc", - description="Process GoodData as code file structure. Utilize @gooddata/code-cli for workspaces. " - "Note that this is an EXPERIMENTAL feature.", + description="GoodData CLI for deploying and cloning analytics-as-code projects.", ) parser.add_argument("action", help="Specify if you want to deploy or clone project.", choices=("deploy", "clone")) parser.add_argument("--only", help="Specify available granularity for action.", nargs="+", choices=_SUPPORTED) - _manage_node_cli() args = parser.parse_args() - manifest_directory = get_manifest_directory() + config_path = _find_config_file() + manifest_directory = config_path.parent + source_dir = _get_source_dir(config_path) + if args.action == "clone": - _clone(manifest_directory, args) + _clone(manifest_directory, source_dir, args) elif args.action == "deploy": - _deploy(manifest_directory, args) + _deploy(manifest_directory, source_dir, args) if __name__ == "__main__": diff --git a/packages/gooddata-sdk/src/gooddata_sdk/cli/package.json b/packages/gooddata-sdk/src/gooddata_sdk/cli/package.json deleted file mode 100644 index 8af00fa5f..000000000 --- a/packages/gooddata-sdk/src/gooddata_sdk/cli/package.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "name": "test", - "version": "1.0.0", - "dependencies": { - "@gooddata/code-cli": "1.0.0-alpha.3" - } -} diff --git a/packages/gooddata-sdk/src/gooddata_sdk/config.py b/packages/gooddata-sdk/src/gooddata_sdk/config.py index bce62d3cc..e6611fd74 100644 --- a/packages/gooddata-sdk/src/gooddata_sdk/config.py +++ b/packages/gooddata-sdk/src/gooddata_sdk/config.py @@ -32,26 +32,35 @@ def can_structure(cls: type[T], data: dict[str, Any]) -> bool: class Profile(ConfigBase): host: str token: str + workspace_id: str | None = None + data_source: str | None = None custom_headers: dict[str, str] | None = None extra_user_agent: str | None = None ssl_ca_cert: str | None = None + # Fields used only by CLI/config, not passed to GoodDataApiClient + _NON_API_FIELDS = frozenset({"workspace_id", "data_source"}) + def to_dict(self, use_env: bool = False) -> dict[str, str]: load_dotenv() + base = {k: v for k, v in asdict(self).items() if k not in self._NON_API_FIELDS} if not use_env: - return asdict(self) + return base env_var = self.token[1:] if env_var not in os.environ: raise ValueError(f"Environment variable {env_var} not found") - return {**asdict(self), "token": os.environ[env_var]} + return {**base, "token": os.environ[env_var]} @define class AacConfig(ConfigBase): profiles: dict[str, Profile] default_profile: str - access: dict[str, str] + access: dict[str, str] | None = None + source_dir: str | None = None def ds_credentials(self) -> dict[str, str]: load_dotenv() + if self.access is None: + return {} return {k: os.environ.get(v[1:], v) for k, v in self.access.items()} diff --git a/packages/gooddata-sdk/tests/catalog/fixtures/aac/dashboards/dashboard_1.yaml b/packages/gooddata-sdk/tests/catalog/fixtures/aac/dashboards/dashboard_1.yaml new file mode 100644 index 000000000..b46d932cf --- /dev/null +++ b/packages/gooddata-sdk/tests/catalog/fixtures/aac/dashboards/dashboard_1.yaml @@ -0,0 +1,35 @@ +# (C) 2026 GoodData Corporation +# A dashboard is a collection of visualizations that are organized into sections. +# Because they allow filtering and other adjustments, they function as a dynamic presentation layer for your data analytics. +# Read more about dashboards: +# https://www.gooddata.com/docs/cloud/create-dashboards/concepts/dashboard/ + +id: 90a53321-9060-4aef-9dc6-a3d0a9a5323e +type: dashboard + +title: Dashboard 1 + +sections: + - widgets: + - visualization: 71bdc379-384a-4eac-9627-364ea847d977 + title: Ratings + columns: 12 + rows: 22 + +filters: + date_month: + type: date_filter + granularity: MONTH + from: 0 + to: 0 + mode: hidden + customer_created_date_year: + type: date_filter + granularity: YEAR + date: customer_created_date + from: 0 + to: 0 + mode: hidden + customer_age: + type: attribute_filter + using: label/customer_age diff --git a/packages/gooddata-sdk/tests/catalog/fixtures/aac/datasets/customer.yaml b/packages/gooddata-sdk/tests/catalog/fixtures/aac/datasets/customer.yaml new file mode 100644 index 000000000..77f1bab17 --- /dev/null +++ b/packages/gooddata-sdk/tests/catalog/fixtures/aac/datasets/customer.yaml @@ -0,0 +1,97 @@ +# (C) 2026 GoodData Corporation +# A dataset is a logical object that represents a set of related facts, attributes, and attribute labels. +# Datasets are basic organization units of a logical data model. +# Read more about Datasets: +# https://www.gooddata.com/developers/cloud-native/doc/cloud/model-data/concepts/dataset/ + +type: dataset +id: customer + +table_path: ECOMMERCE_DEMO_SCHEMA/customer + +title: Customer +description: Customer +tags: + - Customer + +primary_key: customer_id + +fields: + customer_city: + type: attribute + source_column: customer_city + data_type: STRING + title: Customer city + description: Customer city + tags: + - Customer + labels: + geo__customer_city__city_pushpin_latitude: + source_column: geo__customer_city__city_pushpin_latitude + data_type: STRING + title: City pushpin latitude + description: City pushpin latitude + tags: + - Customer + geo__customer_city__city_pushpin_longitude: + source_column: geo__customer_city__city_pushpin_longitude + data_type: STRING + title: City pushpin longitude + description: City pushpin longitude + tags: + - Customer + customer_country: + type: attribute + source_column: customer_country + data_type: STRING + title: Customer country + description: Customer country + tags: + - Customer + customer_email: + type: attribute + source_column: customer_email + data_type: STRING + title: Customer email + description: Customer email + tags: + - Customer + labels: + customer_email.customeremaillink: + source_column: customer_email + data_type: STRING + title: Customer email link + tags: + - Customer + value_type: HYPERLINK + customer_id: + type: attribute + source_column: customer_id + data_type: STRING + title: Customer id + description: Customer id + tags: + - Customer + labels: + customer_name: + source_column: ls__customer_id__customer_name + data_type: STRING + title: Customer name + description: Customer name + tags: + - Customer + customer_state: + type: attribute + source_column: customer_state + data_type: STRING + title: Customer state + description: Customer state + tags: + - Customer + +references: + - dataset: customer_created_date + sources: + - source_column: customer_created_date + data_type: DATE + target: customer_created_date diff --git a/packages/gooddata-sdk/tests/catalog/fixtures/aac/datasets/order_date.yaml b/packages/gooddata-sdk/tests/catalog/fixtures/aac/datasets/order_date.yaml new file mode 100644 index 000000000..9375792b8 --- /dev/null +++ b/packages/gooddata-sdk/tests/catalog/fixtures/aac/datasets/order_date.yaml @@ -0,0 +1,32 @@ +# (C) 2026 GoodData Corporation +# A Date dataset is a dataset in the logical data model(LDM) +# that represents DATE / TIMESTAMP columns in your database. +# Read more about date instances: +# https://www.gooddata.com/developers/cloud-native/doc/cloud/model-data/concepts/dataset/#date-datasets + +type: date +id: order_date + +title: Order date +tags: + - Order date + +granularities: + - MINUTE + - HOUR + - DAY + - WEEK + - MONTH + - QUARTER + - YEAR + - MINUTE_OF_HOUR + - HOUR_OF_DAY + - DAY_OF_WEEK + - DAY_OF_MONTH + - DAY_OF_YEAR + - WEEK_OF_YEAR + - MONTH_OF_YEAR + - QUARTER_OF_YEAR + +title_base: "" +title_pattern: "%titleBase - %granularityTitle" diff --git a/packages/gooddata-sdk/tests/catalog/fixtures/aac/datasets/orders.yaml b/packages/gooddata-sdk/tests/catalog/fixtures/aac/datasets/orders.yaml new file mode 100644 index 000000000..3582723f1 --- /dev/null +++ b/packages/gooddata-sdk/tests/catalog/fixtures/aac/datasets/orders.yaml @@ -0,0 +1,35 @@ +# (C) 2026 GoodData Corporation +# A dataset is a logical object that represents a set of related facts, attributes, and attribute labels. +# Datasets are basic organization units of a logical data model. +# Read more about Datasets: +# https://www.gooddata.com/developers/cloud-native/doc/cloud/model-data/concepts/dataset/ + +type: dataset +id: orders + +table_path: ECOMMERCE_DEMO_SCHEMA/orders + +title: Orders +description: Orders +tags: + - Orders + +primary_key: order_id + +fields: + order_id: + type: attribute + source_column: order_id + data_type: STRING + title: Order id + description: Order id + tags: + - Orders + order_status: + type: attribute + source_column: order_status + data_type: STRING + title: Order status + description: Order status + tags: + - Orders diff --git a/packages/gooddata-sdk/tests/catalog/fixtures/aac/gooddata.yaml b/packages/gooddata-sdk/tests/catalog/fixtures/aac/gooddata.yaml new file mode 100644 index 000000000..88559b8a9 --- /dev/null +++ b/packages/gooddata-sdk/tests/catalog/fixtures/aac/gooddata.yaml @@ -0,0 +1,9 @@ +# (C) 2026 GoodData Corporation +profiles: + default: + host: "https://test.gooddata.com" + token: "test-token" + workspace_id: "test-workspace" + data_source: "test-datasource" +default_profile: "default" +source_dir: "analytics" diff --git a/packages/gooddata-sdk/tests/catalog/fixtures/aac/metrics/top_products.yaml b/packages/gooddata-sdk/tests/catalog/fixtures/aac/metrics/top_products.yaml new file mode 100644 index 000000000..344534843 --- /dev/null +++ b/packages/gooddata-sdk/tests/catalog/fixtures/aac/metrics/top_products.yaml @@ -0,0 +1,13 @@ +# (C) 2026 GoodData Corporation +# A metric is a computational expression of numerical data (facts or other metrics). +# Use MAQL to create reusable multidimensional queries that combine multiple facts and attributes. +# Read more about MAQL: +# https://www.gooddata.com/developers/cloud-native/doc/cloud/create-metrics/maql/ + +type: metric +id: top_products + +title: Top Products + +maql: SELECT SUM({fact/rating}) WHERE {label/product_rating} = "*****"; +format: "#,##0.00" diff --git a/packages/gooddata-sdk/tests/catalog/fixtures/aac/visualisations/ratings.yaml b/packages/gooddata-sdk/tests/catalog/fixtures/aac/visualisations/ratings.yaml new file mode 100644 index 000000000..ab26fbfd3 --- /dev/null +++ b/packages/gooddata-sdk/tests/catalog/fixtures/aac/visualisations/ratings.yaml @@ -0,0 +1,23 @@ +# (C) 2026 GoodData Corporation +# A visualization is a visual representation of a user’s analytical view of the data. +# You build visualizations from metrics, attributes, and optionally filters that are combined in a way to visualize a particular aspect of your data. +# The visualizations are executed over and over as fresh data gets loaded. +# Interpreting the content of a visualization is up to the user (the consumer of the visualization). +# Read more about visualisations: +# https://www.gooddata.com/developers/cloud-native/doc/cloud/create-visualizations/concepts/visualization/ + +id: 71bdc379-384a-4eac-9627-364ea847d977 +type: table + +title: Ratings + +query: + fields: + sum_of_rating_1: + title: Sum of Rating + aggregation: SUM + using: fact/rating + +metrics: + - field: sum_of_rating_1 + format: "#,##0.00" diff --git a/packages/gooddata-sdk/tests/catalog/fixtures/aac/visualisations/ratings_per_category.yaml b/packages/gooddata-sdk/tests/catalog/fixtures/aac/visualisations/ratings_per_category.yaml new file mode 100644 index 000000000..b7c964220 --- /dev/null +++ b/packages/gooddata-sdk/tests/catalog/fixtures/aac/visualisations/ratings_per_category.yaml @@ -0,0 +1,43 @@ +# (C) 2026 GoodData Corporation +# A visualization is a visual representation of a user’s analytical view of the data. +# You build visualizations from metrics, attributes, and optionally filters that are combined in a way to visualize a particular aspect of your data. +# The visualizations are executed over and over as fresh data gets loaded. +# Interpreting the content of a visualization is up to the user (the consumer of the visualization). +# Read more about visualisations: +# https://www.gooddata.com/developers/cloud-native/doc/cloud/create-visualizations/concepts/visualization/ + +id: 4c2002a4-a60c-4f4a-a440-d2a6a35887cf +type: column_chart + +title: Ratings per Category + +query: + fields: + sum_of_rating: + title: Sum of Rating + aggregation: SUM + using: fact/rating + product_category: label/product_category + product_rating: label/product_rating + +metrics: + - field: sum_of_rating + format: "#,##0.00" + +view_by: + - product_category + +segment_by: + - product_rating + +config: + colors: + "*": rgb(255,0,0) + "**": rgb(255,159,0) + "***": rgb(245,255,102) + "****": rgb(102,255,235) + "*****": rgb(23,242,13) + legend_position: top + xaxis_name_position: center + xaxis_rotation: "0" + yaxis_name_position: top diff --git a/packages/gooddata-sdk/tests/catalog/test_aac.py b/packages/gooddata-sdk/tests/catalog/test_aac.py new file mode 100644 index 000000000..f4ccfe741 --- /dev/null +++ b/packages/gooddata-sdk/tests/catalog/test_aac.py @@ -0,0 +1,282 @@ +# (C) 2026 GoodData Corporation +from __future__ import annotations + +from pathlib import Path + +import yaml +from gooddata_sdk.catalog.workspace.aac import ( + aac_dataset_to_declarative, + aac_date_dataset_to_declarative, + aac_metric_to_declarative, + aac_visualization_to_declarative, + declarative_dataset_to_aac, + declarative_metric_to_aac, + detect_yaml_format, + load_aac_workspace_from_disk, + store_aac_workspace_to_disk, +) +from gooddata_sdk.config import AacConfig + +_FIXTURES_DIR = Path(__file__).parent / "fixtures" / "aac" + + +# --------------------------------------------------------------------------- +# Config alignment tests +# --------------------------------------------------------------------------- + + +class TestAacConfig: + def test_parse_vscode_config(self) -> None: + """VSCode plugin's gooddata.yaml should be parseable by AacConfig.""" + content = yaml.safe_load((_FIXTURES_DIR / "gooddata.yaml").read_text()) + assert AacConfig.can_structure(content) + config = AacConfig.from_dict(content) + assert config.default_profile == "default" + assert config.source_dir == "analytics" + profile = config.profiles["default"] + assert profile.host == "https://test.gooddata.com" + assert profile.workspace_id == "test-workspace" + assert profile.data_source == "test-datasource" + + def test_config_without_access(self) -> None: + """Config without 'access' field (VSCode style) should parse fine.""" + content = { + "profiles": {"dev": {"host": "https://h", "token": "t"}}, + "default_profile": "dev", + } + assert AacConfig.can_structure(content) + config = AacConfig.from_dict(content) + assert config.access is None + assert config.ds_credentials() == {} + + def test_config_with_source_dir(self) -> None: + content = { + "profiles": {"dev": {"host": "https://h", "token": "t"}}, + "default_profile": "dev", + "source_dir": "my_analytics", + } + config = AacConfig.from_dict(content) + assert config.source_dir == "my_analytics" + + +# --------------------------------------------------------------------------- +# Individual conversion tests +# --------------------------------------------------------------------------- + + +class TestIndividualConversions: + def test_metric_round_trip(self) -> None: + aac_input = { + "id": "revenue", + "title": "Revenue", + "maql": "SELECT SUM({fact/amount})", + "format": "#,##0", + } + declarative = aac_metric_to_declarative(aac_input) + assert declarative["id"] == "revenue" + assert declarative["title"] == "Revenue" + assert declarative["content"]["maql"] == "SELECT SUM({fact/amount})" + + result = declarative_metric_to_aac(declarative) + assert "json" in result + assert "content" in result + assert result["json"]["id"] == "revenue" + assert result["json"]["maql"] == "SELECT SUM({fact/amount})" + + def test_dataset_to_declarative(self) -> None: + """Test forward conversion: AAC dataset → declarative.""" + aac_input = { + "type": "dataset", + "id": "orders", + "table_path": "orders", + "title": "Orders", + "primary_key": "order_id", + "fields": { + "order_id": { + "type": "attribute", + "source_column": "order_id", + "data_type": "STRING", + "title": "Order ID", + }, + }, + } + declarative = aac_dataset_to_declarative(aac_input) + assert declarative["id"] == "orders" + assert declarative["title"] == "Orders" + + def test_date_dataset_conversion(self) -> None: + aac_input = { + "type": "date", + "id": "order_date", + "title": "Order Date", + } + declarative = aac_date_dataset_to_declarative(aac_input) + assert declarative["id"] == "order_date" + assert declarative["title"] == "Order Date" + + def test_visualization_to_declarative(self) -> None: + """Test forward conversion: AAC visualization → declarative.""" + aac_input = { + "type": "table", + "id": "my_table", + "title": "My Table", + "query": { + "fields": { + "m1": { + "title": "Sum of Amount", + "aggregation": "SUM", + "using": "fact/amount", + }, + }, + }, + "metrics": [{"field": "m1", "format": "#,##0"}], + } + declarative = aac_visualization_to_declarative(aac_input) + assert declarative["id"] == "my_table" + assert declarative["title"] == "My Table" + + def test_metric_from_fixture(self) -> None: + content = yaml.safe_load((_FIXTURES_DIR / "metrics" / "top_products.yaml").read_text()) + declarative = aac_metric_to_declarative(content) + assert declarative["id"] == "top_products" + assert declarative["title"] == "Top Products" + + def test_dataset_from_fixture(self) -> None: + content = yaml.safe_load((_FIXTURES_DIR / "datasets" / "orders.yaml").read_text()) + declarative = aac_dataset_to_declarative(content) + assert declarative["id"] == "orders" + + def test_visualization_from_fixture(self) -> None: + content = yaml.safe_load((_FIXTURES_DIR / "visualisations" / "ratings.yaml").read_text()) + declarative = aac_visualization_to_declarative(content) + assert declarative["id"] == "71bdc379-384a-4eac-9627-364ea847d977" + + def test_metric_declarative_to_aac(self) -> None: + """Test the full round-trip on metrics (both directions work cleanly).""" + aac_input = { + "id": "budget", + "title": "Budget", + "maql": "SELECT SUM({fact/budget})", + "format": "#,##0.00", + "description": "Total budget", + "tags": ["finance"], + } + declarative = aac_metric_to_declarative(aac_input) + result = declarative_metric_to_aac(declarative) + assert result["json"]["id"] == "budget" + assert result["json"]["maql"] == "SELECT SUM({fact/budget})" + assert isinstance(result["content"], str) + assert "budget" in result["content"] + + def test_dataset_declarative_to_aac(self) -> None: + """Test declarative → AAC for datasets using a fixture-converted dataset.""" + content = yaml.safe_load((_FIXTURES_DIR / "datasets" / "orders.yaml").read_text()) + declarative = aac_dataset_to_declarative(content) + result = declarative_dataset_to_aac(declarative) + assert result["json"]["id"] == "orders" + assert isinstance(result["content"], str) + + +# --------------------------------------------------------------------------- +# Format detection tests +# --------------------------------------------------------------------------- + + +class TestFormatDetection: + def test_detect_aac_with_typed_subdirs(self, tmp_path: Path) -> None: + (tmp_path / "datasets").mkdir() + (tmp_path / "datasets" / "orders.yaml").write_text("type: dataset\nid: orders\n") + assert detect_yaml_format(tmp_path) == "aac" + + def test_detect_aac_with_flat_files(self, tmp_path: Path) -> None: + (tmp_path / "orders.yaml").write_text("type: dataset\nid: orders\n") + assert detect_yaml_format(tmp_path) == "aac" + + def test_detect_declarative_with_workspaces_dir(self, tmp_path: Path) -> None: + (tmp_path / "workspaces").mkdir() + assert detect_yaml_format(tmp_path) == "declarative" + + def test_detect_declarative_with_ldm_dir(self, tmp_path: Path) -> None: + (tmp_path / "ldm").mkdir() + assert detect_yaml_format(tmp_path) == "declarative" + + +# --------------------------------------------------------------------------- +# Workspace-level load/store tests +# --------------------------------------------------------------------------- + + +class TestWorkspaceLoadStore: + def test_load_aac_workspace_from_fixtures(self) -> None: + """Load fixtures excluding dashboards (WASM crypto limitation).""" + # Use a temp dir with only datasets, metrics, visualizations + import shutil + import tempfile + + with tempfile.TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + for subdir in ("datasets", "metrics", "visualisations"): + src = _FIXTURES_DIR / subdir + if src.exists(): + shutil.copytree(src, tmp_path / subdir) + + model = load_aac_workspace_from_disk(tmp_path) + model_dict = model.to_dict(camel_case=True) + + ldm = model_dict.get("ldm", {}) + analytics = model_dict.get("analytics", {}) + + # 2 datasets + 1 date dataset in fixtures + assert len(ldm.get("datasets", [])) == 2 + assert len(ldm.get("dateInstances", [])) == 1 + + # 1 metric + assert len(analytics.get("metrics", [])) == 1 + + # 2 visualizations + assert len(analytics.get("visualizationObjects", [])) == 2 + + def test_store_and_reload_metrics(self, tmp_path: Path) -> None: + """Store metric AAC files, then reload and compare.""" + # Create a workspace model with just metrics (clean round-trip) + + aac_metrics = [ + {"id": "m1", "title": "Metric 1", "maql": "SELECT 1", "format": "#,##0"}, + {"id": "m2", "title": "Metric 2", "maql": "SELECT 2", "format": "#,##0.00"}, + ] + metrics_declarative = [aac_metric_to_declarative(m) for m in aac_metrics] + + model_dict = {"analytics": {"metrics": metrics_declarative}} + from gooddata_sdk.catalog.workspace.declarative_model.workspace.workspace import ( + CatalogDeclarativeWorkspaceModel, + ) + + model = CatalogDeclarativeWorkspaceModel.from_dict(model_dict) + store_aac_workspace_to_disk(model, tmp_path) + + # Verify files were created + metric_files = list((tmp_path / "metrics").glob("*.yaml")) + assert len(metric_files) == 2 + + # Reload + reloaded = load_aac_workspace_from_disk(tmp_path) + reloaded_dict = reloaded.to_dict(camel_case=True) + assert len(reloaded_dict.get("analytics", {}).get("metrics", [])) == 2 + + def test_load_ignores_non_workspace_dirs(self, tmp_path: Path) -> None: + """Ensure load skips declarative non-workspace directories.""" + # Create AAC file + datasets_dir = tmp_path / "datasets" + datasets_dir.mkdir() + (datasets_dir / "orders.yaml").write_text( + "type: dataset\nid: orders\ntable_path: orders\ntitle: Orders\nprimary_key: order_id\n" + "fields:\n order_id:\n type: attribute\n source_column: order_id\n data_type: STRING\n" + ) + # Create declarative non-workspace dir (should be ignored) + users_dir = tmp_path / "users" + users_dir.mkdir() + (users_dir / "users.yaml").write_text("- id: admin\n authenticationId: admin\n") + + model = load_aac_workspace_from_disk(tmp_path) + model_dict = model.to_dict(camel_case=True) + assert len(model_dict.get("ldm", {}).get("datasets", [])) == 1 diff --git a/packages/gooddata-sdk/tests/cli/__init__.py b/packages/gooddata-sdk/tests/cli/__init__.py new file mode 100644 index 000000000..efe7c60c8 --- /dev/null +++ b/packages/gooddata-sdk/tests/cli/__init__.py @@ -0,0 +1 @@ +# (C) 2026 GoodData Corporation diff --git a/packages/gooddata-sdk/tests/cli/test_gdc.py b/packages/gooddata-sdk/tests/cli/test_gdc.py new file mode 100644 index 000000000..24c582513 --- /dev/null +++ b/packages/gooddata-sdk/tests/cli/test_gdc.py @@ -0,0 +1,49 @@ +# (C) 2026 GoodData Corporation +from __future__ import annotations + +from pathlib import Path + +import pytest +import yaml +from gooddata_sdk.cli.constants import DEFAULT_SOURCE_DIR +from gooddata_sdk.cli.deploy import _get_workspace_id +from gooddata_sdk.cli.gdc_core import _get_source_dir + +_FIXTURES_DIR = Path(__file__).parent.parent / "catalog" / "fixtures" / "aac" + + +class TestCliConfig: + def test_get_source_dir_from_config(self) -> None: + source_dir = _get_source_dir(_FIXTURES_DIR / "gooddata.yaml") + assert source_dir == "analytics" + + def test_get_source_dir_default(self, tmp_path: Path) -> None: + """When source_dir is not in config, fall back to default.""" + config = { + "profiles": {"default": {"host": "https://h", "token": "t"}}, + "default_profile": "default", + } + config_path = tmp_path / "gooddata.yaml" + config_path.write_text(yaml.dump(config)) + source_dir = _get_source_dir(config_path) + assert source_dir == DEFAULT_SOURCE_DIR + + def test_get_workspace_id(self) -> None: + workspace_id = _get_workspace_id(_FIXTURES_DIR / "gooddata.yaml") + assert workspace_id == "test-workspace" + + def test_get_workspace_id_missing(self, tmp_path: Path) -> None: + """Raise error when workspace_id is not set in profile.""" + config = { + "profiles": {"default": {"host": "https://h", "token": "t"}}, + "default_profile": "default", + } + config_path = tmp_path / "gooddata.yaml" + config_path.write_text(yaml.dump(config)) + with pytest.raises(ValueError, match="workspace_id"): + _get_workspace_id(config_path) + + def test_no_nodejs_dependency(self) -> None: + """Ensure no Node.js artifacts remain in the CLI module.""" + cli_dir = Path(__file__).parent.parent.parent / "src" / "gooddata_sdk" / "cli" + assert not (cli_dir / "package.json").exists() diff --git a/scripts/validate_python.sh b/scripts/validate_python.sh new file mode 100755 index 000000000..4cc4446ff --- /dev/null +++ b/scripts/validate_python.sh @@ -0,0 +1,223 @@ +#!/usr/bin/env bash +# (C) 2026 GoodData Corporation +# +# Python validation wrapper for AIDA validate_command. +# +# Contract (streaming-first): +# - Emits human-readable progress lines to stdout. +# - Writes raw command output to stderr only when a step fails. +# - Prints the final JSON result as the last non-empty line on stdout. + +set -uo pipefail + +TOOL="validate_python" + +json_escape() { + local s="${1-}" + s="${s//\\/\\\\}" + s="${s//\"/\\\"}" + s="${s//$'\t'/\\t}" + s="${s//$'\r'/\\r}" + s="${s//$'\n'/\\n}" + printf '%s' "$s" +} + +emit_line() { + local msg="${2-${1-}}" + printf '%s\n' "$msg" +} + +usage() { + cat <<'EOF' +Usage: + validate_python.sh --project-path --workspace-root [options] + +Options: + --scope Validation scope: pre_commit or pre_push (default: pre_push) + pre_commit: format,lint,types + pre_push: format,lint,types,test + --steps-csv "" Comma-separated steps (overrides --scope defaults) + --auto-fix Default: true (uses *-fix Make targets) + --test-filter Optional pytest selector (runs uv run pytest -v ) + + -h, --help Show help +EOF +} + +PROJECT_PATH="" +WORKSPACE_ROOT="" +SCOPE="" +STEPS_CSV="" +AUTO_FIX="true" +TEST_FILTER="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --project-path) PROJECT_PATH="${2-}"; shift 2 ;; + --workspace-root) WORKSPACE_ROOT="${2-}"; shift 2 ;; + --scope) SCOPE="${2-}"; shift 2 ;; + --steps-csv) STEPS_CSV="${2-}"; shift 2 ;; + --auto-fix) AUTO_FIX="${2-}"; shift 2 ;; + --test-filter) TEST_FILTER="${2-}"; shift 2 ;; + -h|--help) usage; exit 0 ;; + *) echo "Unknown arg: $1" >&2; usage >&2; exit 2 ;; + esac +done + +if [[ -z "$PROJECT_PATH" || -z "$WORKSPACE_ROOT" ]]; then + echo "Missing required args: --project-path and --workspace-root" >&2 + usage >&2 + exit 2 +fi + +PROJECT_NAME="$(basename "$PROJECT_PATH")" + +to_bool() { + local v="${1-}" + v="$(printf '%s' "$v" | tr '[:upper:]' '[:lower:]')" + case "$v" in + 1|true|yes|on) printf 'true' ;; + *) printf 'false' ;; + esac +} + +AUTO_FIX_BOOL="$(to_bool "$AUTO_FIX")" + +split_csv() { + local csv="${1-}" + local out=() + local item + IFS=',' read -r -a out <<<"$csv" + for item in "${out[@]}"; do + item="$(printf '%s' "$item" | xargs)" + if [[ -n "$item" ]]; then + printf '%s\n' "$item" + fi + done +} + +if [[ -n "${STEPS_CSV// }" ]]; then + mapfile -t STEPS < <(split_csv "$STEPS_CSV") +else + case "${SCOPE}" in + pre_commit) + STEPS=("format" "lint" "types") + ;; + pre_push|"") + STEPS=("format" "lint" "types" "test") + ;; + *) + STEPS=("format" "lint" "types" "test") + ;; + esac +fi + +if [[ ! -d "$PROJECT_PATH" ]]; then + emit_line info "Python ${PROJECT_NAME}: project path not found: ${PROJECT_PATH}" + printf '{"tool":"%s","success":false,"text":"%s"}\n' \ + "$(json_escape "$TOOL")" \ + "$(json_escape "project path not found: ${PROJECT_PATH}")" + exit 1 +fi + +if [[ ! -f "${PROJECT_PATH%/}/Makefile" ]]; then + emit_line info "Python ${PROJECT_NAME}: Makefile missing (required)" + printf '{"tool":"%s","success":false,"text":"%s"}\n' \ + "$(json_escape "$TOOL")" \ + "$(json_escape "No Makefile found in ${PROJECT_PATH}")" + exit 1 +fi + +run_in_project() { + local label="$1" + shift + local -a cmd=("$@") + local tmp_out + tmp_out="$(mktemp)" + + emit_line progress "Python ${PROJECT_NAME}: ${label}" + + (cd "$PROJECT_PATH" && env -u VIRTUAL_ENV "${cmd[@]}") >"$tmp_out" 2>&1 + local rc=$? + if [[ "$rc" -ne 0 ]]; then + cat "$tmp_out" >&2 + fi + rm -f "$tmp_out" + return "$rc" +} + +run_in_project_capture() { + local label="$1" + local out_file="$2" + shift 2 + local -a cmd=("$@") + + emit_line progress "Python ${PROJECT_NAME}: ${label}" + + (cd "$PROJECT_PATH" && env -u VIRTUAL_ENV "${cmd[@]}") >"$out_file" 2>&1 + return $? +} + +has_make_target() { + local target="$1" + local code + (cd "$PROJECT_PATH" && make -q "$target" >/dev/null 2>&1) + code=$? + [[ $code -eq 0 || $code -eq 1 ]] +} + +FAIL_STEP="" + +for step in "${STEPS[@]}"; do + case "$step" in + format) + if [[ "$AUTO_FIX_BOOL" == "true" ]] && has_make_target "format-fix"; then + if ! run_in_project "Format (make format-fix)" make format-fix; then FAIL_STEP="Format"; break; fi + else + if ! run_in_project "Format (make format)" make format; then FAIL_STEP="Format"; break; fi + fi + ;; + lint) + if [[ "$AUTO_FIX_BOOL" == "true" ]] && has_make_target "lint-fix"; then + if ! run_in_project "Lint (make lint-fix)" make lint-fix; then FAIL_STEP="Lint"; break; fi + else + if ! run_in_project "Lint (make lint)" make lint; then FAIL_STEP="Lint"; break; fi + fi + ;; + types) + tmp_types="$(mktemp)" + if run_in_project_capture "Types (make type-check)" "$tmp_types" make type-check; then + rm -f "$tmp_types" + else + cat "$tmp_types" >&2 + rm -f "$tmp_types" + FAIL_STEP="Types" + break + fi + ;; + test) + if [[ -n "${TEST_FILTER}" ]]; then + if ! run_in_project "Test (pytest ${TEST_FILTER})" uv run pytest -v "$TEST_FILTER"; then + FAIL_STEP="Test"; break + fi + else + if ! run_in_project "Test (make test)" make test; then FAIL_STEP="Test"; break; fi + fi + ;; + *) + emit_line info "Python ${PROJECT_NAME}: skipping unknown step '${step}'" + ;; + esac +done + +if [[ -n "$FAIL_STEP" ]]; then + printf '{"tool":"%s","success":false,"text":"%s"}\n' \ + "$(json_escape "$TOOL")" \ + "$(json_escape "Python ${PROJECT_NAME}: FAILED at ${FAIL_STEP}")" + exit 1 +fi + +printf '{"tool":"%s","success":true,"text":"%s"}\n' \ + "$(json_escape "$TOOL")" \ + "$(json_escape "Python ${PROJECT_NAME}: PASSED")" +exit 0 diff --git a/uv.lock b/uv.lock index cb9d80d13..183ce813a 100644 --- a/uv.lock +++ b/uv.lock @@ -754,6 +754,17 @@ requires-dist = [ { name = "urllib3", specifier = ">=2.6.1" }, ] +[[package]] +name = "gooddata-code-convertors" +version = "11.28.0a15" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wasmtime" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/05/74/9b26234b7e255b8977843fbf843a0a533e304510d75ddbd11dd98fbf8f79/gooddata_code_convertors-11.28.0a15-py3-none-any.whl", hash = "sha256:b583372a9e3a12551b930b4506e109e2f12ff7ff1a938ca2731b72ab7675a8f6", size = 1103444, upload-time = "2026-04-01T08:23:01.969Z" }, +] + [[package]] name = "gooddata-dbt" version = "1.62.0" @@ -1104,6 +1115,7 @@ dependencies = [ { name = "brotli" }, { name = "cattrs" }, { name = "gooddata-api-client" }, + { name = "gooddata-code-convertors" }, { name = "python-dateutil" }, { name = "python-dotenv" }, { name = "pyyaml" }, @@ -1130,6 +1142,7 @@ requires-dist = [ { name = "brotli", specifier = "==1.2.0" }, { name = "cattrs", specifier = ">=22.1.0,<=24.1.1" }, { name = "gooddata-api-client", editable = "gooddata-api-client" }, + { name = "gooddata-code-convertors" }, { name = "python-dateutil", specifier = ">=2.5.3" }, { name = "python-dotenv", specifier = ">=1.0.0,<2.0.0" }, { name = "pyyaml", specifier = ">=6.0" }, @@ -1215,6 +1228,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fa/5e/f8e9a1d23b9c20a551a8a02ea3637b4642e22c2626e3a13a9a29cdea99eb/importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151", size = 27865, upload-time = "2025-12-21T10:00:18.329Z" }, ] +[[package]] +name = "importlib-resources" +version = "6.5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cf/8c/f834fbf984f691b4f7ff60f50b514cc3de5cc08abfc3295564dd89c5e2e7/importlib_resources-6.5.2.tar.gz", hash = "sha256:185f87adef5bcc288449d98fb4fba07cea78bc036455dd44c5fc4a2fe78fed2c", size = 44693, upload-time = "2025-01-03T18:51:56.698Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/ed/1f1afb2e9e7f38a545d628f864d562a5ae64fe6f7a10e28ffb9b185b4e89/importlib_resources-6.5.2-py3-none-any.whl", hash = "sha256:789cfdc3ed28c78b67a06acb8126751ced69a3d5f79c095a98298cd8a760ccec", size = 37461, upload-time = "2025-01-03T18:51:54.306Z" }, +] + [[package]] name = "iniconfig" version = "2.3.0" @@ -2784,6 +2806,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/42/d7/394801755d4c8684b655d35c665aea7836ec68320304f62ab3c94395b442/virtualenv-20.38.0-py3-none-any.whl", hash = "sha256:d6e78e5889de3a4742df2d3d44e779366325a90cf356f15621fddace82431794", size = 5837778, upload-time = "2026-02-19T07:47:59.778Z" }, ] +[[package]] +name = "wasmtime" +version = "30.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "importlib-resources" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/31/0c/290dd5081c7c99d39bf85c5315a88c53079c5fe777ba6264f49cefed6362/wasmtime-30.0.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:578378ab33c9b046e74d44461eedd6fe4e4fca8522caa7f251123490263dabbf", size = 7040481, upload-time = "2025-02-20T16:26:20.322Z" }, + { url = "https://files.pythonhosted.org/packages/1c/4d/b127fab50719ad64cf5feef47daa5d0967ce0b1d96b8061ed2955b92d660/wasmtime-30.0.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:71423e8f56f7376ef1e5e2f560f2c51818213fc5d6f1a10dcf1d46b337cf9476", size = 6378107, upload-time = "2025-02-20T16:26:23.368Z" }, + { url = "https://files.pythonhosted.org/packages/44/aa/630ebb7a1dc8202da6b0d4d218b257203dc5df1571b6d9a9fe231cac9dfe/wasmtime-30.0.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:26af8a74e0f5786ee01ec8398621a268e6696d3b8e25d66f5d0ec931357f683a", size = 7471161, upload-time = "2025-02-20T16:26:26.906Z" }, + { url = "https://files.pythonhosted.org/packages/2c/f8/c13505b4506b117f6adffba9980d224c14922718dc437213447659db5c35/wasmtime-30.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:cac51ab4e91a2f44bdae88153859cef72a0c47438ef976ab18d90fa12d0c354e", size = 6958245, upload-time = "2025-02-20T16:26:29.068Z" }, + { url = "https://files.pythonhosted.org/packages/f6/e1/ed753bf0dc18c460feec06197f21f9c96709640990712bb6c6f075aee753/wasmtime-30.0.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:3964f0cf115e3c8dbd120a2b0eda6744fda10648a9e465930b81bdc876bcba36", size = 7481754, upload-time = "2025-02-20T16:26:31.901Z" }, + { url = "https://files.pythonhosted.org/packages/7d/53/ed6efad1505014abb7634fc2aa61aa7b521e45dce6d17fcb8180757a459e/wasmtime-30.0.0-py3-none-win_amd64.whl", hash = "sha256:651d8c0fa75b10840906a96530bac09937af7510a38346e1d0391f0240057f58", size = 5866033, upload-time = "2025-02-20T16:26:35.781Z" }, + { url = "https://files.pythonhosted.org/packages/67/09/e0a2a218d5292f017d41665a47bc672bd00533b3c0c9eb11316b4fd9fa56/wasmtime-30.0.0-py3-none-win_arm64.whl", hash = "sha256:0fcc47ca1b5ccc303167e58a89c2b9865928d8c024ee1481a4f2cf75dcc4eb70", size = 5375297, upload-time = "2025-02-20T16:26:39.611Z" }, +] + [[package]] name = "werkzeug" version = "3.1.5"