@1aboveio/skills 1.0.0 → 1.0.2
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.
- package/README.md +24 -7
- package/bin/select-core.mjs +182 -50
- package/extensions/hide-copies.ts +21 -0
- package/package.json +115 -3
- package/skills/app-dev/airflow/SKILL.md +1 -1
- package/skills/app-dev/android-foundation/SKILL.md +1 -1
- package/skills/app-dev/app-debug/SKILL.md +1 -1
- package/skills/app-dev/better-auth/SKILL.md +1 -1
- package/skills/app-dev/data-catalog/SKILL.md +10 -6
- package/skills/app-dev/data-catalog/references/data-catalog-conventions.md +5 -3
- package/skills/app-dev/data-catalog/references/field-definitions.md +15 -7
- package/skills/app-dev/data-catalog/references/format.md +21 -8
- package/skills/app-dev/data-catalog/scripts/data_catalog.py +87 -32
- package/skills/app-dev/data-catalog/scripts/test_data_catalog.py +58 -11
- package/skills/app-dev/data-quality/LICENSE +3 -0
- package/skills/app-dev/data-quality/SKILL.md +96 -0
- package/skills/app-dev/data-quality/agents/openai.yaml +3 -0
- package/skills/app-dev/data-quality/references/checks.md +56 -0
- package/skills/app-dev/data-quality/release-check.mjs +93 -0
- package/skills/app-dev/data-quality/scripts/data-quality.test.mjs +12 -0
- package/skills/app-dev/data-quality/scripts/data_quality.py +405 -0
- package/skills/app-dev/data-quality/scripts/test_data_quality.py +277 -0
- package/skills/app-dev/ios-foundation/SKILL.md +1 -1
- package/skills/app-dev/monorepo/SKILL.md +1 -1
- package/skills/app-dev/nextjs-fullstack/SKILL.md +1 -1
- package/skills/app-dev/prisma-setup/SKILL.md +1 -1
- package/skills/app-dev/pyspark/SKILL.md +1 -1
- package/skills/app-dev/python-backend/SKILL.md +1 -1
- package/skills/app-dev/shadcn/SKILL.md +1 -1
- package/skills/app-dev/zod-v4/SKILL.md +1 -1
- package/skills/cicd/cloud-build/SKILL.md +1 -1
- package/skills/cicd/cloud-debug/SKILL.md +1 -1
- package/skills/cicd/cloud-deploy/SKILL.md +1 -1
- package/skills/cicd/docker/SKILL.md +1 -1
- package/skills/cicd/github-actions/SKILL.md +1 -1
- package/skills/cicd/github-flow/SKILL.md +1 -1
- package/skills/cicd/google-cloud/SKILL.md +1 -1
- package/skills/cicd/mergify/SKILL.md +1 -1
- package/skills/cicd/podman/SKILL.md +1 -1
- package/skills/content/article-review/SKILL.md +1 -1
- package/skills/content/article-to-image/SKILL.md +1 -1
- package/skills/content/flowchart-svg/SKILL.md +1 -1
- package/skills/content/image-gen/SKILL.md +1 -1
- package/skills/content/mermaid/SKILL.md +1 -1
- package/skills/content/wechat-article-writer/SKILL.md +1 -1
- package/skills/content/wechat-publisher/SKILL.md +1 -1
- package/skills/content/wechat-search/SKILL.md +1 -1
- package/skills/content/xai-search/SKILL.md +1 -1
- package/skills/core/distill-writing/SKILL.md +1 -1
- package/skills/core/doc-to-md/SKILL.md +1 -1
- package/skills/core/editorial-review/SKILL.md +1 -1
- package/skills/core/house-style/SKILL.md +1 -1
- package/skills/core/plain-language/LICENSE +3 -0
- package/skills/core/plain-language/SKILL.md +57 -0
- package/skills/core/plain-language/release-check.mjs +93 -0
- package/skills/core/polish-cn/SKILL.md +1 -1
- package/skills/core/polish-cn/references/models.md +1 -1
- package/skills/core/reg-parser/SKILL.md +1 -1
- package/skills/core/terminology/SKILL.md +1 -1
- package/skills/core/write-doc/SKILL.md +1 -1
- package/skills/engineering/better-review/SKILL.md +1 -1
- package/skills/engineering/e2e-test/SKILL.md +1 -1
- package/skills/engineering/engineering-runtime/SKILL.md +1 -1
- package/skills/engineering/engineering-runtime/coherence/workflow.json +12 -12
- package/skills/engineering/ensure-coverage/SKILL.md +1 -1
- package/skills/engineering/golden-path/SKILL.md +1 -1
- package/skills/engineering/harness-runtime/SKILL.md +1 -1
- package/skills/engineering/harness-runtime/model-catalog-seed.test.mjs +4 -4
- package/skills/engineering/harness-runtime/references/model-catalog-seed.json +4 -4
- package/skills/engineering/resolve-issues/SKILL.md +1 -1
- package/skills/engineering/resolve-issues/scripts/preflight-models.test.mjs +1 -1
- package/skills/engineering/resolve-release/SKILL.md +1 -1
- package/skills/engineering/rush-issues/SKILL.md +1 -1
- package/skills/engineering/rush-release/SKILL.md +1 -1
- package/skills/engineering/test-quality/SKILL.md +1 -1
- package/skills/risk-compliance/compliance-audit/SKILL.md +1 -1
- package/skills/risk-compliance/contract-compare/SKILL.md +1 -1
- package/skills/risk-compliance/contract-review/SKILL.md +1 -1
- package/skills/risk-compliance/doc-authenticity/SKILL.md +1 -1
- package/skills/risk-compliance/fraud-analysis/SKILL.md +1 -1
- package/skills/risk-compliance/payment-analysis/SKILL.md +1 -1
- package/skills/risk-compliance/report-writing/SKILL.md +1 -1
- package/skills/risk-compliance/similar-domain/SKILL.md +1 -1
- package/skills/risk-compliance/tld-detection/SKILL.md +1 -1
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: data-catalog
|
|
3
3
|
description: "Column definitions for warehouse tables, written inside the Spark/PySpark or SQL script that builds each table (CATALOG_TABLE / CATALOG_FIELDS, or a `-- @field` comment block), with a generated markdown catalog in docs/catalog/ and a CI check that fails a PR whose catalog is stale. Use when writing or changing a table script that adds, renames, removes, or changes the meaning of an output column or feature; when asked what a warehouse column or feature means; to set a repo up ('init the data catalog', 'generate the data dictionary', '字段字典', 'add catalog CI'); or to audit existing SQL or Spark scripts for undocumented columns. NOT for implementing or tuning the Spark job itself (pyspark), DAG wiring (airflow), or general workflow YAML (github-actions)."
|
|
4
|
-
release-version: 0.
|
|
4
|
+
release-version: 0.39.0
|
|
5
5
|
---
|
|
6
6
|
**Updates:** Once/session run `node <skill-base-dir>/release-check.mjs`.
|
|
7
7
|
# Data catalog
|
|
@@ -42,6 +42,7 @@ Never document tables during setup unless the task asks for it.
|
|
|
42
42
|
| CI says the catalog is stale | `python3 <engine> generate`, commit the pages |
|
|
43
43
|
| Before committing a table-script change, or reviewing one | `python3 <engine> drift` (`--base origin/main` for a whole branch) |
|
|
44
44
|
| `Field`/`Table` arguments, the SQL block, config keys, exit codes | [references/format.md](references/format.md) |
|
|
45
|
+
| Does the table's data match its definitions? | the data-quality skill |
|
|
45
46
|
|
|
46
47
|
`<engine>` is `scripts/data-catalog/data_catalog.py` in the repo, or
|
|
47
48
|
`<skill-base-dir>/scripts/data_catalog.py` before the repo has a copy. When
|
|
@@ -68,7 +69,8 @@ Never document tables during setup unless the task asks for it.
|
|
|
68
69
|
Example: adding `refund_cnt_30d` to the SELECT means adding its `Field` in the
|
|
69
70
|
same commit, then running `generate`.
|
|
70
71
|
2. **Each row says only what is particular to its column, in business words.**
|
|
71
|
-
Shared filters, window anchor and currency go once in the table notes
|
|
72
|
+
Shared filters, window anchor and currency go once in the table notes, in every
|
|
73
|
+
language; the row
|
|
72
74
|
gives what one value measures, its unit, and 0/null meaning; `source` gives
|
|
73
75
|
lineage in one line. Follow the eleven principles in
|
|
74
76
|
[references/field-definitions.md](references/field-definitions.md).
|
|
@@ -178,16 +180,18 @@ script is clean when its columns were `unknown`.
|
|
|
178
180
|
|
|
179
181
|
## Backfill one table
|
|
180
182
|
|
|
181
|
-
1. Pick the script that builds the table from the audit.
|
|
182
|
-
|
|
183
|
+
1. Pick the script that builds the table from the audit. Backfill, migration, and
|
|
184
|
+
copy scripts that write the same table share its definitions and need none of
|
|
185
|
+
their own.
|
|
183
186
|
2. Read the SQL or DataFrame code for every output column. Reuse an existing
|
|
184
187
|
hand-written dictionary as a draft, and check every row against the code.
|
|
185
188
|
3. Add `CATALOG_TABLE` and `CATALOG_FIELDS` in contract order. Keep the literal
|
|
186
189
|
contract while you write.
|
|
187
190
|
4. Run `generate` until it passes, then derive the contract and run the repo's
|
|
188
191
|
schema tests (rule 5).
|
|
189
|
-
5. Rerun the audit for that script
|
|
190
|
-
`thin-definition
|
|
192
|
+
5. Rerun the audit for that script and fix its writing findings (`repeated-text`,
|
|
193
|
+
`code-in-description`, `long-definition`, `thin-definition`,
|
|
194
|
+
`relative-definition`), or leave one only when the text really belongs in that row.
|
|
191
195
|
6. Commit the script and pages together. Replace the old hand-written dictionary
|
|
192
196
|
with a link to the generated page.
|
|
193
197
|
|
|
@@ -7,7 +7,8 @@ table. `docs/catalog/` is generated from those definitions; never edit it by han
|
|
|
7
7
|
|
|
8
8
|
**Where definitions live**
|
|
9
9
|
|
|
10
|
-
- Python: `CATALOG_TABLE = Table("schema.table",
|
|
10
|
+
- Python: `CATALOG_TABLE = Table("schema.table", <language>=..., grain_<language>=...,
|
|
11
|
+
notes_<language>=(...))` (grain and notes in every language) and
|
|
11
12
|
`CATALOG_FIELDS = (Field("column", <language>=..., source=..., window=..., unit=...), ...)`,
|
|
12
13
|
imported `from catalog_fields import Field, Table`. Use plain string literals.
|
|
13
14
|
- SQL: a comment block above the first statement: `-- @table schema.table`, then one
|
|
@@ -30,7 +31,7 @@ table. `docs/catalog/` is generated from those definitions; never edit it by han
|
|
|
30
31
|
|
|
31
32
|
1. Say each fact once, at the widest level where it is true. Filters, the window
|
|
32
33
|
anchor, currency, time zone, terms many columns use ("personal", "platform") and
|
|
33
|
-
list cleaning rules go in the table's
|
|
34
|
+
list cleaning rules go in the table's grain or notes; a row says only what
|
|
34
35
|
differs for that column.
|
|
35
36
|
2. The description is meaning in business words: what one value measures, its unit
|
|
36
37
|
and form (a ratio says 0–1 or percent; a time says date or timestamp and its
|
|
@@ -51,7 +52,8 @@ table. `docs/catalog/` is generated from those definitions; never edit it by han
|
|
|
51
52
|
collections.", not "0 when none".
|
|
52
53
|
9. Never restate the column name or write "same as above".
|
|
53
54
|
10. Cover every case: what the column holds for each kind of row, including when it
|
|
54
|
-
is empty; list an enumerated column's values
|
|
55
|
+
is empty; list an enumerated column's values in the text and in `values=(...)`;
|
|
56
|
+
say so if it is always empty today.
|
|
55
57
|
11. Write related columns as a set: a count and its amount, a value and its missing
|
|
56
58
|
flag, a legacy column and its replacement use the same scope and wording, and any
|
|
57
59
|
difference is stated in both rows.
|
|
@@ -8,7 +8,7 @@ column that says only what is particular to that column.
|
|
|
8
8
|
|
|
9
9
|
1. **Say each fact once, at the widest level where it is true.** A filter, window
|
|
10
10
|
anchor, currency rule, or row-inclusion rule that holds for every column goes in
|
|
11
|
-
the table's
|
|
11
|
+
the table's grain or notes, never in each row. So does the meaning of a term
|
|
12
12
|
many columns use ("personal", "platform", "own name") and how lists are cleaned
|
|
13
13
|
(case, sorting, empty items). A row carries only what differs for that column.
|
|
14
14
|
Bad (every row): `"Posted collections only, test customers excluded, [dt-7d, dt), excluding dt; ..."`
|
|
@@ -50,7 +50,8 @@ column that says only what is particular to that column.
|
|
|
50
50
|
`"同上,30 天窗口"` tell the reader nothing.
|
|
51
51
|
10. **Cover every case.** Say what the column holds for every kind of row (each
|
|
52
52
|
transaction type, each branch of the code), including when it is empty. List
|
|
53
|
-
the values an enumerated column can take, with a Chinese gloss for English codes
|
|
53
|
+
the values an enumerated column can take, with a Chinese gloss for English codes,
|
|
54
|
+
and put the same list in `values=(...)` so the data can be checked against it.
|
|
54
55
|
A column that is always empty or constant today says so.
|
|
55
56
|
Bad: `"换汇交易为 COMPLETED,内部转账为空。"` (collections and payouts unsaid)
|
|
56
57
|
Good: `"换汇交易为 COMPLETED;收款、付款和内部转账为空。"`
|
|
@@ -66,10 +67,10 @@ column that says only what is particular to that column.
|
|
|
66
67
|
|
|
67
68
|
| Fact | Goes in |
|
|
68
69
|
|---|---|
|
|
69
|
-
| What one row is, which rows exist | `Table
|
|
70
|
+
| What one row is, which rows exist | `Table` `grain_<lang>` |
|
|
70
71
|
| What the table is for, its main source | `Table` language text |
|
|
71
|
-
| Filters, window anchor, currency, time zone shared by all columns | `Table
|
|
72
|
-
| Meaning of a term many columns use, list cleaning rules | `Table
|
|
72
|
+
| Filters, window anchor, currency, time zone shared by all columns | `Table` `notes_<lang>` |
|
|
73
|
+
| Meaning of a term many columns use, list cleaning rules | `Table` `notes_<lang>` |
|
|
73
74
|
| What this column measures, its unit in words, 0/null meaning | `Field` language text |
|
|
74
75
|
| Upstream column, aggregation, filters only this column adds | `Field.source` |
|
|
75
76
|
| Exact interval / unit code | `Field.window` / `Field.unit` |
|
|
@@ -81,15 +82,22 @@ Table:
|
|
|
81
82
|
```python
|
|
82
83
|
CATALOG_TABLE = Table(
|
|
83
84
|
"dwd.customer_daily",
|
|
84
|
-
grain="One row per (dt, cust_id) for each customer with a posted collection before dt.",
|
|
85
85
|
en="Daily customer collection metrics for risk features.",
|
|
86
86
|
zh="客户每日收款指标,供风险特征使用。",
|
|
87
|
-
|
|
87
|
+
grain_en="One row per (dt, cust_id) for each customer with a posted collection before dt.",
|
|
88
|
+
grain_zh="每个 (dt, cust_id) 一行;只有 dt 之前有已入账收款的客户才有行。",
|
|
89
|
+
notes_en=(
|
|
88
90
|
"All metrics count posted collections (status 'POSTED') from non-test customers.",
|
|
89
91
|
"Windows end the day before dt: 'last 7 days' is [dt-7d, dt).",
|
|
90
92
|
"Amounts are converted to USD at the transaction date's rate; collections with no rate are left out of every metric.",
|
|
91
93
|
"Every metric is 0, not empty, if the customer had no collections in its window.",
|
|
92
94
|
),
|
|
95
|
+
notes_zh=(
|
|
96
|
+
"所有指标只计非测试客户的已入账(status 为 'POSTED')收款。",
|
|
97
|
+
"窗口截至 dt 前一天:「近 7 天」指 [dt-7d, dt),含 dt-7d,不含 dt。",
|
|
98
|
+
"金额按交易日汇率换算为美元;若某笔收款当天没有汇率,则所有指标都不计这笔收款。",
|
|
99
|
+
"若客户在窗口内没有收款,则指标为 0,而不是空值。",
|
|
100
|
+
),
|
|
93
101
|
)
|
|
94
102
|
```
|
|
95
103
|
|
|
@@ -7,13 +7,18 @@ from catalog_fields import Field, Table
|
|
|
7
7
|
|
|
8
8
|
CATALOG_TABLE = Table(
|
|
9
9
|
"dwd.customer_daily",
|
|
10
|
-
grain="One row per (dt, cust_id) for each customer with a posted transaction that day.",
|
|
11
10
|
en="Daily customer collection rollup used by risk features.",
|
|
12
11
|
zh="客户每日收款汇总,供风险特征使用。",
|
|
13
|
-
|
|
12
|
+
grain_en="One row per (dt, cust_id) for each customer with a posted transaction that day.",
|
|
13
|
+
grain_zh="每个 (dt, cust_id) 一行;只有当天有已入账交易的客户才有行。",
|
|
14
|
+
notes_en=(
|
|
14
15
|
"All metrics count posted collections (status 'POSTED').",
|
|
15
16
|
"Amounts are converted to USD at the transaction date's rate.",
|
|
16
17
|
),
|
|
18
|
+
notes_zh=(
|
|
19
|
+
"所有指标只计已入账(status 为 'POSTED')的收款。",
|
|
20
|
+
"金额按交易日汇率换算为美元。",
|
|
21
|
+
),
|
|
17
22
|
)
|
|
18
23
|
|
|
19
24
|
KEYS = (
|
|
@@ -36,8 +41,13 @@ CATALOG_FIELDS = (
|
|
|
36
41
|
PUBLISHED_COLUMNS = tuple(f.name for f in CATALOG_FIELDS)
|
|
37
42
|
```
|
|
38
43
|
|
|
39
|
-
- `Field(name, <one keyword per language>, source, window=None, unit=None)`.
|
|
40
|
-
|
|
44
|
+
- `Field(name, <one keyword per language>, source, window=None, unit=None, values=())`.
|
|
45
|
+
`values` lists the values an enumerated column can take, as distinct strings
|
|
46
|
+
(`values=("是", "否", "无法判定")`); the data-quality skill checks the data against it.
|
|
47
|
+
Never use it on identifiers or free text.
|
|
48
|
+
- `Table(name, <one keyword per language>, grain_<lang>=..., notes_<lang>=(...))`: a
|
|
49
|
+
grain for every language, and the same number of notes in every language (notes are
|
|
50
|
+
optional).
|
|
41
51
|
- Pass only the name positionally; everything else by keyword.
|
|
42
52
|
- The language keywords come from `languages` in `data-catalog.json`. `init`
|
|
43
53
|
regenerates `catalog_fields.py` with exactly those, so a missing language is a
|
|
@@ -49,10 +59,12 @@ PUBLISHED_COLUMNS = tuple(f.name for f in CATALOG_FIELDS)
|
|
|
49
59
|
|
|
50
60
|
```sql
|
|
51
61
|
-- @table ads.merchant_monthly
|
|
52
|
-
--
|
|
62
|
+
-- grain_en: One row per (month, merchant_id).
|
|
63
|
+
-- grain_zh: 每个 (month, merchant_id) 一行。
|
|
53
64
|
-- en: Monthly settled volume per merchant.
|
|
54
65
|
-- zh: 商户月度结算金额。
|
|
55
|
-
--
|
|
66
|
+
-- notes_en: Built on the 1st of each month; Counts settled rows (status 'SETTLED') only; Excludes test merchants
|
|
67
|
+
-- notes_zh: 每月 1 日构建;只计已结算(status 为 'SETTLED')的记录;不含测试商户
|
|
56
68
|
-- @field merchant_id
|
|
57
69
|
-- en: Merchant that settled the volume; key of dim_merchant.
|
|
58
70
|
-- zh: 结算该金额的商户,dim_merchant 主键。
|
|
@@ -68,9 +80,10 @@ SELECT ...
|
|
|
68
80
|
```
|
|
69
81
|
|
|
70
82
|
- A directive line is `-- @table <name>` or `-- @field <name>`.
|
|
71
|
-
- A key line is exactly two spaces after `-- `, then `key: value`.
|
|
83
|
+
- A key line is exactly two spaces after `-- `, then `key: value`. `values:` takes a
|
|
84
|
+
list split on `;` or `;`.
|
|
72
85
|
- A continuation line has four or more spaces and joins the previous key with a space.
|
|
73
|
-
- `
|
|
86
|
+
- `notes_<lang>` on `@table` splits on `;` or `;`.
|
|
74
87
|
- The block ends at the first non-comment line. One `@table` per file.
|
|
75
88
|
|
|
76
89
|
## data-catalog.json
|
|
@@ -49,7 +49,7 @@ DEFAULT_CONFIG = {
|
|
|
49
49
|
"tableNames": [],
|
|
50
50
|
"schemaNames": [],
|
|
51
51
|
}
|
|
52
|
-
FIELD_EXTRAS = ("source", "window", "unit")
|
|
52
|
+
FIELD_EXTRAS = ("source", "window", "unit", "values")
|
|
53
53
|
LANGUAGE_LABELS = {"en": "English", "zh": "中文", "zh_hant": "繁體中文", "ja": "日本語", "ko": "한국어"}
|
|
54
54
|
LANGUAGE_RE = re.compile(r"^[a-z]{2}(_[a-z]+)?$")
|
|
55
55
|
SKIP_DIRS = {"node_modules", "__pycache__", "venv"}
|
|
@@ -151,15 +151,16 @@ class FieldDoc:
|
|
|
151
151
|
window: str | None
|
|
152
152
|
unit: str | None
|
|
153
153
|
line: int
|
|
154
|
+
values: tuple = () # the values an enumerated column can take; checked by data-quality
|
|
154
155
|
|
|
155
156
|
|
|
156
157
|
@dataclass
|
|
157
158
|
class TableDoc:
|
|
158
159
|
name: str
|
|
159
160
|
script: str
|
|
160
|
-
grain:
|
|
161
|
+
grain: dict # language -> what one row is
|
|
161
162
|
text: dict
|
|
162
|
-
notes:
|
|
163
|
+
notes: dict # language -> [shared rules]
|
|
163
164
|
fields: list
|
|
164
165
|
line: int
|
|
165
166
|
contracts: dict = field(default_factory=dict) # name -> list[str] | "derived" | "unchecked"
|
|
@@ -258,26 +259,66 @@ def build_field(values: dict, languages: list, where: str, line: int) -> FieldDo
|
|
|
258
259
|
f"languages configured: {languages}")
|
|
259
260
|
text = {lang: require_text(values, lang, where, line, f"Field {name!r}") for lang in languages}
|
|
260
261
|
source = require_text(values, "source", where, line, f"Field {name!r}")
|
|
261
|
-
|
|
262
|
+
allowed = values.get("values") or ()
|
|
263
|
+
if isinstance(allowed, str):
|
|
264
|
+
allowed = tuple(v.strip() for v in re.split(r"[;;]", allowed))
|
|
265
|
+
if not isinstance(allowed, tuple) or not all(isinstance(v, str) and v != "" for v in allowed):
|
|
266
|
+
raise CatalogError(f"{where}:{line}: Field {name!r}: values must be a tuple of non-empty strings")
|
|
267
|
+
if len(set(allowed)) != len(allowed):
|
|
268
|
+
raise CatalogError(f"{where}:{line}: Field {name!r}: values lists a value twice")
|
|
269
|
+
return FieldDoc(name, text, source, (values.get("window") or None), (values.get("unit") or None), line, allowed)
|
|
262
270
|
|
|
263
271
|
|
|
264
272
|
def build_table(values: dict, fields: list, languages: list, where: str, line: int, script: str) -> TableDoc:
|
|
265
273
|
name = require_text(values, "name", where, line, "Table")
|
|
266
|
-
|
|
274
|
+
if "grain" in values or "notes" in values:
|
|
275
|
+
keys = ", ".join(f"grain_{lang}=..., notes_{lang}=(...)" for lang in languages)
|
|
276
|
+
raise CatalogError(f"{where}:{line}: Table {name!r}: grain and notes are written per language: {keys}")
|
|
277
|
+
known = {"name", *languages, *(f"grain_{lang}" for lang in languages), *(f"notes_{lang}" for lang in languages)}
|
|
267
278
|
unknown = sorted(set(values) - known)
|
|
268
279
|
if unknown:
|
|
269
280
|
raise CatalogError(f"{where}:{line}: Table {name!r} has unknown argument(s) {unknown}")
|
|
270
|
-
grain = require_text(values, "grain", where, line, f"Table {name!r}")
|
|
271
281
|
text = {lang: require_text(values, lang, where, line, f"Table {name!r}") for lang in languages}
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
282
|
+
grain = {lang: require_text(values, f"grain_{lang}", where, line, f"Table {name!r}") for lang in languages}
|
|
283
|
+
notes = {}
|
|
284
|
+
for lang in languages:
|
|
285
|
+
raw = values.get(f"notes_{lang}") or ()
|
|
286
|
+
raw = (raw,) if isinstance(raw, str) else raw
|
|
287
|
+
notes[lang] = [n.strip() for n in raw if n and n.strip()]
|
|
288
|
+
counts = {lang: len(n) for lang, n in notes.items()}
|
|
289
|
+
if len(set(counts.values())) > 1:
|
|
290
|
+
raise CatalogError(f"{where}:{line}: Table {name!r}: every language needs the same notes, got {counts}")
|
|
291
|
+
return TableDoc(name, script, grain, text, notes, fields, line)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def struct_fields(node: ast.AST, names: dict):
|
|
295
|
+
"""Column names of a literal StructType([StructField("name", ...), ...]), or None."""
|
|
296
|
+
if call_name(node) != "StructType" or not node.args:
|
|
297
|
+
return None
|
|
298
|
+
try:
|
|
299
|
+
items = resolve_sequence(node.args[0], names, "", frozenset())
|
|
300
|
+
except CatalogError:
|
|
301
|
+
return None
|
|
302
|
+
out = []
|
|
303
|
+
for item in items:
|
|
304
|
+
if call_name(item) != "StructField" or not item.args:
|
|
305
|
+
return None
|
|
306
|
+
try:
|
|
307
|
+
value = eval_text(item.args[0], names, "")
|
|
308
|
+
except CatalogError:
|
|
309
|
+
return None
|
|
310
|
+
if not isinstance(value, str):
|
|
311
|
+
return None
|
|
312
|
+
out.append(value)
|
|
313
|
+
return out
|
|
276
314
|
|
|
277
315
|
|
|
278
316
|
def contract_state(node: ast.AST, names: dict):
|
|
279
317
|
if any(isinstance(n, ast.Name) and n.id == FIELDS_VAR for n in ast.walk(node)):
|
|
280
318
|
return "derived"
|
|
319
|
+
columns = struct_fields(node, names)
|
|
320
|
+
if columns is not None:
|
|
321
|
+
return columns
|
|
281
322
|
try:
|
|
282
323
|
items = resolve_sequence(node, names, "", frozenset())
|
|
283
324
|
values = [eval_text(item, names, "") for item in items]
|
|
@@ -363,14 +404,12 @@ def parse_sql(text: str, where: str, languages: list) -> TableDoc | None:
|
|
|
363
404
|
fields = []
|
|
364
405
|
for kind, name, values, line, _ in entries:
|
|
365
406
|
if kind == "field":
|
|
366
|
-
if "notes" in values:
|
|
367
|
-
raise CatalogError(f"{where}:{line}: `
|
|
407
|
+
if any(k.startswith(("notes", "grain")) for k in values):
|
|
408
|
+
raise CatalogError(f"{where}:{line}: `grain_*` and `notes_*` belong on @table, not @field")
|
|
368
409
|
fields.append(build_field({"name": name, **values}, languages, where, line))
|
|
369
410
|
_, name, values, line, _ = tables[0]
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
if notes:
|
|
373
|
-
values["notes"] = tuple(n.strip() for n in notes.split(";"))
|
|
411
|
+
values = {"name": name, **{k: tuple(n.strip() for n in re.split(r"[;;]", v)) if k.startswith("notes_") else v
|
|
412
|
+
for k, v in values.items()}}
|
|
374
413
|
return build_table(values, fields, languages, where, line, where)
|
|
375
414
|
|
|
376
415
|
|
|
@@ -467,6 +506,9 @@ def cell(text: str | None) -> str:
|
|
|
467
506
|
return text.replace("\\", "\\\\").replace("|", "\\|").replace("\r\n", "\n").replace("\n", "<br>")
|
|
468
507
|
|
|
469
508
|
|
|
509
|
+
SECTION_LABELS = {"en": ("Grain:", "Notes:"), "zh": ("粒度:", "说明:"), "zh_hant": ("粒度:", "說明:")}
|
|
510
|
+
|
|
511
|
+
|
|
470
512
|
def label(lang: str) -> str:
|
|
471
513
|
return LANGUAGE_LABELS.get(lang, lang)
|
|
472
514
|
|
|
@@ -496,28 +538,33 @@ def render_table(table: TableDoc, root: Path, config: dict) -> str:
|
|
|
496
538
|
langs = config["languages"]
|
|
497
539
|
out = [GENERATED_MARK, "", f"# `{table.name}`", ""]
|
|
498
540
|
out.append(f"- **Script:** [{table.script}]({link_to(root, config['output'], table.script)})")
|
|
499
|
-
out.append(f"- **Grain:** {cell(table.grain)}")
|
|
500
541
|
out.append(f"- **Fields:** {len(table.fields)}")
|
|
501
542
|
out.append("")
|
|
502
543
|
for lang in langs:
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
out.
|
|
507
|
-
|
|
508
|
-
|
|
544
|
+
grain_label, notes_label = SECTION_LABELS.get(lang, SECTION_LABELS["en"])
|
|
545
|
+
if len(langs) > 1:
|
|
546
|
+
out += [f"## {label(lang)}", ""]
|
|
547
|
+
out += [cell(table.text[lang]), "", f"- **{grain_label}** {cell(table.grain[lang])}"]
|
|
548
|
+
if table.notes[lang]:
|
|
549
|
+
out.append(f"- **{notes_label}**")
|
|
550
|
+
out.extend(f" - {cell(note)}" for note in table.notes[lang])
|
|
509
551
|
out.append("")
|
|
552
|
+
if len(langs) > 1:
|
|
553
|
+
out += ["## Fields", ""]
|
|
510
554
|
has_window = any(f.window for f in table.fields)
|
|
511
555
|
has_unit = any(f.unit for f in table.fields)
|
|
556
|
+
has_values = any(f.values for f in table.fields)
|
|
512
557
|
head = ["Field", *(label(lang) for lang in langs), "Source"]
|
|
513
558
|
head += ["Window"] if has_window else []
|
|
514
559
|
head += ["Unit"] if has_unit else []
|
|
560
|
+
head += ["Values"] if has_values else []
|
|
515
561
|
out.append("| " + " | ".join(head) + " |")
|
|
516
562
|
out.append("|" + "---|" * len(head))
|
|
517
563
|
for f in table.fields:
|
|
518
564
|
row = [f"`{f.name}`", *(cell(f.text[lang]) for lang in langs), cell(f.source)]
|
|
519
565
|
row += [cell(f.window)] if has_window else []
|
|
520
566
|
row += [cell(f.unit)] if has_unit else []
|
|
567
|
+
row += [cell(", ".join(f"`{v}`" for v in f.values))] if has_values else []
|
|
521
568
|
out.append("| " + " | ".join(row) + " |")
|
|
522
569
|
return "\n".join(out) + "\n"
|
|
523
570
|
|
|
@@ -539,7 +586,7 @@ def render_index(tables: list, root: Path, config: dict) -> str:
|
|
|
539
586
|
out += ["| Table | Summary | Grain | Fields | Script |", "|---|---|---|---|---|"]
|
|
540
587
|
for t in tables:
|
|
541
588
|
script = f"[{t.script}]({link_to(root, config['output'], t.script)})"
|
|
542
|
-
out.append(f"| [`{t.name}`]({page_name(t)}) | {cell(t.text[lang])} | {cell(t.grain)} | {len(t.fields)} | {script} |")
|
|
589
|
+
out.append(f"| [`{t.name}`]({page_name(t)}) | {cell(t.text[lang])} | {cell(t.grain[lang])} | {len(t.fields)} | {script} |")
|
|
543
590
|
return "\n".join(out) + "\n"
|
|
544
591
|
|
|
545
592
|
|
|
@@ -1019,11 +1066,13 @@ def preview(items: list, limit: int = 12) -> str:
|
|
|
1019
1066
|
return shown + (f", … (+{len(items) - limit})" if len(items) > limit else "")
|
|
1020
1067
|
|
|
1021
1068
|
|
|
1022
|
-
def cmd_audit(root: Path, globs: list, as_json: bool, verbose: bool, table_names: list, schema_names: list
|
|
1069
|
+
def cmd_audit(root: Path, globs: list, as_json: bool, verbose: bool, table_names: list, schema_names: list,
|
|
1070
|
+
contracts: list) -> int:
|
|
1023
1071
|
config_path = root / CONFIG_FILE
|
|
1024
1072
|
config = load_config(root) if config_path.exists() else dict(DEFAULT_CONFIG)
|
|
1025
1073
|
config = {**config, "tableNames": [*config["tableNames"], *table_names],
|
|
1026
|
-
"schemaNames": [*config["schemaNames"], *schema_names]
|
|
1074
|
+
"schemaNames": [*config["schemaNames"], *schema_names],
|
|
1075
|
+
"contracts": [*config["contracts"], *contracts]}
|
|
1027
1076
|
if globs:
|
|
1028
1077
|
patterns, exclude = globs, config.get("exclude", [])
|
|
1029
1078
|
elif config_path.exists():
|
|
@@ -1137,8 +1186,9 @@ def code_without_metadata(text: str, suffix: str) -> str:
|
|
|
1137
1186
|
def definitions(table: TableDoc | None) -> tuple[dict, tuple]:
|
|
1138
1187
|
if table is None:
|
|
1139
1188
|
return {}, ()
|
|
1140
|
-
fields = {f.name: (tuple(sorted(f.text.items())), f.source, f.window, f.unit) for f in table.fields}
|
|
1141
|
-
|
|
1189
|
+
fields = {f.name: (tuple(sorted(f.text.items())), f.source, f.window, f.unit, f.values) for f in table.fields}
|
|
1190
|
+
notes = tuple((lang, tuple(n)) for lang, n in sorted(table.notes.items()))
|
|
1191
|
+
return fields, (tuple(sorted(table.grain.items())), tuple(sorted(table.text.items())), notes)
|
|
1142
1192
|
|
|
1143
1193
|
|
|
1144
1194
|
def drift_items(root: Path, config: dict, base: str) -> list:
|
|
@@ -1212,12 +1262,14 @@ def cmd_drift(root: Path, base: str) -> int:
|
|
|
1212
1262
|
|
|
1213
1263
|
def render_fields_module(languages: list) -> str:
|
|
1214
1264
|
lang_fields = "\n".join(f" {lang}: str" for lang in languages)
|
|
1265
|
+
grain_fields = "\n".join(f" grain_{lang}: str" for lang in languages)
|
|
1266
|
+
notes_fields = "\n".join(f" notes_{lang}: tuple[str, ...] = ()" for lang in languages)
|
|
1215
1267
|
return f'''"""Column metadata types for the data catalog.
|
|
1216
1268
|
|
|
1217
1269
|
Generated by data_catalog.py init from data-catalog.json; rerun init after changing
|
|
1218
1270
|
`languages` there, never edit this file. Table scripts declare:
|
|
1219
1271
|
|
|
1220
|
-
CATALOG_TABLE = Table("schema.table",
|
|
1272
|
+
CATALOG_TABLE = Table("schema.table", {", ".join(f"{lang}=..., grain_{lang}=..." for lang in languages)})
|
|
1221
1273
|
{FIELDS_VAR} = (Field("column", {", ".join(f"{lang}=..." for lang in languages)}, source=...), ...)
|
|
1222
1274
|
PUBLISHED_COLUMNS = tuple(f.name for f in {FIELDS_VAR})
|
|
1223
1275
|
"""
|
|
@@ -1233,14 +1285,15 @@ class Field:
|
|
|
1233
1285
|
source: str
|
|
1234
1286
|
window: str | None = None
|
|
1235
1287
|
unit: str | None = None
|
|
1288
|
+
values: tuple[str, ...] = ()
|
|
1236
1289
|
|
|
1237
1290
|
|
|
1238
1291
|
@dataclass(frozen=True)
|
|
1239
1292
|
class Table:
|
|
1240
1293
|
name: str
|
|
1241
|
-
grain: str
|
|
1242
1294
|
{lang_fields}
|
|
1243
|
-
|
|
1295
|
+
{grain_fields}
|
|
1296
|
+
{notes_fields}
|
|
1244
1297
|
'''
|
|
1245
1298
|
|
|
1246
1299
|
|
|
@@ -1367,6 +1420,8 @@ def main(argv: list | None = None) -> int:
|
|
|
1367
1420
|
help="extra variable/attribute whose string value is the table written; repeatable")
|
|
1368
1421
|
p_audit.add_argument("--schema-name", action="append", default=[], metavar="NAME",
|
|
1369
1422
|
help="extra variable/attribute holding that table's schema; repeatable")
|
|
1423
|
+
p_audit.add_argument("--contract", action="append", default=[], metavar="NAME",
|
|
1424
|
+
help="extra column-contract name (tuple of names or StructType); repeatable")
|
|
1370
1425
|
p_drift = sub.add_parser("drift", help="local check: code changed since a git base while its definitions did not")
|
|
1371
1426
|
p_drift.add_argument("--base", default="HEAD", help="git ref to compare with (default HEAD: uncommitted work)")
|
|
1372
1427
|
sub.add_parser("version", help="print the engine identity")
|
|
@@ -1382,7 +1437,7 @@ def main(argv: list | None = None) -> int:
|
|
|
1382
1437
|
return cmd_generate(root, args.check)
|
|
1383
1438
|
if args.command == "drift":
|
|
1384
1439
|
return cmd_drift(root, args.base)
|
|
1385
|
-
return cmd_audit(root, args.globs, args.json, args.verbose, args.table_name, args.schema_name)
|
|
1440
|
+
return cmd_audit(root, args.globs, args.json, args.verbose, args.table_name, args.schema_name, args.contract)
|
|
1386
1441
|
except CatalogError as exc:
|
|
1387
1442
|
print(f"data-catalog: {exc}", file=sys.stderr)
|
|
1388
1443
|
return 2
|
|
@@ -25,10 +25,12 @@ WINDOW_NOTE = "[dt-7d, dt)"
|
|
|
25
25
|
|
|
26
26
|
CATALOG_TABLE = Table(
|
|
27
27
|
"dwd.customer_daily",
|
|
28
|
-
grain="one row per (dt, cust_id)",
|
|
29
28
|
en="Daily customer collection rollup.",
|
|
30
29
|
zh="客户每日收款汇总。",
|
|
31
|
-
|
|
30
|
+
grain_en="one row per (dt, cust_id)",
|
|
31
|
+
grain_zh="每个 (dt, cust_id) 一行",
|
|
32
|
+
notes_en=("Amounts in USD via dim_forex.",),
|
|
33
|
+
notes_zh=("金额经 dim_forex 换算为 USD。",),
|
|
32
34
|
)
|
|
33
35
|
KEYS = (
|
|
34
36
|
Field("dt", en="Snapshot date.", zh="快照日期。", source="Run parameter `dt`."),
|
|
@@ -50,10 +52,12 @@ PUBLISHED_COLUMNS = tuple(f.name for f in CATALOG_FIELDS)
|
|
|
50
52
|
|
|
51
53
|
SQL_SCRIPT = """\
|
|
52
54
|
-- @table ads.merchant_monthly
|
|
53
|
-
-- grain: one row per (month, merchant_id)
|
|
54
55
|
-- en: Monthly merchant volume.
|
|
55
56
|
-- zh: 商户月度交易量。
|
|
56
|
-
--
|
|
57
|
+
-- grain_en: one row per (month, merchant_id)
|
|
58
|
+
-- grain_zh: 每个 (month, merchant_id) 一行
|
|
59
|
+
-- notes_en: Built monthly; Excludes test merchants
|
|
60
|
+
-- notes_zh: 每月构建;不含测试商户
|
|
57
61
|
-- @field merchant_id
|
|
58
62
|
-- en: Merchant id.
|
|
59
63
|
-- zh: 商户 ID。
|
|
@@ -111,12 +115,30 @@ class GenerateTest(unittest.TestCase):
|
|
|
111
115
|
self.assertIn("| `volume_usd` | Settled volume in the month. | 当月结算金额。 | "
|
|
112
116
|
"dwd_settlement settled rows, summed amount_usd | USD |", sql_page)
|
|
113
117
|
self.assertNotIn("Window", sql_page)
|
|
114
|
-
self.assertIn("- Excludes test merchants", sql_page)
|
|
118
|
+
self.assertIn(" - Excludes test merchants", sql_page)
|
|
119
|
+
self.assertIn("## 中文", sql_page)
|
|
120
|
+
self.assertIn("- **粒度:** 每个 (month, merchant_id) 一行", sql_page)
|
|
121
|
+
self.assertIn(" - 不含测试商户", sql_page)
|
|
115
122
|
index = (root / "docs/catalog/README.md").read_text(encoding="utf-8")
|
|
116
123
|
self.assertLess(index.index("ads.merchant_monthly"), index.index("dwd.customer_daily"))
|
|
117
124
|
self.assertIn("| [`dwd.customer_daily`](dwd.customer_daily.md) | Daily customer collection rollup. | "
|
|
118
125
|
"one row per (dt, cust_id) | 2 |", index)
|
|
119
126
|
|
|
127
|
+
def test_values_render_and_must_be_distinct_strings(self):
|
|
128
|
+
script = PY_SCRIPT.replace('source="Run parameter `dt`."),', 'source="Run parameter `dt`.", values=("a", "b")),')
|
|
129
|
+
root = make_repo({"jobs/a.py": script}, BILINGUAL)
|
|
130
|
+
self.assertEqual(run(root, "generate")[0], 0)
|
|
131
|
+
page = (root / "docs/catalog/dwd.customer_daily.md").read_text(encoding="utf-8")
|
|
132
|
+
self.assertIn("| Unit | Values |", page)
|
|
133
|
+
self.assertIn("| `a`, `b` |", page)
|
|
134
|
+
twice = script.replace('values=("a", "b")', 'values=("a", "a")')
|
|
135
|
+
root = make_repo({"jobs/a.py": twice}, BILINGUAL)
|
|
136
|
+
self.assertIn("values lists a value twice", run(root, "generate")[2])
|
|
137
|
+
sql = SQL_SCRIPT.replace("-- source: dim_merchant.merchant_id", "-- source: dim_merchant.merchant_id\n-- values: m1;m2")
|
|
138
|
+
root = make_repo({"sql/m.sql": sql}, BILINGUAL)
|
|
139
|
+
self.assertEqual(run(root, "generate")[0], 0)
|
|
140
|
+
self.assertIn("`m1`, `m2`", (root / "docs/catalog/ads.merchant_monthly.md").read_text(encoding="utf-8"))
|
|
141
|
+
|
|
120
142
|
def test_check_passes_when_current_and_fails_after_a_definition_changes(self):
|
|
121
143
|
root = make_repo({"jobs/dwd/customer_daily.py": PY_SCRIPT}, BILINGUAL)
|
|
122
144
|
self.assertEqual(run(root, "generate")[0], 0)
|
|
@@ -192,6 +214,18 @@ SELECT dt, SUM(amt) AS coll_amt_7d_usd, COUNT(*) AS coll_cnt_7d FROM dwd.txn GRO
|
|
|
192
214
|
root = make_repo({"jobs/a.py": imported}, BILINGUAL)
|
|
193
215
|
self.assertIn("imported lists cannot be read", run(root, "generate")[2])
|
|
194
216
|
|
|
217
|
+
def test_table_grain_and_notes_are_per_language_with_matching_notes(self):
|
|
218
|
+
old = PY_SCRIPT.replace('grain_en="one row per (dt, cust_id)",', 'grain="one row per (dt, cust_id)",')
|
|
219
|
+
root = make_repo({"jobs/a.py": old}, BILINGUAL)
|
|
220
|
+
code, _, err = run(root, "generate")
|
|
221
|
+
self.assertEqual(code, 1)
|
|
222
|
+
self.assertIn("grain and notes are written per language: grain_en=..., notes_en=(...), grain_zh=...", err)
|
|
223
|
+
uneven = PY_SCRIPT.replace('notes_zh=("金额经 dim_forex 换算为 USD。",),', 'notes_zh=(),')
|
|
224
|
+
root = make_repo({"jobs/a.py": uneven}, BILINGUAL)
|
|
225
|
+
code, _, err = run(root, "generate")
|
|
226
|
+
self.assertEqual(code, 1)
|
|
227
|
+
self.assertIn("every language needs the same notes, got {'en': 1, 'zh': 0}", err)
|
|
228
|
+
|
|
195
229
|
def test_same_table_in_two_scripts_is_an_error(self):
|
|
196
230
|
root = make_repo({"jobs/a.py": PY_SCRIPT, "jobs/b.py": PY_SCRIPT}, BILINGUAL)
|
|
197
231
|
code, _, err = run(root, "generate")
|
|
@@ -238,6 +272,18 @@ class AuditTest(unittest.TestCase):
|
|
|
238
272
|
"writes dwd.undocumented but carries no CATALOG_TABLE/CATALOG_FIELDS (or -- @table block); "
|
|
239
273
|
"also written by jobs/backfill_b.py (put the metadata in the script that builds the table)")
|
|
240
274
|
|
|
275
|
+
def test_a_structtype_contract_is_read_for_its_column_names(self):
|
|
276
|
+
script = '''
|
|
277
|
+
L1A_PUBLISHED_SCHEMA = StructType([
|
|
278
|
+
StructField("dt", DateType(), False),
|
|
279
|
+
StructField("cust_id", StringType(), False),
|
|
280
|
+
])
|
|
281
|
+
TARGET_TABLE = "dwd.flow"
|
|
282
|
+
'''
|
|
283
|
+
root = make_repo({"jobs/flow.py": script})
|
|
284
|
+
[entry] = json.loads(run(root, "audit", "--json", "--contract", "L1A_PUBLISHED_SCHEMA", "jobs/*.py")[1])["scripts"]
|
|
285
|
+
self.assertEqual((entry["columns"], entry["columnsFrom"]), (["dt", "cust_id"], "L1A_PUBLISHED_SCHEMA"))
|
|
286
|
+
|
|
241
287
|
def test_class_attributes_name_the_table_with_their_schema(self):
|
|
242
288
|
script = '''
|
|
243
289
|
class OrdersEtl(Etl):
|
|
@@ -311,7 +357,7 @@ class AuditTest(unittest.TestCase):
|
|
|
311
357
|
("c_amt", " ".join(["word"] * 31), "近 7 天收款总额。", "sum of amount"),
|
|
312
358
|
])
|
|
313
359
|
script = f'''
|
|
314
|
-
CATALOG_TABLE = Table("dwd.t",
|
|
360
|
+
CATALOG_TABLE = Table("dwd.t", en="Table.", zh="表。", grain_en="One row per id.", grain_zh="每个 id 一行。")
|
|
315
361
|
CATALOG_FIELDS = (
|
|
316
362
|
{rows}
|
|
317
363
|
)
|
|
@@ -329,8 +375,9 @@ CATALOG_FIELDS = (
|
|
|
329
375
|
|
|
330
376
|
def test_shared_vocabulary_and_plain_units_are_not_flagged(self):
|
|
331
377
|
script = '''
|
|
332
|
-
CATALOG_TABLE = Table("dwd.t",
|
|
333
|
-
|
|
378
|
+
CATALOG_TABLE = Table("dwd.t", en="Table.", zh="表。", grain_en="One row per id.", grain_zh="每个 id 一行。",
|
|
379
|
+
notes_en=("All metrics count posted collections.", "Every metric is 0 if there were no collections."),
|
|
380
|
+
notes_zh=("所有指标只计已入账收款。", "若窗口内无收款,则指标为 0。"))
|
|
334
381
|
CATALOG_FIELDS = (
|
|
335
382
|
Field("a", en="Number of collections in the last 7 days.", zh="近 7 天收款笔数。",
|
|
336
383
|
source="count of dwd.transaction rows", window="[dt-7d, dt)"),
|
|
@@ -390,9 +437,9 @@ class ReviewFixTest(unittest.TestCase):
|
|
|
390
437
|
self.assertIn("no column contract and no readable SELECT writing ads.merchant_monthly", err)
|
|
391
438
|
|
|
392
439
|
def test_tables_mapping_to_one_page_or_the_index_are_errors(self):
|
|
393
|
-
a = PY_SCRIPT.replace('"dwd.customer_daily",\n
|
|
394
|
-
b = PY_SCRIPT.replace('"dwd.customer_daily",\n
|
|
395
|
-
readme = PY_SCRIPT.replace('"dwd.customer_daily",\n
|
|
440
|
+
a = PY_SCRIPT.replace('"dwd.customer_daily",\n en=', '"ads.a_b",\n en=')
|
|
441
|
+
b = PY_SCRIPT.replace('"dwd.customer_daily",\n en=', '"ads.a b",\n en=')
|
|
442
|
+
readme = PY_SCRIPT.replace('"dwd.customer_daily",\n en=', '"README",\n en=')
|
|
396
443
|
root = make_repo({"jobs/a.py": a, "jobs/b.py": b, "jobs/c.py": readme}, BILINGUAL)
|
|
397
444
|
code, _, err = run(root, "generate")
|
|
398
445
|
self.assertEqual(code, 1)
|