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.
- phlo_airbyte-0.15.1/PKG-INFO +15 -0
- phlo_airbyte-0.15.1/README.md +60 -0
- phlo_airbyte-0.15.1/pyproject.toml +64 -0
- phlo_airbyte-0.15.1/setup.cfg +4 -0
- phlo_airbyte-0.15.1/src/phlo_airbyte/__init__.py +13 -0
- phlo_airbyte-0.15.1/src/phlo_airbyte/assets.py +153 -0
- phlo_airbyte-0.15.1/src/phlo_airbyte/cli.py +63 -0
- phlo_airbyte-0.15.1/src/phlo_airbyte/cli_plugin.py +15 -0
- phlo_airbyte-0.15.1/src/phlo_airbyte/client.py +157 -0
- phlo_airbyte-0.15.1/src/phlo_airbyte/plugin.py +77 -0
- phlo_airbyte-0.15.1/src/phlo_airbyte/resource_provider.py +25 -0
- phlo_airbyte-0.15.1/src/phlo_airbyte/service.yaml +60 -0
- phlo_airbyte-0.15.1/src/phlo_airbyte/settings.py +49 -0
- phlo_airbyte-0.15.1/src/phlo_airbyte.egg-info/PKG-INFO +15 -0
- phlo_airbyte-0.15.1/src/phlo_airbyte.egg-info/SOURCES.txt +20 -0
- phlo_airbyte-0.15.1/src/phlo_airbyte.egg-info/dependency_links.txt +1 -0
- phlo_airbyte-0.15.1/src/phlo_airbyte.egg-info/entry_points.txt +14 -0
- phlo_airbyte-0.15.1/src/phlo_airbyte.egg-info/requires.txt +6 -0
- phlo_airbyte-0.15.1/src/phlo_airbyte.egg-info/top_level.txt +1 -0
- phlo_airbyte-0.15.1/tests/test_airbyte_assets.py +93 -0
- phlo_airbyte-0.15.1/tests/test_airbyte_client.py +106 -0
- phlo_airbyte-0.15.1/tests/test_airbyte_plugin.py +37 -0
|
@@ -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,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 @@
|
|
|
1
|
+
|
|
@@ -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 @@
|
|
|
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())
|