Skip to content

Commit 7b0b61f

Browse files
feat(workspaces): phase 4b — airtable/github/google_analytics/shopify/stripe
Five more StateBackedComponent workspaces, all category: ingestion so the Dagster Designer's Add Data dialog picks them up. - airtable_workspace — enumerates bases × tables via Meta API; asset per table; per-asset paginated records fetch; selector matches BaseName.TableName or just TableName. - github_workspace — enumerates repos × workflows in an org (or user); asset per (repo, workflow); per-asset reads recent workflow runs; two-level filtering via repo_selector + workflow_selector. - google_analytics_workspace — enumerates GA4 properties via Admin API; asset per property; per-asset runReport with configurable metrics + dimensions + rolling date range. Service-account JWT auth. - shopify_workspace — 14 static canonical resources (products / orders / customers / inventory_items / discount_codes / draft_orders / collections / checkouts / abandoned_checkouts / fulfillments / gift_cards / locations / price_rules / shipping_zones); one asset per selected resource per store. - stripe_workspace — 19 canonical resources including nested endpoints (checkout/sessions, billing_portal/sessions); one asset per selected resource. All use the shared per-vendor selector dataclass (Fivetran shape). Bumps version to 0.10.38.
1 parent 0a7c1a9 commit 7b0b61f

33 files changed

Lines changed: 1453 additions & 2 deletions

dagster_community_components/__init__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1036,6 +1036,11 @@
10361036
"JiraWorkspaceComponent": "integrations/jira_workspace/component.py",
10371037
"ServiceNowWorkspaceComponent": "integrations/servicenow_workspace/component.py",
10381038
"NotionWorkspaceComponent": "integrations/notion_workspace/component.py",
1039+
"AirtableWorkspaceComponent": "integrations/airtable_workspace/component.py",
1040+
"GitHubWorkspaceComponent": "integrations/github_workspace/component.py",
1041+
"GoogleAnalyticsWorkspaceComponent": "integrations/google_analytics_workspace/component.py",
1042+
"ShopifyWorkspaceComponent": "integrations/shopify_workspace/component.py",
1043+
"StripeWorkspaceComponent": "integrations/stripe_workspace/component.py",
10391044
}
10401045

10411046
_loaded: dict[str, object] = {}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
# AirtableWorkspaceComponent
2+
3+
Auto-emit one Dagster asset per **Airtable table** by enumerating bases × tables via the Meta API. `StateBackedComponent` — discovery cached to disk. Materializing an asset reads records and emits a DataFrame with flattened fields.
4+
5+
The workspace-shape peer of `airtable_ingestion`.
6+
7+
## Example
8+
9+
```yaml
10+
type: dagster_community_components.AirtableWorkspaceComponent
11+
attributes:
12+
pat_env_var: AIRTABLE_PAT
13+
table_selector:
14+
by_pattern: ["*.Orders", "*.Customers"]
15+
defs_state:
16+
management_type: LOCAL_FILESYSTEM
17+
refresh_if_dev: true
18+
```
19+
20+
## Selector
21+
22+
Same Fivetran-shape as other workspaces. Match against **`BaseName.TableName`** OR just `TableName`:
23+
24+
```yaml
25+
table_selector:
26+
by_name: [MyBase.Orders] # exact fully qualified
27+
by_pattern: [*.Customers] # any base, table named Customers
28+
by_pattern: [SalesDB.*] # any table in SalesDB base
29+
```
30+
31+
## PAT scopes required
32+
33+
- `data.records:read` — read records from tables
34+
- `schema.bases:read` — enumerate bases and tables
35+
36+
Create the token at [airtable.com/create/tokens](https://airtable.com/create/tokens).
37+
38+
## Related
39+
40+
- `airtable_resource`
41+
- `airtable_ingestion` — single-table counterpart
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
from .component import AirtableTableSelector, AirtableWorkspaceComponent
2+
3+
__all__ = ["AirtableTableSelector", "AirtableWorkspaceComponent"]
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
"""Airtable Workspace Component.
2+
3+
Auto-enumerates Airtable bases × tables via the Meta API and emits one
4+
Dagster asset per table. Materializing an asset reads records via the
5+
Records API and emits a DataFrame with the flattened fields.
6+
"""
7+
import hashlib
8+
import json
9+
from dataclasses import dataclass, field
10+
from pathlib import Path
11+
from typing import Any, List, Optional
12+
13+
import dagster as dg
14+
15+
try:
16+
from dagster.components.component.state_backed_component import StateBackedComponent
17+
from dagster.components.utils.defs_state import (
18+
DefsStateConfig,
19+
DefsStateConfigArgs,
20+
ResolvedDefsStateConfig,
21+
)
22+
_HAS_STATE_BACKED = True
23+
except ImportError:
24+
StateBackedComponent = None # type: ignore
25+
DefsStateConfig = None # type: ignore
26+
DefsStateConfigArgs = None # type: ignore
27+
ResolvedDefsStateConfig = Any # type: ignore
28+
_HAS_STATE_BACKED = False
29+
30+
31+
@dataclass
32+
class AirtableTableSelector(dg.Resolvable):
33+
"""Selector for filtering Airtable tables. Fivetran-shape.
34+
35+
Matches on `<base_name>.<table_name>` OR just `<table_name>` depending on
36+
whether patterns include a dot.
37+
"""
38+
by_name: Optional[List[str]] = None
39+
by_pattern: Optional[List[str]] = None
40+
exclude_by_name: Optional[List[str]] = None
41+
exclude_by_pattern: Optional[List[str]] = None
42+
43+
def matches(self, qualified_name: str) -> bool:
44+
"""qualified_name = 'BaseName.TableName' — selectors can match either."""
45+
import fnmatch
46+
table_only = qualified_name.split(".", 1)[-1] if "." in qualified_name else qualified_name
47+
candidates = [qualified_name, table_only]
48+
49+
if self.exclude_by_name and any(n in self.exclude_by_name for n in candidates):
50+
return False
51+
if self.exclude_by_pattern:
52+
for p in self.exclude_by_pattern:
53+
if any(fnmatch.fnmatch(n, p) for n in candidates):
54+
return False
55+
if not self.by_name and not self.by_pattern:
56+
return True
57+
if self.by_name and any(n in self.by_name for n in candidates):
58+
return True
59+
if self.by_pattern:
60+
for p in self.by_pattern:
61+
if any(fnmatch.fnmatch(n, p) for n in candidates):
62+
return True
63+
return False
64+
65+
66+
def _enumerate_airtable(pat, verify_ssl) -> dict:
67+
import requests
68+
session = requests.Session()
69+
session.verify = verify_ssl
70+
headers = {"Authorization": f"Bearer {pat}", "Accept": "application/json"}
71+
72+
out: dict = {"bases": []}
73+
try:
74+
r = session.get("https://api.airtable.com/v0/meta/bases", headers=headers, timeout=60)
75+
r.raise_for_status()
76+
body = r.json() or {}
77+
for b in body.get("bases", []):
78+
base_id = b.get("id")
79+
base_name = b.get("name") or base_id
80+
if not base_id:
81+
continue
82+
tables: list = []
83+
try:
84+
tr = session.get(
85+
f"https://api.airtable.com/v0/meta/bases/{base_id}/tables",
86+
headers=headers, timeout=30,
87+
)
88+
tr.raise_for_status()
89+
tbody = tr.json() or {}
90+
for t in tbody.get("tables", []):
91+
tid = t.get("id")
92+
tname = t.get("name") or tid
93+
if tid and tname:
94+
tables.append({"id": tid, "name": tname})
95+
except Exception: # noqa: BLE001
96+
pass
97+
out["bases"].append({"id": base_id, "name": base_name, "tables": tables})
98+
except Exception: # noqa: BLE001
99+
pass
100+
return out
101+
102+
103+
if _HAS_STATE_BACKED:
104+
105+
@dataclass
106+
class AirtableWorkspaceComponent(StateBackedComponent, dg.Resolvable):
107+
pat_env_var: str # Airtable personal access token
108+
verify_ssl: bool = True
109+
110+
table_selector: Optional[AirtableTableSelector] = None
111+
page_size: int = 100
112+
113+
group_name: Optional[str] = None
114+
asset_key_prefix: List[str] = field(default_factory=lambda: ["airtable"])
115+
compute_kind: str = "airtable"
116+
117+
defs_state: Optional[ResolvedDefsStateConfig] = None
118+
119+
@property
120+
def defs_state_config(self) -> "DefsStateConfig":
121+
_key = f"AirtableWorkspace[{hashlib.sha256(self.pat_env_var.encode()).hexdigest()[:12]}]"
122+
return DefsStateConfig.from_args(self.defs_state, default_key=_key)
123+
124+
def write_state_to_path(self, state_path: Path) -> None:
125+
import os
126+
pat = os.environ.get(self.pat_env_var, "")
127+
snapshot = _enumerate_airtable(pat, self.verify_ssl)
128+
if self.table_selector is not None:
129+
for b in snapshot["bases"]:
130+
b["tables"] = [
131+
t for t in b["tables"]
132+
if self.table_selector.matches(f"{b['name']}.{t['name']}")
133+
]
134+
state_path.write_text(json.dumps(snapshot, indent=2))
135+
136+
def build_defs_from_state(
137+
self, context: dg.ComponentLoadContext, state_path: Optional[Path],
138+
) -> dg.Definitions:
139+
if state_path is None or not state_path.exists():
140+
return dg.Definitions()
141+
state = json.loads(state_path.read_text())
142+
assets = []
143+
for b in state.get("bases", []):
144+
for t in b.get("tables", []):
145+
assets.append(self._build_asset(b["id"], b["name"], t["id"], t["name"]))
146+
return dg.Definitions(assets=assets)
147+
148+
def _build_asset(self, base_id: str, base_name: str, table_id: str, table_name: str):
149+
_self = self
150+
safe_base = "".join(c if c.isalnum() or c == "_" else "_" for c in base_name)[:40] or "base"
151+
safe_table = "".join(c if c.isalnum() or c == "_" else "_" for c in table_name)[:40] or "table"
152+
key = dg.AssetKey([*self.asset_key_prefix, safe_base, safe_table])
153+
154+
@dg.asset(
155+
key=key, group_name=self.group_name, compute_kind=self.compute_kind,
156+
metadata={
157+
"airtable_base_id": dg.MetadataValue.text(base_id),
158+
"airtable_base_name": dg.MetadataValue.text(base_name),
159+
"airtable_table_id": dg.MetadataValue.text(table_id),
160+
"airtable_table_name": dg.MetadataValue.text(table_name),
161+
},
162+
)
163+
def _asset(context: dg.AssetExecutionContext):
164+
import os
165+
try:
166+
import pandas as pd
167+
import requests
168+
except ImportError as e:
169+
raise Exception("pandas or requests library not installed") from e
170+
171+
pat = os.environ.get(_self.pat_env_var, "")
172+
session = requests.Session()
173+
session.verify = _self.verify_ssl
174+
headers = {"Authorization": f"Bearer {pat}", "Accept": "application/json"}
175+
176+
url = f"https://api.airtable.com/v0/{base_id}/{table_id}"
177+
r = session.get(url, headers=headers, params={"pageSize": _self.page_size}, timeout=120)
178+
r.raise_for_status()
179+
body = r.json() or {}
180+
records = body.get("records", [])
181+
rows = []
182+
for rec in records:
183+
row = {"id": rec.get("id"), "createdTime": rec.get("createdTime")}
184+
row.update(rec.get("fields") or {})
185+
rows.append(row)
186+
df = pd.DataFrame(rows)
187+
context.add_output_metadata({
188+
"row_count": len(df),
189+
"airtable_table": f"{base_name}.{table_name}",
190+
})
191+
return df
192+
193+
return _asset
194+
195+
else:
196+
class AirtableWorkspaceComponent: # type: ignore
197+
def __init__(self, *args, **kwargs):
198+
raise ImportError("AirtableWorkspaceComponent requires Dagster with StateBackedComponent support.")
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
type: dagster_community_components.AirtableWorkspaceComponent
2+
attributes:
3+
pat_env_var: AIRTABLE_PAT # Personal Access Token from airtable.com/create/tokens
4+
5+
table_selector:
6+
by_pattern: ["*.Orders", "*.Customers"] # qualified as BaseName.TableName
7+
# by_name: [MyBase.Orders] # exact matches also OK
8+
# exclude_by_pattern: ["*.Archive*"]
9+
10+
page_size: 100
11+
group_name: airtable_data
12+
asset_key_prefix: [airtable]
13+
14+
defs_state:
15+
management_type: LOCAL_FILESYSTEM
16+
refresh_if_dev: true
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
pandas>=2.0.0
2+
requests>=2.28.0
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
{
2+
"component_type": "dagster_community_components.AirtableWorkspaceComponent",
3+
"description": "Auto-emit one Dagster asset per Airtable table by enumerating bases × tables via the Meta API. StateBackedComponent — discovery cached to disk. Materializing an asset reads records via the Records API and emits a DataFrame with flattened fields.",
4+
"category": "ingestion",
5+
"icon": "AccountTree",
6+
"attributes": {
7+
"pat_env_var": {"type": "string", "label": "Personal Access Token Env Var", "required": true, "description": "Env var with an Airtable Personal Access Token (create at airtable.com/create/tokens with data.records:read + schema.bases:read scopes)."},
8+
"verify_ssl": {"type": "boolean", "label": "Verify SSL", "required": false, "default": true, "ui:widget": "checkbox"},
9+
"table_selector": {"type": "object", "label": "Table Selector", "required": false, "description": "Fivetran-shape selector matching against 'BaseName.TableName' (or just TableName)."},
10+
"page_size": {"type": "integer", "label": "Page Size", "required": false, "default": 100, "description": "Records per page (Airtable caps at 100)."},
11+
"group_name": {"type": "string", "label": "Group Name", "required": false},
12+
"asset_key_prefix": {"type": "array", "label": "Asset Key Prefix", "required": false, "default": ["airtable"]},
13+
"compute_kind": {"type": "string", "label": "Compute Kind", "required": false, "default": "airtable"},
14+
"defs_state": {"type": "object", "label": "Defs State Config", "required": false}
15+
}
16+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
# GitHubWorkspaceComponent
2+
3+
Auto-emit one Dagster asset per **GitHub Actions workflow** across every repo in an org (or user). `StateBackedComponent` — discovery cached to disk. Materializing an asset reads the most recent workflow runs and emits a DataFrame.
4+
5+
The workspace-shape peer of `github_ingestion` / `github_event_sensor`.
6+
7+
## Example
8+
9+
```yaml
10+
type: dagster_community_components.GitHubWorkspaceComponent
11+
attributes:
12+
owner_env_var: GITHUB_OWNER
13+
token_env_var: GITHUB_TOKEN
14+
repo_selector:
15+
by_pattern: ["dagster-*", "*-pipelines"]
16+
workflow_selector:
17+
by_pattern: ["CI*", "Deploy*"]
18+
recent_runs_limit: 20
19+
defs_state:
20+
management_type: LOCAL_FILESYSTEM
21+
refresh_if_dev: true
22+
```
23+
24+
## Two-level filtering
25+
26+
- `repo_selector` — narrow which repos are enumerated
27+
- `workflow_selector` — narrow which workflows within those repos become assets
28+
29+
Both are Fivetran-shape (by_name / by_pattern / exclude_*).
30+
31+
## Emitted DataFrame schema
32+
33+
Each asset materialization returns:
34+
- `id`, `run_number`, `status`, `conclusion`, `event`, `head_branch`, `actor`, `created_at`, `updated_at`
35+
36+
## Related
37+
38+
- `github_resource`
39+
- `github_ingestion` — REST endpoint ingestion (issues/PRs/commits)
40+
- `github_event_sensor` — event-driven trigger on repo events
41+
- `github_audit_log_ingestion` — org audit log
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
from .component import GitHubRepoSelector, GitHubWorkspaceComponent
2+
3+
__all__ = ["GitHubRepoSelector", "GitHubWorkspaceComponent"]

0 commit comments

Comments
 (0)