phlo-airbyte 0.15.1__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.
@@ -0,0 +1,15 @@
1
+ Metadata-Version: 2.4
2
+ Name: phlo-airbyte
3
+ Version: 0.15.1
4
+ Summary: Airbyte control-plane integration plugin for Phlo
5
+ Author-email: Phlo Team <team@phlo.dev>
6
+ License: MIT
7
+ Requires-Python: >=3.11
8
+ Description-Content-Type: text/plain
9
+ Requires-Dist: phlo<0.16,>=0.15.1
10
+ Requires-Dist: requests>=2.32.5
11
+ Provides-Extra: dev
12
+ Requires-Dist: pytest>=7.0; extra == "dev"
13
+ Requires-Dist: ruff>=0.1.0; extra == "dev"
14
+
15
+ Airbyte control-plane integration plugin for Phlo.
@@ -0,0 +1,60 @@
1
+ # phlo-airbyte
2
+
3
+ Airbyte control-plane integration plugin for Phlo.
4
+
5
+ ## Description
6
+
7
+ `phlo-airbyte` treats Airbyte as a **connector execution engine, not a second
8
+ orchestrator**. Dagster remains the scheduler: a Phlo asset
9
+ (`AirbyteConnectionAsset`) starts one sync on a pre-existing Airbyte
10
+ connection, polls its job, fails closed on unknown or ambiguous terminal
11
+ states, and only then emits a materialization carrying the job id, connection
12
+ id, output tables, and timestamps as lineage evidence.
13
+
14
+ Source credentials live in Airbyte's secret store; Phlo config never stores
15
+ them.
16
+
17
+ > **Boundary note:** connector execution requires the full self-managed
18
+ > Airbyte stack (workers, temporal, webapp), deployed via `abctl` or an
19
+ > external installation. The pinned `airbyte/server` service here provides
20
+ > the control-plane API Phlo integrates with. The Iceberg destination
21
+ > contract is under an active compatibility spike against the pinned
22
+ > release; if it cannot land schema-evolving output in the required Iceberg
23
+ > layout, the approved fallback keeps Airbyte for extraction and delegates
24
+ > the final Iceberg write to Phlo's existing dlt path.
25
+
26
+ ## Installation
27
+
28
+ ```bash
29
+ pip install phlo-airbyte
30
+ # or
31
+ phlo plugin install airbyte
32
+ ```
33
+
34
+ ## Configuration
35
+
36
+ | Variable | Default | Description |
37
+ | -------- | ------- | ----------- |
38
+ | `AIRBYTE_PORT` | `10020` | Airbyte server API host port |
39
+ | `AIRBYTE_WORKSPACE_ID` | _(empty)_ | Airbyte workspace id for connection lookups |
40
+ | `AIRBYTE_POLL_INTERVAL_SECONDS` | `10` | Seconds between sync job status polls |
41
+ | `AIRBYTE_SYNC_TIMEOUT_SECONDS` | `3600` | Max seconds to wait for one sync |
42
+
43
+ ## Usage
44
+
45
+ ```python
46
+ from phlo_airbyte.assets import phlo_airbyte_connection
47
+
48
+ phlo_airbyte_connection(
49
+ connection_id="<airbyte-connection-uuid>",
50
+ tables=["bronze.postgres_users"],
51
+ group="ingestion",
52
+ name="postgres_users",
53
+ )
54
+ ```
55
+
56
+ ```bash
57
+ phlo airbyte status
58
+ phlo airbyte connections
59
+ phlo airbyte sync <connection-id>
60
+ ```
@@ -0,0 +1,64 @@
1
+ [build-system]
2
+ build-backend = "setuptools.build_meta"
3
+ requires = [
4
+ "setuptools>=45",
5
+ "wheel",
6
+ ]
7
+
8
+ [project]
9
+ dependencies = [
10
+ "phlo>=0.15.1,<0.16",
11
+ "requests>=2.32.5",
12
+ ]
13
+ description = "Airbyte control-plane integration plugin for Phlo"
14
+ name = "phlo-airbyte"
15
+ requires-python = ">=3.11"
16
+ version = "0.15.1"
17
+
18
+ [[project.authors]]
19
+ email = "team@phlo.dev"
20
+ name = "Phlo Team"
21
+
22
+ [project.entry-points."phlo.plugins.assets"]
23
+ airbyte = "phlo_airbyte.plugin:AirbyteAssetProvider"
24
+
25
+ [project.entry-points."phlo.plugins.cli"]
26
+ airbyte = "phlo_airbyte.cli_plugin:AirbyteCliPlugin"
27
+
28
+ [project.entry-points."phlo.plugins.ingestion_providers"]
29
+ airbyte = "phlo_airbyte.plugin:AirbyteIngestionProvider"
30
+
31
+ [project.entry-points."phlo.plugins.resources"]
32
+ airbyte = "phlo_airbyte.resource_provider:AirbyteResourceProvider"
33
+
34
+ [project.entry-points."phlo.plugins.services"]
35
+ airbyte = "phlo_airbyte.plugin:AirbyteServicePlugin"
36
+
37
+ [project.license]
38
+ text = "MIT"
39
+
40
+ [project.optional-dependencies]
41
+ dev = [
42
+ "pytest>=7.0",
43
+ "ruff>=0.1.0",
44
+ ]
45
+
46
+ [project.readme]
47
+ content-type = "text/plain"
48
+ text = "Airbyte control-plane integration plugin for Phlo."
49
+
50
+ [tool.ruff]
51
+ line-length = 100
52
+ target-version = "py311"
53
+
54
+ [tool.setuptools]
55
+ include-package-data = true
56
+
57
+ [tool.setuptools.package-data]
58
+ phlo_airbyte = ["service.yaml"]
59
+
60
+ [tool.setuptools.package-dir]
61
+ "" = "src"
62
+
63
+ [tool.setuptools.packages.find]
64
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,13 @@
1
+ """Phlo Airbyte integration package.
2
+
3
+ Provides the self-managed Airbyte service descriptor, the Configuration API
4
+ client, and the ``AirbyteConnectionAsset`` that runs one Airbyte sync inside
5
+ Phlo's Dagster-owned lifecycle with job-state evidence.
6
+ """
7
+
8
+ from importlib.metadata import version
9
+
10
+ from phlo_airbyte.plugin import AirbyteServicePlugin
11
+
12
+ __all__ = ["AirbyteServicePlugin"]
13
+ __version__ = version("phlo-airbyte")
@@ -0,0 +1,153 @@
1
+ """Airbyte connection assets.
2
+
3
+ ``phlo_airbyte_connection`` declares a named, pre-existing Airbyte connection
4
+ plus its expected output tables. Dagster owns scheduling: the asset starts one
5
+ sync, polls it to a verified terminal state, and only then emits a
6
+ materialization carrying the job id, connection id, output tables, and
7
+ timestamps as lineage evidence. Source credentials stay in Airbyte's secret
8
+ store; Phlo never stores them.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from collections.abc import Callable, Iterator
14
+
15
+ from phlo.capabilities import AssetSpec, MaterializeResult, PartitionSpec, RunSpec
16
+ from phlo.capabilities.runtime import RuntimeContext
17
+ from phlo.exceptions import PhloConfigError
18
+ from phlo.logging import log_event
19
+
20
+ from phlo_airbyte.client import AirbyteClient
21
+
22
+ # Populated at decoration time and kept for the process lifetime. Cleared only
23
+ # by clear_airbyte_assets(), which tests and plugin reloads use to reset state.
24
+ _AIRBYTE_ASSETS: list[AssetSpec] = []
25
+
26
+
27
+ def get_airbyte_assets() -> list[AssetSpec]:
28
+ """Return registered Airbyte connection asset specifications."""
29
+ return list(_AIRBYTE_ASSETS)
30
+
31
+
32
+ def clear_airbyte_assets() -> None:
33
+ """Clear all registered Airbyte assets (for tests and plugin reloads)."""
34
+ _AIRBYTE_ASSETS.clear()
35
+
36
+
37
+ def _validate_connection_config(connection_id: str, tables: list[str]) -> None:
38
+ if not connection_id or not connection_id.strip():
39
+ raise PhloConfigError(
40
+ message="Airbyte connection assets require a connection_id",
41
+ suggestions=["Copy the connection id from the Airbyte workspace."],
42
+ )
43
+ if not tables:
44
+ raise PhloConfigError(
45
+ message="Airbyte connection assets must declare their expected output tables",
46
+ suggestions=[
47
+ "List the tables the connection writes so downstream assets can depend on them."
48
+ ],
49
+ )
50
+
51
+
52
+ def _build_asset_run(
53
+ *,
54
+ connection_id: str,
55
+ tables: list[str],
56
+ client_factory: Callable[[], AirbyteClient] | None,
57
+ ) -> Callable[[RuntimeContext], Iterator[MaterializeResult]]:
58
+ """Build the run callable that triggers and polls one Airbyte sync."""
59
+
60
+ def run(runtime: RuntimeContext) -> Iterator[MaterializeResult]:
61
+ logger = runtime.logger
62
+ log_event(logger, "info", "starting_airbyte_sync", connection_id=connection_id)
63
+
64
+ if client_factory is not None:
65
+ client = client_factory()
66
+ else:
67
+ client = AirbyteClient()
68
+ evidence = client.run_sync(connection_id)
69
+
70
+ log_event(
71
+ logger,
72
+ "info",
73
+ "airbyte_sync_completed",
74
+ job_id=evidence["job_id"],
75
+ status=evidence["status"],
76
+ )
77
+ yield MaterializeResult(
78
+ metadata={
79
+ "airbyte_job_id": evidence["job_id"],
80
+ "airbyte_connection_id": evidence["connection_id"],
81
+ "airbyte_status": evidence["status"],
82
+ "output_tables": tables,
83
+ "airbyte_started_at": evidence.get("started_at"),
84
+ "airbyte_ended_at": evidence.get("ended_at"),
85
+ "airbyte_elapsed_seconds": evidence.get("elapsed_seconds"),
86
+ "source_state": {"job_id": evidence["job_id"], "status": evidence["status"]},
87
+ },
88
+ status="ok",
89
+ )
90
+
91
+ return run
92
+
93
+
94
+ def phlo_airbyte_connection(
95
+ connection_id: str,
96
+ tables: list[str],
97
+ group: str,
98
+ *,
99
+ name: str | None = None,
100
+ destination: str = "iceberg",
101
+ description: str | None = None,
102
+ max_runtime_seconds: int = 3600,
103
+ max_retries: int = 1,
104
+ retry_delay_seconds: int = 60,
105
+ cron: str | None = None,
106
+ client_factory: Callable[[], AirbyteClient] | None = None,
107
+ ) -> AssetSpec:
108
+ """Register an Airbyte connection asset and return its specification.
109
+
110
+ ``connection_id`` must reference a connection that already exists in the
111
+ Airbyte workspace; Phlo triggers and observes the sync but never mutates
112
+ Airbyte configuration.
113
+ """
114
+ _validate_connection_config(connection_id, tables)
115
+ asset_name = name or connection_id[:8]
116
+ asset_key = f"{group}.{asset_name}"
117
+ spec = AssetSpec(
118
+ key=asset_key,
119
+ group=group,
120
+ description=description
121
+ or f"Airbyte sync for connection {connection_id} landing {', '.join(tables)}",
122
+ kinds={"airbyte", "ingestion"},
123
+ tags={
124
+ "provider": "airbyte",
125
+ "asset_type": "ingestion",
126
+ "source": "airbyte",
127
+ "destination": destination,
128
+ },
129
+ metadata={
130
+ "provider": "airbyte",
131
+ "airbyte_connection_id": connection_id,
132
+ "output_tables": tables,
133
+ "destination": destination,
134
+ "group": group,
135
+ },
136
+ partitions=PartitionSpec(kind="daily"),
137
+ resources=set(),
138
+ run=RunSpec(
139
+ fn=_build_asset_run(
140
+ connection_id=connection_id,
141
+ tables=tables,
142
+ client_factory=client_factory,
143
+ ),
144
+ max_runtime_seconds=max_runtime_seconds,
145
+ max_retries=max_retries,
146
+ retry_delay_seconds=retry_delay_seconds,
147
+ cron=cron,
148
+ freshness_hours=None,
149
+ ),
150
+ checks=[],
151
+ )
152
+ _AIRBYTE_ASSETS.append(spec)
153
+ return spec
@@ -0,0 +1,63 @@
1
+ """Airbyte CLI commands: status, connections, and manual sync."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import click
6
+
7
+ from phlo.cli.output import command_failed_error
8
+
9
+
10
+ @click.command(name="airbyte")
11
+ @click.argument("airbyte_args", nargs=-1, type=click.UNPROCESSED)
12
+ @click.pass_context
13
+ def airbyte_group(ctx: click.Context, airbyte_args: tuple[str, ...]) -> None:
14
+ """Interact with the Airbyte control plane (status, connections, sync)."""
15
+ args = list(airbyte_args)
16
+ if not args or args[0] in {"-h", "--help", "help"}:
17
+ click.echo(ctx.get_help())
18
+ return
19
+ command = args.pop(0)
20
+ if command == "status":
21
+ _status()
22
+ return
23
+ if command == "connections":
24
+ _connections()
25
+ return
26
+ if command == "sync":
27
+ if not args:
28
+ command_failed_error("sync requires a connection id")
29
+ _sync(args[0])
30
+ return
31
+ click.echo(f"Unknown airbyte command: {command}", err=True)
32
+ ctx.exit(2)
33
+
34
+
35
+ def _status() -> None:
36
+ from phlo_airbyte.client import AirbyteClient
37
+
38
+ client = AirbyteClient()
39
+ healthy = client.health_check()
40
+ click.echo(f"Airbyte health: {'ok' if healthy else 'unavailable'}")
41
+
42
+
43
+ def _connections() -> None:
44
+ from phlo_airbyte.client import AirbyteClient
45
+
46
+ client = AirbyteClient()
47
+ try:
48
+ connections = client.list_connections()
49
+ except Exception as exc:
50
+ command_failed_error(f"Could not list Airbyte connections: {exc}")
51
+ for connection in connections:
52
+ click.echo(f" - {connection.get('connectionId', '?')}: {connection.get('name', '')}")
53
+
54
+
55
+ def _sync(connection_id: str) -> None:
56
+ from phlo_airbyte.client import AirbyteClient
57
+
58
+ client = AirbyteClient()
59
+ try:
60
+ evidence = client.run_sync(connection_id)
61
+ except Exception as exc:
62
+ command_failed_error(f"Airbyte sync failed: {exc}")
63
+ click.echo(f"Sync {evidence['job_id']}: {evidence['status']}")
@@ -0,0 +1,15 @@
1
+ """CLI plugin registration for the Airbyte package."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from phlo.plugins.base import cli_command_plugin_class
6
+
7
+ from phlo_airbyte.cli import airbyte_group
8
+
9
+ AirbyteCliPlugin = cli_command_plugin_class(
10
+ "AirbyteCliPlugin",
11
+ name="airbyte",
12
+ version="0.1.0",
13
+ description="Airbyte connection and sync commands",
14
+ commands=[airbyte_group],
15
+ )
@@ -0,0 +1,157 @@
1
+ """Airbyte Configuration API client with fail-closed job-state handling.
2
+
3
+ Dagster owns scheduling; Airbyte owns connector execution and state. This
4
+ client starts one sync, polls its job, and only returns a verdict when the
5
+ pinned Airbyte release reports a known terminal state. Unknown or ambiguous
6
+ states raise :class:`AmbiguousJobStateError` so the Phlo asset fails closed
7
+ instead of guessing.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import time
13
+ from typing import Any
14
+
15
+ import requests
16
+
17
+ from phlo.logging import get_logger
18
+ from phlo_airbyte.settings import AirbyteSettings, get_settings
19
+
20
+ logger = get_logger(__name__)
21
+
22
+ REQUEST_TIMEOUT_SECONDS = 30
23
+
24
+ KNOWN_TERMINAL_STATUSES: dict[str, str] = {
25
+ "succeeded": "succeeded",
26
+ "failed": "failed",
27
+ "cancelled": "cancelled",
28
+ }
29
+
30
+
31
+ class AmbiguousJobStateError(RuntimeError):
32
+ """Raised when a sync job ends in an unknown or ambiguous state."""
33
+
34
+
35
+ class AirbyteClient:
36
+ """HTTP client for the Airbyte Configuration API."""
37
+
38
+ def __init__(self, settings: AirbyteSettings | None = None) -> None:
39
+ self._settings = settings
40
+
41
+ @property
42
+ def settings(self) -> AirbyteSettings:
43
+ if self._settings is None:
44
+ self._settings = get_settings()
45
+ return self._settings
46
+
47
+ def _request(self, method: str, path: str, *, json_body: dict[str, Any] | None = None) -> Any:
48
+ url = f"{self.settings.airbyte_api_uri()}{path}"
49
+ response = requests.request(method, url, json=json_body, timeout=REQUEST_TIMEOUT_SECONDS)
50
+ response.raise_for_status()
51
+ payload = response.json()
52
+ return payload if payload is not None else {}
53
+
54
+ def health_check(self) -> bool:
55
+ """Return whether the Airbyte server responds on its health endpoint."""
56
+ try:
57
+ response = requests.get(
58
+ f"{self.settings.airbyte_api_uri()}/api/v1/health",
59
+ timeout=REQUEST_TIMEOUT_SECONDS,
60
+ )
61
+ except requests.RequestException:
62
+ logger.warning("airbyte_health_check_failed", exc_info=True)
63
+ return False
64
+ return response.status_code == 200
65
+
66
+ def list_connections(self) -> list[dict[str, Any]]:
67
+ """List connections in the configured workspace."""
68
+ body: dict[str, Any] = {}
69
+ if self.settings.airbyte_workspace_id:
70
+ body["workspaceId"] = self.settings.airbyte_workspace_id
71
+ payload = self._request("POST", "/api/v1/connections/list", json_body=body)
72
+ return list(payload.get("connections", []))
73
+
74
+ def trigger_sync(self, connection_id: str) -> dict[str, Any]:
75
+ """Start one sync for a pre-existing Airbyte connection."""
76
+ return dict(
77
+ self._request(
78
+ "POST",
79
+ "/api/v1/jobs/run",
80
+ json_body={"connectionId": connection_id, "jobType": "sync"},
81
+ )
82
+ )
83
+
84
+ def get_job(self, job_id: str) -> dict[str, Any]:
85
+ """Return the current job record for one sync."""
86
+ return dict(self._request("POST", "/api/v1/jobs/get", json_body={"id": int(job_id)}))
87
+
88
+ @staticmethod
89
+ def classify_status(status: str | None) -> str | None:
90
+ """Map an Airbyte job status to a Phlo terminal verdict, else None.
91
+
92
+ Raises AmbiguousJobStateError for any status that is neither a known
93
+ terminal state nor a known in-progress state, so callers fail closed.
94
+ """
95
+ if status is None:
96
+ raise AmbiguousJobStateError("Airbyte job returned no status")
97
+ normalized = str(status).strip().lower()
98
+ if normalized in KNOWN_TERMINAL_STATUSES:
99
+ return KNOWN_TERMINAL_STATUSES[normalized]
100
+ if normalized in {"pending", "running", "incomplete", "incomplete_retrying"}:
101
+ return None
102
+ raise AmbiguousJobStateError(
103
+ f"Unknown Airbyte job status {status!r}; refusing to guess the outcome"
104
+ )
105
+
106
+ def run_sync(
107
+ self,
108
+ connection_id: str,
109
+ *,
110
+ poll_interval_seconds: int | None = None,
111
+ timeout_seconds: int | None = None,
112
+ clock: Any = None,
113
+ ) -> dict[str, Any]:
114
+ """Run one sync to a verified terminal state and return its evidence.
115
+
116
+ The returned evidence carries the job id, connection id, terminal
117
+ status, and timestamps for lineage. Unknown states and timeouts raise
118
+ rather than reporting success.
119
+ """
120
+ settings = self.settings
121
+ poll_interval = poll_interval_seconds or settings.airbyte_poll_interval_seconds
122
+ timeout = timeout_seconds or settings.airbyte_sync_timeout_seconds
123
+ sleep = clock.sleep if clock is not None else time.sleep
124
+
125
+ started = time.time()
126
+ job_payload = self.trigger_sync(connection_id)
127
+ job = job_payload.get("job", job_payload)
128
+ job_id = str(job.get("id"))
129
+ logger.info("airbyte_sync_started", connection_id=connection_id, job_id=job_id)
130
+
131
+ while True:
132
+ record = self.get_job(job_id).get("job", {})
133
+ verdict = self.classify_status(record.get("status"))
134
+ if verdict is not None:
135
+ evidence = {
136
+ "job_id": job_id,
137
+ "connection_id": connection_id,
138
+ "status": verdict,
139
+ "started_at": job.get("createdAt"),
140
+ "ended_at": record.get("updatedAt"),
141
+ "elapsed_seconds": round(time.time() - started, 2),
142
+ "records_synced": record.get("recordsSynced")
143
+ if isinstance(record, dict)
144
+ else None,
145
+ }
146
+ if verdict != "succeeded":
147
+ raise RuntimeError(
148
+ f"Airbyte sync {job_id} for connection {connection_id} "
149
+ f"ended with status {verdict!r}"
150
+ )
151
+ return evidence
152
+ if time.time() - started > timeout:
153
+ raise TimeoutError(
154
+ f"Airbyte sync {job_id} for connection {connection_id} did not reach a "
155
+ f"terminal state within {timeout}s"
156
+ )
157
+ sleep(poll_interval)
@@ -0,0 +1,77 @@
1
+ """Airbyte plugin registrations: service, assets, and ingestion provider."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable, Iterable
6
+ from typing import Any
7
+
8
+ from phlo.capabilities import AssetCheckSpec, AssetSpec
9
+ from phlo.plugins.base import (
10
+ AssetProviderPlugin,
11
+ IngestionProviderPlugin,
12
+ PluginMetadata,
13
+ service_plugin_class,
14
+ )
15
+
16
+ AirbyteServicePlugin = service_plugin_class(
17
+ "AirbyteServicePlugin",
18
+ name="airbyte",
19
+ version="0.1.0",
20
+ description="Self-managed Airbyte control plane for connector-managed ingestion",
21
+ author="Phlo Team",
22
+ tags=["ingestion", "airbyte", "connectors"],
23
+ )
24
+
25
+
26
+ class AirbyteAssetProvider(AssetProviderPlugin):
27
+ """Expose registered Airbyte connection assets to the orchestrator."""
28
+
29
+ @property
30
+ def metadata(self) -> PluginMetadata:
31
+ """Return plugin metadata for the Airbyte asset provider."""
32
+ return PluginMetadata(
33
+ name="airbyte",
34
+ version="0.1.0",
35
+ description="Airbyte connection assets for Phlo",
36
+ )
37
+
38
+ def get_assets(self) -> Iterable[AssetSpec]:
39
+ """Return Airbyte assets registered via the decorator."""
40
+ from phlo_airbyte.assets import get_airbyte_assets
41
+
42
+ return get_airbyte_assets()
43
+
44
+ def get_checks(self) -> Iterable[AssetCheckSpec]:
45
+ """Airbyte connections do not register checks directly."""
46
+ return []
47
+
48
+ def clear_registries(self) -> None:
49
+ """Reset the asset registry (tests and plugin reloads)."""
50
+ from phlo_airbyte.assets import clear_airbyte_assets
51
+
52
+ clear_airbyte_assets()
53
+
54
+
55
+ class AirbyteIngestionProvider(IngestionProviderPlugin):
56
+ """Expose the Airbyte connection decorator to workflow authoring."""
57
+
58
+ @property
59
+ def metadata(self) -> PluginMetadata:
60
+ """Return plugin metadata for the Airbyte ingestion provider."""
61
+ return PluginMetadata(
62
+ name="airbyte",
63
+ version="0.1.0",
64
+ description="Airbyte connector-managed ingestion provider",
65
+ )
66
+
67
+ def get_decorator(self) -> Callable[..., Any]:
68
+ """Return the Airbyte connection decorator function."""
69
+ from phlo_airbyte.assets import phlo_airbyte_connection
70
+
71
+ return phlo_airbyte_connection
72
+
73
+ def get_asset_retriever(self) -> Callable[[], list[Any]]:
74
+ """Return the function retrieving registered Airbyte assets."""
75
+ from phlo_airbyte.assets import get_airbyte_assets
76
+
77
+ return get_airbyte_assets
@@ -0,0 +1,25 @@
1
+ """Capability provider exposing the Airbyte client as a runtime resource."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from phlo.capabilities import ResourceSpec
6
+ from phlo.plugins.base import PluginMetadata, ResourceProviderPlugin
7
+
8
+ from phlo_airbyte.client import AirbyteClient
9
+
10
+
11
+ class AirbyteResourceProvider(ResourceProviderPlugin):
12
+ """Expose the Airbyte Configuration API client as a capability resource."""
13
+
14
+ @property
15
+ def metadata(self) -> PluginMetadata:
16
+ """Return plugin metadata for the Airbyte resource provider."""
17
+ return PluginMetadata(
18
+ name="airbyte",
19
+ version="0.1.0",
20
+ description="Airbyte API client resource",
21
+ )
22
+
23
+ def get_resources(self) -> list[ResourceSpec]:
24
+ """Expose the raw Airbyte client as a runtime resource."""
25
+ return [ResourceSpec(name="airbyte", resource=AirbyteClient())]
@@ -0,0 +1,60 @@
1
+ name: airbyte
2
+ description: Self-managed Airbyte control plane for connector-managed ingestion
3
+ category: ingestion
4
+ default: false
5
+
6
+ image: airbyte/server:2.2.0@sha256:70e125498a1c110bc3e9444ccf9b373cc82517a3cfc68b8d4c54dbc553a4f4eb
7
+
8
+ depends_on:
9
+ - postgres
10
+ - airbyte-temporal
11
+ - airbyte-manifest
12
+ compose:
13
+ restart: unless-stopped
14
+ labels:
15
+ phlo.metrics.enabled: "false"
16
+ environment:
17
+ AIRBYTE_VERSION: "2.2.0"
18
+ MANIFEST_SERVER_API_HOST: http://airbyte-manifest:8080
19
+ AIRBYTE_API_HOST: http://localhost:8001/api/public
20
+ AIRBYTE_URL: ${AIRBYTE_URL:-http://localhost:8001}
21
+ CONFIG_ROOT: /data
22
+ WORKSPACE_ROOT: /data
23
+ DATABASE_USER: ${POSTGRES_USER:-phlo}
24
+ DATABASE_PASSWORD: ${POSTGRES_PASSWORD:-phlo}
25
+ DATABASE_URL: jdbc:postgresql://postgres:5432/${POSTGRES_DB:-phlo}
26
+ CONFIG_DATABASE_REPLICA_URL: jdbc:postgresql://postgres:5432/${POSTGRES_DB:-phlo}
27
+ CONFIG_DATABASE_REPLICA_USER: ${POSTGRES_USER:-phlo}
28
+ CONFIGS_DATABASE_MINIMUM_FLYWAY_MIGRATION_VERSION: "0.35.15.001"
29
+ JOBS_DATABASE_MINIMUM_FLYWAY_MIGRATION_VERSION: "0.29.15.001"
30
+ ENTERPRISE_SOURCE_STUBS_URL: https://connectors.airbyte.com/files/resources/connector_stubs/v1/connector_stubs.json
31
+ STORAGE_TYPE: minio
32
+ STORAGE_BUCKET_LOG: airbyte-bucket
33
+ STORAGE_BUCKET_STATE: airbyte-bucket
34
+ STORAGE_BUCKET_AUDIT_LOGGING: airbyte-bucket
35
+ STORAGE_BUCKET_WORKLOAD_OUTPUT: airbyte-bucket
36
+ STORAGE_BUCKET_ACTIVITY_PAYLOAD: airbyte-bucket
37
+ AWS_ACCESS_KEY_ID: ${ICEBERG_S3_ACCESS_KEY:-minio}
38
+ AWS_SECRET_ACCESS_KEY: ${ICEBERG_S3_SECRET_KEY:-minio123}
39
+ AWS_DEFAULT_REGION: ${ICEBERG_S3_REGION:-us-east-1}
40
+ MINIO_ENDPOINT: http://minio:9000
41
+ S3_PATH_STYLE_ACCESS: "true"
42
+ WORKSPACE_ID: ${AIRBYTE_WORKSPACE_ID:-}
43
+ ports:
44
+ - "${AIRBYTE_PORT:-10020}:8001"
45
+ healthcheck:
46
+ test: ["CMD", "curl", "-f", "http://localhost:8001/api/v1/health"]
47
+ interval: 15s
48
+ timeout: 5s
49
+ retries: 12
50
+ start_period: 60s
51
+
52
+ env_vars:
53
+ AIRBYTE_PORT:
54
+ default: 10020
55
+ description: Airbyte server API host port
56
+ AIRBYTE_WORKSPACE_ID:
57
+ default: ""
58
+ description: Airbyte workspace id used for connection lookups
59
+
60
+ files:
@@ -0,0 +1,49 @@
1
+ """Airbyte settings resolved from the project environment."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from phlo.config.base import BaseConfig
9
+ from phlo.config.cache import project_root_cached
10
+ from phlo.config.network import resolve_host
11
+ from pydantic import Field
12
+
13
+
14
+ class AirbyteSettings(BaseConfig):
15
+ """Settings for the Airbyte control-plane integration."""
16
+
17
+ airbyte_host: str = Field(default="airbyte-server", description="Airbyte API host")
18
+ airbyte_port: int = Field(default=8001, description="Airbyte server API port")
19
+ airbyte_workspace_id: str | None = Field(
20
+ default=None, description="Airbyte workspace id for connection lookups"
21
+ )
22
+ airbyte_client_id: str | None = Field(
23
+ default=None, description="Airbyte API client id for token auth"
24
+ )
25
+ airbyte_client_secret: str | None = Field(
26
+ default=None,
27
+ description="Airbyte API client secret for token auth",
28
+ )
29
+ airbyte_poll_interval_seconds: int = Field(
30
+ default=10, description="Seconds between sync job status polls"
31
+ )
32
+ airbyte_sync_timeout_seconds: int = Field(
33
+ default=3600, description="Maximum seconds to wait for one sync to reach a terminal state"
34
+ )
35
+
36
+ def model_post_init(self, __context: Any) -> None:
37
+ host, port = resolve_host(self.airbyte_host, self.airbyte_port, port_env_var="AIRBYTE_PORT")
38
+ object.__setattr__(self, "airbyte_host", host)
39
+ object.__setattr__(self, "airbyte_port", port)
40
+
41
+ def airbyte_api_uri(self) -> str:
42
+ """Return the Airbyte server API base URI."""
43
+ return f"http://{self.airbyte_host}:{self.airbyte_port}"
44
+
45
+
46
+ @project_root_cached
47
+ def get_settings(project_root: Path) -> AirbyteSettings:
48
+ """Return cached Airbyte settings for the selected project root."""
49
+ return AirbyteSettings()
@@ -0,0 +1,15 @@
1
+ Metadata-Version: 2.4
2
+ Name: phlo-airbyte
3
+ Version: 0.15.1
4
+ Summary: Airbyte control-plane integration plugin for Phlo
5
+ Author-email: Phlo Team <team@phlo.dev>
6
+ License: MIT
7
+ Requires-Python: >=3.11
8
+ Description-Content-Type: text/plain
9
+ Requires-Dist: phlo<0.16,>=0.15.1
10
+ Requires-Dist: requests>=2.32.5
11
+ Provides-Extra: dev
12
+ Requires-Dist: pytest>=7.0; extra == "dev"
13
+ Requires-Dist: ruff>=0.1.0; extra == "dev"
14
+
15
+ Airbyte control-plane integration plugin for Phlo.
@@ -0,0 +1,20 @@
1
+ README.md
2
+ pyproject.toml
3
+ src/phlo_airbyte/__init__.py
4
+ src/phlo_airbyte/assets.py
5
+ src/phlo_airbyte/cli.py
6
+ src/phlo_airbyte/cli_plugin.py
7
+ src/phlo_airbyte/client.py
8
+ src/phlo_airbyte/plugin.py
9
+ src/phlo_airbyte/resource_provider.py
10
+ src/phlo_airbyte/service.yaml
11
+ src/phlo_airbyte/settings.py
12
+ src/phlo_airbyte.egg-info/PKG-INFO
13
+ src/phlo_airbyte.egg-info/SOURCES.txt
14
+ src/phlo_airbyte.egg-info/dependency_links.txt
15
+ src/phlo_airbyte.egg-info/entry_points.txt
16
+ src/phlo_airbyte.egg-info/requires.txt
17
+ src/phlo_airbyte.egg-info/top_level.txt
18
+ tests/test_airbyte_assets.py
19
+ tests/test_airbyte_client.py
20
+ tests/test_airbyte_plugin.py
@@ -0,0 +1,14 @@
1
+ [phlo.plugins.assets]
2
+ airbyte = phlo_airbyte.plugin:AirbyteAssetProvider
3
+
4
+ [phlo.plugins.cli]
5
+ airbyte = phlo_airbyte.cli_plugin:AirbyteCliPlugin
6
+
7
+ [phlo.plugins.ingestion_providers]
8
+ airbyte = phlo_airbyte.plugin:AirbyteIngestionProvider
9
+
10
+ [phlo.plugins.resources]
11
+ airbyte = phlo_airbyte.resource_provider:AirbyteResourceProvider
12
+
13
+ [phlo.plugins.services]
14
+ airbyte = phlo_airbyte.plugin:AirbyteServicePlugin
@@ -0,0 +1,6 @@
1
+ phlo<0.16,>=0.15.1
2
+ requests>=2.32.5
3
+
4
+ [dev]
5
+ pytest>=7.0
6
+ ruff>=0.1.0
@@ -0,0 +1 @@
1
+ phlo_airbyte
@@ -0,0 +1,93 @@
1
+ """Tests for Airbyte connection asset registration and execution."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import pytest
6
+ from phlo.helpers.testing import FakeRuntimeContext
7
+ from phlo_airbyte.assets import (
8
+ clear_airbyte_assets,
9
+ get_airbyte_assets,
10
+ phlo_airbyte_connection,
11
+ )
12
+
13
+
14
+ class FakeClient:
15
+ def __init__(self, evidence: dict | None = None, error: Exception | None = None) -> None:
16
+ self.evidence = evidence or {
17
+ "job_id": "42",
18
+ "connection_id": "conn-1",
19
+ "status": "succeeded",
20
+ "started_at": 1000,
21
+ "ended_at": 2000,
22
+ "elapsed_seconds": 1.5,
23
+ }
24
+ self.error = error
25
+ self.ran: list[str] = []
26
+
27
+ def run_sync(self, connection_id: str, **kwargs) -> dict:
28
+ self.ran.append(connection_id)
29
+ if self.error is not None:
30
+ raise self.error
31
+ return self.evidence
32
+
33
+
34
+ @pytest.fixture(autouse=True)
35
+ def _clean_registry():
36
+ clear_airbyte_assets()
37
+ yield
38
+ clear_airbyte_assets()
39
+
40
+
41
+ def _runtime() -> FakeRuntimeContext:
42
+ return FakeRuntimeContext(partition_key="2026-09-01", run_id="run-1")
43
+
44
+
45
+ def test_connection_asset_requires_tables_and_connection_id() -> None:
46
+ with pytest.raises(Exception, match="connection_id"):
47
+ phlo_airbyte_connection(connection_id=" ", tables=["t"], group="ingestion")
48
+ with pytest.raises(Exception, match="output tables"):
49
+ phlo_airbyte_connection(connection_id="conn-1", tables=[], group="ingestion")
50
+
51
+
52
+ def test_registered_asset_carries_airbyte_metadata() -> None:
53
+ spec = phlo_airbyte_connection(
54
+ connection_id="conn-1",
55
+ tables=["bronze.users"],
56
+ group="ingestion",
57
+ name="users",
58
+ )
59
+ assert spec.key == "ingestion.users"
60
+ assert spec.tags["provider"] == "airbyte"
61
+ assert spec.metadata["airbyte_connection_id"] == "conn-1"
62
+ assert spec.metadata["output_tables"] == ["bronze.users"]
63
+ assert get_airbyte_assets() == [spec]
64
+
65
+
66
+ def test_asset_run_emits_job_evidence_metadata() -> None:
67
+ client = FakeClient()
68
+ spec = phlo_airbyte_connection(
69
+ connection_id="conn-1",
70
+ tables=["bronze.users"],
71
+ group="ingestion",
72
+ client_factory=lambda: client,
73
+ )
74
+ results = list(spec.run.fn(_runtime()))
75
+ assert len(results) == 1
76
+ metadata = results[0].metadata
77
+ assert metadata["airbyte_job_id"] == "42"
78
+ assert metadata["airbyte_connection_id"] == "conn-1"
79
+ assert metadata["output_tables"] == ["bronze.users"]
80
+ assert metadata["source_state"] == {"job_id": "42", "status": "succeeded"}
81
+ assert client.ran == ["conn-1"]
82
+
83
+
84
+ def test_asset_run_fails_closed_on_sync_failure() -> None:
85
+ client = FakeClient(error=RuntimeError("Airbyte sync ended with status 'failed'"))
86
+ spec = phlo_airbyte_connection(
87
+ connection_id="conn-1",
88
+ tables=["bronze.users"],
89
+ group="ingestion",
90
+ client_factory=lambda: client,
91
+ )
92
+ with pytest.raises(RuntimeError, match="failed"):
93
+ list(spec.run.fn(_runtime()))
@@ -0,0 +1,106 @@
1
+ """Tests for the Airbyte client's fail-closed job-state handling."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import pytest
6
+ from phlo_airbyte.client import AirbyteClient, AmbiguousJobStateError
7
+ from phlo_airbyte.settings import AirbyteSettings
8
+
9
+
10
+ class FakeTransport:
11
+ """Scripted HTTP transport returning queued payloads per path."""
12
+
13
+ def __init__(self, responses: dict[str, list[dict]]) -> None:
14
+ self.responses = {key: list(value) for key, value in responses.items()}
15
+ self.calls: list[tuple[str, dict]] = []
16
+
17
+ def post(self, path: str, body: dict) -> dict:
18
+ self.calls.append((path, body))
19
+ queue = self.responses.get(path, [])
20
+ if not queue:
21
+ raise AssertionError(f"unexpected request to {path}")
22
+ item = queue.pop(0)
23
+ if isinstance(item, Exception):
24
+ raise item
25
+ return item
26
+
27
+
28
+ def _client(transport: FakeTransport) -> AirbyteClient:
29
+ settings = AirbyteSettings(airbyte_poll_interval_seconds=0)
30
+ client = AirbyteClient(settings=settings)
31
+ client._request = lambda method, path, json_body=None: transport.post(path, json_body or {})
32
+ return client
33
+
34
+
35
+ def test_trigger_sync_sends_connection_and_job_type() -> None:
36
+ transport = FakeTransport({"/api/v1/jobs/run": [{"job": {"id": 42, "status": "pending"}}]})
37
+ client = _client(transport)
38
+ job = client.trigger_sync("conn-1")
39
+ assert job["job"]["id"] == 42
40
+ assert transport.calls == [("/api/v1/jobs/run", {"connectionId": "conn-1", "jobType": "sync"})]
41
+
42
+
43
+ def test_run_sync_returns_evidence_after_success() -> None:
44
+ transport = FakeTransport(
45
+ {
46
+ "/api/v1/jobs/run": [{"job": {"id": 42, "status": "pending", "createdAt": 1000}}],
47
+ "/api/v1/jobs/get": [
48
+ {"job": {"id": 42, "status": "running"}},
49
+ {"job": {"id": 42, "status": "succeeded", "updatedAt": 2000}},
50
+ ],
51
+ }
52
+ )
53
+ sleeps: list[int] = []
54
+ client = _client(transport)
55
+
56
+ evidence = client.run_sync(
57
+ "conn-1", poll_interval_seconds=1, timeout_seconds=30, clock=SimpleClock(sleeps)
58
+ )
59
+
60
+ assert evidence["status"] == "succeeded"
61
+ assert evidence["job_id"] == "42"
62
+ assert evidence["connection_id"] == "conn-1"
63
+ assert sleeps == [1]
64
+
65
+
66
+ class SimpleClock:
67
+ def __init__(self, sleeps: list[int]) -> None:
68
+ self.sleeps = sleeps
69
+
70
+ def sleep(self, seconds: int) -> None:
71
+ self.sleeps.append(seconds)
72
+
73
+
74
+ def test_run_sync_raises_for_failed_terminal_state() -> None:
75
+ transport = FakeTransport(
76
+ {
77
+ "/api/v1/jobs/run": [{"job": {"id": 7, "status": "running"}}],
78
+ "/api/v1/jobs/get": [{"job": {"id": 7, "status": "failed"}}],
79
+ }
80
+ )
81
+ client = _client(transport)
82
+ with pytest.raises(RuntimeError, match="ended with status 'failed'"):
83
+ client.run_sync("conn-1", poll_interval_seconds=1, timeout_seconds=10)
84
+
85
+
86
+ def test_unknown_job_status_fails_closed() -> None:
87
+ client = AirbyteClient()
88
+ assert client.classify_status("succeeded") == "succeeded"
89
+ assert client.classify_status("running") is None
90
+ assert client.classify_status("incomplete_retrying") is None
91
+ with pytest.raises(AmbiguousJobStateError, match="refusing to guess"):
92
+ client.classify_status("mystery-state")
93
+ with pytest.raises(AmbiguousJobStateError):
94
+ client.classify_status(None)
95
+
96
+
97
+ def test_run_sync_times_out_without_terminal_state() -> None:
98
+ transport = FakeTransport(
99
+ {
100
+ "/api/v1/jobs/run": [{"job": {"id": 9, "status": "running"}}],
101
+ "/api/v1/jobs/get": [{"job": {"id": 9, "status": "running"}}],
102
+ }
103
+ )
104
+ client = _client(transport)
105
+ with pytest.raises(TimeoutError, match="did not reach a terminal state"):
106
+ client.run_sync("conn-1", poll_interval_seconds=1, timeout_seconds=-1)
@@ -0,0 +1,37 @@
1
+ """Tests for the Airbyte service definition and plugin registrations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from phlo_airbyte.plugin import (
6
+ AirbyteAssetProvider,
7
+ AirbyteServicePlugin,
8
+ AirbyteIngestionProvider,
9
+ )
10
+ from phlo_airbyte.resource_provider import AirbyteResourceProvider
11
+
12
+
13
+ def test_service_definition_is_digest_pinned_and_opt_in() -> None:
14
+ plugin = AirbyteServicePlugin()
15
+ definition = plugin.service_definition
16
+ assert definition["name"] == "airbyte"
17
+ assert definition["default"] is False
18
+ assert definition["image"].startswith("airbyte/server:2.2.0@sha256:")
19
+
20
+
21
+ def test_asset_provider_exposes_registered_assets() -> None:
22
+ provider = AirbyteAssetProvider()
23
+ provider.clear_registries()
24
+ assert list(provider.get_assets()) == []
25
+ provider.clear_registries()
26
+
27
+
28
+ def test_resource_provider_exposes_airbyte_client() -> None:
29
+ provider = AirbyteResourceProvider()
30
+ resources = provider.get_resources()
31
+ assert resources[0].name == "airbyte"
32
+
33
+
34
+ def test_ingestion_provider_wires_decorator_and_retriever() -> None:
35
+ provider = AirbyteIngestionProvider()
36
+ assert callable(provider.get_decorator())
37
+ assert callable(provider.get_asset_retriever())