preflight-kit 0.2.0__py3-none-any.whl
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.
- preflight_kit/__init__.py +0 -0
- preflight_kit/cli.py +72 -0
- preflight_kit/engine.py +62 -0
- preflight_kit/fixer.py +50 -0
- preflight_kit/header_registry.py +126 -0
- preflight_kit/json_dump.py +85 -0
- preflight_kit/loader.py +291 -0
- preflight_kit/models.py +66 -0
- preflight_kit/reporters.py +145 -0
- preflight_kit/rules/__init__.py +4 -0
- preflight_kit/rules/file_rules.py +210 -0
- preflight_kit/rules/row_rules.py +448 -0
- preflight_kit-0.2.0.dist-info/METADATA +124 -0
- preflight_kit-0.2.0.dist-info/RECORD +17 -0
- preflight_kit-0.2.0.dist-info/WHEEL +4 -0
- preflight_kit-0.2.0.dist-info/entry_points.txt +2 -0
- preflight_kit-0.2.0.dist-info/licenses/LICENSE +33 -0
|
File without changes
|
preflight_kit/cli.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import argparse
|
|
3
|
+
import csv
|
|
4
|
+
import os
|
|
5
|
+
|
|
6
|
+
from .loader import load_csv
|
|
7
|
+
from .engine import run_engine
|
|
8
|
+
from .fixer import apply_fixes
|
|
9
|
+
from .reporters import write_errors_csv, render_report_md
|
|
10
|
+
from .models import Severity, ImportIntent
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _write_fixed_csv(header, rows, path):
|
|
14
|
+
with open(path, "w", newline="", encoding="utf-8") as fh:
|
|
15
|
+
writer = csv.writer(fh)
|
|
16
|
+
writer.writerow(header)
|
|
17
|
+
writer.writerows(rows)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def main(argv: list[str] | None = None) -> int:
|
|
21
|
+
parser = argparse.ArgumentParser(prog="preflight")
|
|
22
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
23
|
+
check = sub.add_parser("check", help="Validate a Shopify product CSV")
|
|
24
|
+
check.add_argument("input")
|
|
25
|
+
check.add_argument("--out-dir", default="./out")
|
|
26
|
+
check.add_argument("--lang", choices=["ja", "en"], default="ja")
|
|
27
|
+
check.add_argument("--no-fix", action="store_true")
|
|
28
|
+
check.add_argument("--intent", choices=["new", "update", "mixed"], default="mixed")
|
|
29
|
+
args = parser.parse_args(argv)
|
|
30
|
+
|
|
31
|
+
intent = ImportIntent(args.intent)
|
|
32
|
+
load = load_csv(args.input)
|
|
33
|
+
findings = run_engine(load, intent)
|
|
34
|
+
|
|
35
|
+
os.makedirs(args.out_dir, exist_ok=True)
|
|
36
|
+
|
|
37
|
+
# fixer を errors.csv 書き込みより**先**に走らせる。apply_fixes は適用済み Finding を
|
|
38
|
+
# auto_fixed=True に mutate するため、errors.csv にその最終状態を反映させる(round-5 #1)。
|
|
39
|
+
# F01b(非 UTF-8)検出時は fixed_products.csv を出さない。loader は replace 文字で
|
|
40
|
+
# デコードしているため、そのまま書き出すと元データを破壊する(#7)。
|
|
41
|
+
# parse_skipped(15MB 超で行未 parse)時も出さない。rows=[] のまま書くと元 CSV を
|
|
42
|
+
# 空ファイルへ置き換えてしまい、ユーザーが F04a critical を見落として空 fixed を
|
|
43
|
+
# 使うとデータ破損になる(round-2 blocking)。F04a で import 不可なので fixed 不要。
|
|
44
|
+
non_utf8 = load.encoding == "unknown"
|
|
45
|
+
applied = []
|
|
46
|
+
if not args.no_fix and not load.blocked and not non_utf8 and not load.parse_skipped:
|
|
47
|
+
fix = apply_fixes(load, findings)
|
|
48
|
+
applied = fix.applied
|
|
49
|
+
_write_fixed_csv(
|
|
50
|
+
fix.header, fix.rows, os.path.join(args.out_dir, "fixed_products.csv")
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
# auto_fixed 状態が確定してから errors.csv を書く。
|
|
54
|
+
write_errors_csv(findings, os.path.join(args.out_dir, "errors.csv"))
|
|
55
|
+
|
|
56
|
+
group_count = len({r.product_group_id for r in load.rows if r.product_group_id})
|
|
57
|
+
report = render_report_md(
|
|
58
|
+
findings,
|
|
59
|
+
lang=args.lang,
|
|
60
|
+
scanned_rows=len(load.rows),
|
|
61
|
+
group_count=group_count,
|
|
62
|
+
applied=applied,
|
|
63
|
+
)
|
|
64
|
+
with open(os.path.join(args.out_dir, "report.md"), "w", encoding="utf-8") as fh:
|
|
65
|
+
fh.write(report)
|
|
66
|
+
|
|
67
|
+
has_critical = any(f.severity == Severity.CRITICAL for f in findings)
|
|
68
|
+
return 1 if has_critical else 0
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
if __name__ == "__main__":
|
|
72
|
+
raise SystemExit(main())
|
preflight_kit/engine.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from .loader import LoadResult
|
|
4
|
+
from .models import Finding, ImportIntent
|
|
5
|
+
from .header_registry import CANONICAL_TO_HEADERS
|
|
6
|
+
from .rules import ALL_RULES
|
|
7
|
+
from .rules.file_rules import rule_f04a
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _build_field_display_map(load: LoadResult) -> dict[str, str]:
|
|
11
|
+
"""ルールがハードコードする primary 列名 -> その CSV で実際に使われた元列名。
|
|
12
|
+
|
|
13
|
+
spec: errors.csv の `field` は「元の列名で表示」する。ルールは primary 名
|
|
14
|
+
(例 `URL handle`/`SKU`)を field に渡すため、旧 alias 入力(例 `Handle`/
|
|
15
|
+
`Variant SKU`)では実在しない列名が出てしまう(round-1 non-blocking)。
|
|
16
|
+
canonical key 経由で逆引きし、実入力の列名へ置換するマップを作る。
|
|
17
|
+
"""
|
|
18
|
+
# canonical key -> 実際の元列名。同一 canonical key に複数の元列名がある場合
|
|
19
|
+
# (例 `Handle` と `URL handle` が両方 handle に対応)、loader の row.canonical は
|
|
20
|
+
# header 順の dict 内包で構築され **後勝ち**(最後に現れた列の値が残る)。よって
|
|
21
|
+
# ルールが実際に参照している値は最後の列のものなので、display も最後の列名を採用し
|
|
22
|
+
# 整合させる(setdefault=最初優先だと値とラベルがずれる・round-2 non-blocking)。
|
|
23
|
+
key_to_original: dict[str, str] = {}
|
|
24
|
+
for original_col, key in load.canonical_map.items():
|
|
25
|
+
key_to_original[key] = original_col # 後勝ち(row.canonical と一致)
|
|
26
|
+
# primary 名(ルールが渡す値・CANONICAL_TO_HEADERS 先頭) -> 実際の元列名。
|
|
27
|
+
# primary == 実列名のとき(新形式入力)は置換不要なので除く。
|
|
28
|
+
display: dict[str, str] = {}
|
|
29
|
+
for key, original in key_to_original.items():
|
|
30
|
+
primary = CANONICAL_TO_HEADERS.get(key, [None])[0]
|
|
31
|
+
if primary is not None and primary != original:
|
|
32
|
+
display[primary] = original
|
|
33
|
+
return display
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def run_engine(load: LoadResult, intent: ImportIntent) -> list[Finding]:
|
|
37
|
+
findings: list[Finding] = list(load.file_findings)
|
|
38
|
+
if load.blocked:
|
|
39
|
+
# PII ガード等で停止。値・列の中身を見るルールは走らせない。
|
|
40
|
+
# ただし F04a(15MB hard limit)は raw_byte_size だけで決まり、import 可否に
|
|
41
|
+
# 直結する critical なので、PII 停止時でも必ず出す(round-3 blocking: 15MB 超 +
|
|
42
|
+
# PII で F04a が false negative になっていた)。F04a は header/rows を参照しない
|
|
43
|
+
# ため blocked 状態でも安全に評価できる。
|
|
44
|
+
findings.extend(rule_f04a(load, intent))
|
|
45
|
+
return findings
|
|
46
|
+
# parse_skipped でヘッダーすら読めなかった(巨大セルで csv.Error)場合、列構造が
|
|
47
|
+
# 不明なため F03c(必須列欠落)は判定不能。これを通常実行すると header=[] を根拠に
|
|
48
|
+
# title/handle 欠落を誤発火する(round-3 non-blocking)。F04a critical に委ね、
|
|
49
|
+
# 列構造が読めていない F03c はスキップする。ヘッダーが読めた parse_skipped(巨大
|
|
50
|
+
# product CSV)では header 非空なので F03c は正しく機能する(列があれば出ない)。
|
|
51
|
+
skip_f03c = load.parse_skipped and not load.header
|
|
52
|
+
for rule in ALL_RULES:
|
|
53
|
+
if skip_f03c and getattr(rule, "__name__", "") == "rule_f03c":
|
|
54
|
+
continue
|
|
55
|
+
findings.extend(rule(load, intent))
|
|
56
|
+
# field を実入力の元列名へ正規化(spec: 元の列名で表示)。
|
|
57
|
+
display = _build_field_display_map(load)
|
|
58
|
+
if display:
|
|
59
|
+
for f in findings:
|
|
60
|
+
if f.field in display:
|
|
61
|
+
f.field = display[f.field]
|
|
62
|
+
return findings
|
preflight_kit/fixer.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
|
|
4
|
+
from .loader import LoadResult
|
|
5
|
+
from .models import Finding, FixClass
|
|
6
|
+
from .header_registry import to_canonical, CANONICAL_TO_HEADERS
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class FixResult:
|
|
11
|
+
header: list[str]
|
|
12
|
+
rows: list[list[str]]
|
|
13
|
+
applied: list[Finding]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _is_proven(f: Finding) -> bool:
|
|
17
|
+
# spec: fix_class==proven かつ auto_fixable==True の両方を要求(#6)。
|
|
18
|
+
return f.fix_class == FixClass.PROVEN and f.auto_fixable
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def apply_fixes(load: LoadResult, findings: list[Finding]) -> FixResult:
|
|
22
|
+
applied: list[Finding] = []
|
|
23
|
+
|
|
24
|
+
# F01a(BOM 除去)は loader が既にバイト列から除去済み。fixer は適用済みとして
|
|
25
|
+
# applied に取り込み auto_fixed=True にする(#5: report に proven 適用を出す)。
|
|
26
|
+
applied.extend(f for f in findings if f.rule_id == "F01a" and _is_proven(f))
|
|
27
|
+
|
|
28
|
+
# F03a: header case-only 修正(proven)。元列名 -> canonical 正式名へ。
|
|
29
|
+
f03a_cols = {f.field for f in findings if f.rule_id == "F03a" and _is_proven(f)}
|
|
30
|
+
new_header: list[str] = []
|
|
31
|
+
for col in load.header:
|
|
32
|
+
if col in f03a_cols:
|
|
33
|
+
key = to_canonical(col)
|
|
34
|
+
primary = CANONICAL_TO_HEADERS[key][0] if key else col
|
|
35
|
+
new_header.append(primary)
|
|
36
|
+
else:
|
|
37
|
+
new_header.append(col)
|
|
38
|
+
applied.extend(f for f in findings if f.rule_id == "F03a" and _is_proven(f))
|
|
39
|
+
|
|
40
|
+
# 行は列順を保持して元の値をそのまま出す(行順保持)。
|
|
41
|
+
# F04c の余剰セル(extra_cells)は silent discard せず末尾へそのまま付ける。
|
|
42
|
+
out_rows: list[list[str]] = []
|
|
43
|
+
for row in load.rows:
|
|
44
|
+
out_rows.append(
|
|
45
|
+
[row.cells.get(col, "") for col in load.header] + list(row.extra_cells)
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
for f in applied:
|
|
49
|
+
f.auto_fixed = True
|
|
50
|
+
return FixResult(header=new_header, rows=out_rows, applied=applied)
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import re
|
|
3
|
+
|
|
4
|
+
# canonical key -> [新形式ヘッダー, 旧形式 alias...](先頭が canonical 正式名)
|
|
5
|
+
CANONICAL_TO_HEADERS: dict[str, list[str]] = {
|
|
6
|
+
"handle": ["URL handle", "Handle"],
|
|
7
|
+
"title": ["Title"],
|
|
8
|
+
"description": ["Description", "Body (HTML)"],
|
|
9
|
+
"vendor": ["Vendor"],
|
|
10
|
+
"product_category": ["Product category", "Product Category"],
|
|
11
|
+
"type": ["Type"],
|
|
12
|
+
"tags": ["Tags"],
|
|
13
|
+
"published": ["Published on online store", "Published"],
|
|
14
|
+
"status": ["Status"],
|
|
15
|
+
"sku": ["SKU", "Variant SKU"],
|
|
16
|
+
"barcode": ["Barcode", "Variant Barcode"],
|
|
17
|
+
"option1_name": ["Option1 name", "Option1 Name"],
|
|
18
|
+
"option1_value": ["Option1 value", "Option1 Value"],
|
|
19
|
+
"option1_linkedto": ["Option1 LinkedTo", "Option1 Linked To"],
|
|
20
|
+
"option2_name": ["Option2 name", "Option2 Name"],
|
|
21
|
+
"option2_value": ["Option2 value", "Option2 Value"],
|
|
22
|
+
"option2_linkedto": ["Option2 LinkedTo", "Option2 Linked To"],
|
|
23
|
+
"option3_name": ["Option3 name", "Option3 Name"],
|
|
24
|
+
"option3_value": ["Option3 value", "Option3 Value"],
|
|
25
|
+
"option3_linkedto": ["Option3 LinkedTo", "Option3 Linked To"],
|
|
26
|
+
"price": ["Price", "Variant Price"],
|
|
27
|
+
"compare_at_price": ["Compare-at price", "Variant Compare At Price"],
|
|
28
|
+
"cost_per_item": ["Cost per item", "Cost per item (USD)"],
|
|
29
|
+
"inventory_tracker": ["Inventory tracker", "Variant Inventory Tracker"],
|
|
30
|
+
"inventory_qty": ["Inventory quantity", "Variant Inventory Qty"],
|
|
31
|
+
"inventory_policy": [
|
|
32
|
+
"Continue selling when out of stock",
|
|
33
|
+
"Variant Inventory Policy",
|
|
34
|
+
],
|
|
35
|
+
"fulfillment_service": ["Fulfillment service", "Variant Fulfillment Service"],
|
|
36
|
+
"weight_value": ["Weight value (grams)", "Variant Grams"],
|
|
37
|
+
"weight_unit": ["Weight unit for display", "Variant Weight Unit"],
|
|
38
|
+
"requires_shipping": ["Requires shipping", "Variant Requires Shipping"],
|
|
39
|
+
"charge_tax": ["Charge tax", "Variant Taxable"],
|
|
40
|
+
"gift_card": ["Gift card", "Gift Card"],
|
|
41
|
+
"image_src": ["Product image URL", "Image Src"],
|
|
42
|
+
"image_position": ["Image position", "Image Position"],
|
|
43
|
+
"variant_image": ["Variant image URL", "Variant Image"],
|
|
44
|
+
"image_alt": ["Image alt text", "Image Alt Text"],
|
|
45
|
+
"seo_title": ["SEO title", "SEO Title"],
|
|
46
|
+
"seo_description": ["SEO description", "SEO Description"],
|
|
47
|
+
"collection": ["Collection"],
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
# 注意(実装者向け): 上表は repo sample + spec 必須列 + 現行公式 CSV の主要列を含むが、
|
|
51
|
+
# Task 1(rules spec)で公式 current CSV 全列を確定し、ここに無い現行列があれば必ず追加する。
|
|
52
|
+
# 出典: https://help.shopify.com/en/manual/products/import-export/using-csv
|
|
53
|
+
# 怠ると F03d が有効な公式列を unknown warning として誤検出する(round-3 #1 の根本対策)。
|
|
54
|
+
|
|
55
|
+
# 元ヘッダー(lower) -> canonical key
|
|
56
|
+
_HEADER_LOWER_TO_KEY: dict[str, str] = {}
|
|
57
|
+
# 元ヘッダー(正確) -> canonical key
|
|
58
|
+
_HEADER_EXACT_TO_KEY: dict[str, str] = {}
|
|
59
|
+
# canonical 正式名(新形式)の集合(先頭要素)
|
|
60
|
+
_CANONICAL_PRIMARY: set[str] = set()
|
|
61
|
+
# canonical 正式名(新形式)の lower -> primary(case-only 判定を primary に限定する。
|
|
62
|
+
# alias の大小違いを case_only と誤判定して proven 改名するのを防ぐ・round-3 #2)
|
|
63
|
+
_PRIMARY_LOWER_TO_KEY: dict[str, str] = {}
|
|
64
|
+
for _key, _headers in CANONICAL_TO_HEADERS.items():
|
|
65
|
+
_primary = _headers[0]
|
|
66
|
+
_CANONICAL_PRIMARY.add(_primary)
|
|
67
|
+
_PRIMARY_LOWER_TO_KEY[_primary.lower()] = _key
|
|
68
|
+
for _i, _h in enumerate(_headers):
|
|
69
|
+
_HEADER_EXACT_TO_KEY[_h] = _key
|
|
70
|
+
_HEADER_LOWER_TO_KEY[_h.lower()] = _key
|
|
71
|
+
|
|
72
|
+
# dynamic 列パターン(registry に列挙不能な可変ヘッダー。誤検出も誤変換もしない)。
|
|
73
|
+
# round-4 #1: Markets 価格列の market 名は可変(International だけでない)。
|
|
74
|
+
# product metafield は `Metafield:` 接頭辞 / `(...product.metafields...)` / bare
|
|
75
|
+
# `product.metafields.<ns>.<key>` の3形式がある。variant metafield は除外する。
|
|
76
|
+
_DYNAMIC_PATTERNS = [
|
|
77
|
+
re.compile(r"^Metafield:", re.IGNORECASE),
|
|
78
|
+
re.compile(r"product\.metafields\.", re.IGNORECASE), # 括弧付き/ bare 両対応
|
|
79
|
+
re.compile(r"^Google Shopping", re.IGNORECASE),
|
|
80
|
+
# Markets/地域別価格: "Price / <market>" "Compare-at price / <market>"(market 名可変)
|
|
81
|
+
re.compile(r"^(Price|Compare-at price)\s*/\s*.+", re.IGNORECASE),
|
|
82
|
+
re.compile(r"\bIncluded\b\s*/\s*.+", re.IGNORECASE), # Markets included / <market>
|
|
83
|
+
]
|
|
84
|
+
# variant metafield: `Variant Metafield:` 接頭辞 / bare `variant.metafields.<ns>.<key>`。
|
|
85
|
+
# dynamic より先に判定し dynamic から除外する(standard product CSV import 非対応のため警告)。
|
|
86
|
+
_VARIANT_METAFIELD = re.compile(
|
|
87
|
+
r"^Variant Metafield:|variant\.metafields\.", re.IGNORECASE
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def to_canonical(header: str) -> str | None:
|
|
92
|
+
if header in _HEADER_EXACT_TO_KEY:
|
|
93
|
+
return _HEADER_EXACT_TO_KEY[header]
|
|
94
|
+
return _HEADER_LOWER_TO_KEY.get(header.lower())
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def is_variant_metafield(header: str) -> bool:
|
|
98
|
+
# `^Variant Metafield:` か bare `variant.metafields.` を拾う(search で両対応)。
|
|
99
|
+
return bool(_VARIANT_METAFIELD.search(header))
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def is_dynamic(header: str) -> bool:
|
|
103
|
+
if is_variant_metafield(header):
|
|
104
|
+
return False
|
|
105
|
+
return any(p.search(header) for p in _DYNAMIC_PATTERNS)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def match_kind(header: str) -> str:
|
|
109
|
+
if header in _CANONICAL_PRIMARY:
|
|
110
|
+
return "canonical"
|
|
111
|
+
key = _HEADER_EXACT_TO_KEY.get(header)
|
|
112
|
+
if key is not None:
|
|
113
|
+
# 完全一致だが正式名でない = 旧形式 alias(exact 一致)
|
|
114
|
+
return "alias"
|
|
115
|
+
# case_only は **現行 primary ヘッダーの大小違いのみ**。これは proven 改名対象。
|
|
116
|
+
if header.lower() in _PRIMARY_LOWER_TO_KEY:
|
|
117
|
+
return "case_only"
|
|
118
|
+
# primary でない lower 一致 = alias の大小違い。alias→canonical 改名は suggested の
|
|
119
|
+
# ため proven 化しない。alias 扱いに倒す(F03b が suggested で提案・改名しない)。
|
|
120
|
+
if header.lower() in _HEADER_LOWER_TO_KEY:
|
|
121
|
+
return "alias"
|
|
122
|
+
if is_variant_metafield(header):
|
|
123
|
+
return "variant_metafield"
|
|
124
|
+
if is_dynamic(header):
|
|
125
|
+
return "dynamic"
|
|
126
|
+
return "unknown"
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Golden crosscheck 用の読み取り専用 JSON dump(検査ロジックは一切変更しない)。
|
|
2
|
+
|
|
3
|
+
`load_csv` + `run_engine` を呼び、findings を TS `reporters.ts` の `NormalizedFinding`
|
|
4
|
+
と **同一スキーマ・同一 key 順・同一 full sort key** の JSON 配列で stdout 出力する。
|
|
5
|
+
|
|
6
|
+
TS reporters.ts の NormalizedFinding と同期(変更時は両方):
|
|
7
|
+
key order: ruleId, row, severity, field, fixClass, autoFixable,
|
|
8
|
+
productGroupId, rowKind, message, suggestedFix
|
|
9
|
+
sort key : ruleId, row ?? -1, severity, field ?? "", fixClass,
|
|
10
|
+
productGroupId ?? "", rowKind ?? "", message, suggestedFix
|
|
11
|
+
|
|
12
|
+
使い方:
|
|
13
|
+
uv run python -m preflight_kit.json_dump <input.csv> --intent <new|update|mixed>
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
import argparse
|
|
18
|
+
import json
|
|
19
|
+
import sys
|
|
20
|
+
|
|
21
|
+
from .loader import load_csv
|
|
22
|
+
from .engine import run_engine
|
|
23
|
+
from .models import Finding, ImportIntent
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _enum_value(v) -> str | None:
|
|
27
|
+
if v is None:
|
|
28
|
+
return None
|
|
29
|
+
if hasattr(v, "value"):
|
|
30
|
+
return v.value
|
|
31
|
+
return str(v)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def to_normalized_record(f: Finding) -> dict:
|
|
35
|
+
# key 順は TS NormalizedFinding と 1:1(dict 挿入順 = JSON 出力順)。
|
|
36
|
+
return {
|
|
37
|
+
"ruleId": f.rule_id,
|
|
38
|
+
"row": f.row,
|
|
39
|
+
"severity": _enum_value(f.severity),
|
|
40
|
+
"field": f.field,
|
|
41
|
+
"fixClass": _enum_value(f.fix_class),
|
|
42
|
+
"autoFixable": bool(f.auto_fixable),
|
|
43
|
+
"productGroupId": f.product_group_id,
|
|
44
|
+
"rowKind": _enum_value(f.row_kind),
|
|
45
|
+
"message": f.message,
|
|
46
|
+
"suggestedFix": f.suggested_fix,
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def normalize_findings(findings: list[Finding]) -> list[dict]:
|
|
51
|
+
records = [to_normalized_record(f) for f in findings]
|
|
52
|
+
# full sort key(TS normalizeFindings と同一順)。None は TS の ?? 既定に合わせる。
|
|
53
|
+
records.sort(
|
|
54
|
+
key=lambda r: (
|
|
55
|
+
r["ruleId"],
|
|
56
|
+
r["row"] if r["row"] is not None else -1,
|
|
57
|
+
r["severity"],
|
|
58
|
+
r["field"] if r["field"] is not None else "",
|
|
59
|
+
r["fixClass"],
|
|
60
|
+
r["productGroupId"] if r["productGroupId"] is not None else "",
|
|
61
|
+
r["rowKind"] if r["rowKind"] is not None else "",
|
|
62
|
+
r["message"],
|
|
63
|
+
r["suggestedFix"],
|
|
64
|
+
)
|
|
65
|
+
)
|
|
66
|
+
return records
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def main(argv: list[str] | None = None) -> int:
|
|
70
|
+
parser = argparse.ArgumentParser(prog="preflight-kit-json-dump")
|
|
71
|
+
parser.add_argument("input")
|
|
72
|
+
parser.add_argument("--intent", choices=["new", "update", "mixed"], default="new")
|
|
73
|
+
args = parser.parse_args(argv)
|
|
74
|
+
|
|
75
|
+
intent = ImportIntent(args.intent)
|
|
76
|
+
load = load_csv(args.input)
|
|
77
|
+
findings = run_engine(load, intent)
|
|
78
|
+
records = normalize_findings(findings)
|
|
79
|
+
json.dump(records, sys.stdout, ensure_ascii=False, indent=2)
|
|
80
|
+
sys.stdout.write("\n")
|
|
81
|
+
return 0
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
if __name__ == "__main__":
|
|
85
|
+
raise SystemExit(main())
|