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.
@@ -0,0 +1,291 @@
1
+ from __future__ import annotations
2
+ import csv
3
+ import io
4
+ import re
5
+ from dataclasses import dataclass, field
6
+
7
+ from .models import Finding, Row, RowKind, Severity, FixClass, MAX_IMPORT_BYTES
8
+ from .header_registry import to_canonical
9
+
10
+ # csv のフィールド長上限(既定 131072)は Shopify の長い description セルや
11
+ # 15MB 超の単一セル(F04a fixture)で `field larger than field limit` を起こす。
12
+ # Shopify の 15MB import 上限を超える 64MB に引き上げ、構造検査を最後まで走らせる。
13
+ csv.field_size_limit(64 * 1024 * 1024)
14
+
15
+ _PII_PATTERNS = [
16
+ re.compile(r"e-?mail", re.IGNORECASE),
17
+ re.compile(r"\bphone\b", re.IGNORECASE),
18
+ re.compile(r"telephone", re.IGNORECASE),
19
+ re.compile(r"address", re.IGNORECASE),
20
+ re.compile(r"\bcustomer\b", re.IGNORECASE),
21
+ ]
22
+
23
+
24
+ @dataclass
25
+ class LoadResult:
26
+ header: list[str]
27
+ rows: list[Row]
28
+ canonical_map: dict[str, str]
29
+ file_findings: list[Finding]
30
+ encoding: str
31
+ raw_byte_size: int
32
+ blocked: bool = False
33
+ # 15MB 超で行 parse をスキップしたか。header は読めていても rows は空になる。
34
+ # CLI はこのとき fixed_products.csv を書かない(空ファイルでの元データ破壊を防ぐ)。
35
+ # engine はこのとき行ルール・F03c 等の「行/列の中身」判定をスキップする
36
+ # (header=[] でないので F03c 誤発火しないが、行が無い前提のルールも抑止する)。
37
+ parse_skipped: bool = False
38
+
39
+
40
+ def _file_finding(
41
+ rule_id, severity, message, suggested_fix, fix_class, field_name=None, row=None
42
+ ):
43
+ return Finding(
44
+ row=row,
45
+ product_group_id=None,
46
+ row_kind=None,
47
+ handle=None,
48
+ sku=None,
49
+ severity=severity,
50
+ rule_id=rule_id,
51
+ field=field_name,
52
+ message=message,
53
+ suggested_fix=suggested_fix,
54
+ fix_class=fix_class,
55
+ auto_fixable=(fix_class == FixClass.PROVEN),
56
+ )
57
+
58
+
59
+ def _detect_pii(header: list[str]) -> list[Finding]:
60
+ out = []
61
+ for col in header:
62
+ if any(p.search(col) for p in _PII_PATTERNS):
63
+ out.append(
64
+ _file_finding(
65
+ "GUARD-PII",
66
+ Severity.CRITICAL,
67
+ f"Buyer-PII-like column detected: '{col}'. Processing stopped (order/customer CSV out of scope).",
68
+ "Remove buyer PII columns; this tool only handles product CSV.",
69
+ FixClass.NONE,
70
+ field_name=col,
71
+ )
72
+ )
73
+ return out
74
+
75
+
76
+ def _is_product_start(
77
+ canonical: dict[str, str], handle: str, current_group_handle: str | None
78
+ ) -> bool:
79
+ """product-start 判定(#1/#2/#3 対応)。
80
+
81
+ `current_group_handle` は「現在のグループの代表 handle」(最後に product-start を
82
+ 立てた行の handle 値。まだ1つもグループが無ければ None)。直前行の生 handle ではなく
83
+ **グループ代表 handle** と比較することで、handle 空の本体行を挟んでも後続の同一 handle
84
+ 行が誤って前グループを継承する carry-over を防ぐ(round-2 #1)。
85
+
86
+ 分岐:
87
+ - handle 非空 かつ グループ代表 handle と異なる → 新グループの product-start。
88
+ - handle 非空 かつ グループ代表 handle と同一 → Title 非空なら 2つ目の
89
+ product-start(R03 が拾う重複本体行)。Title 空なら variant/image(正常な反復)。
90
+ - handle 空 かつ Title 非空 → 本体行で handle 値だけ欠落 = product-start として扱い、
91
+ R02 が handle 欠落 severity を intent 別に判定する。
92
+ - handle 空 かつ Title 空 かつ 直前にグループあり → 親 handle を書き忘れた
93
+ variant/image 継続行とみなし product-start にしない(R02 の variant 分岐が
94
+ 「handle 欠落 = critical」を出せる)。直前グループが無ければ product-start。
95
+ """
96
+ title = (canonical.get("title") or "").strip()
97
+ if handle != "":
98
+ if handle != current_group_handle:
99
+ return True
100
+ return title != "" # 同一グループ handle 継続
101
+ # handle 空
102
+ if title != "":
103
+ return True # 本体行で handle 値だけ欠落(R02 が intent 別に判定)
104
+ # handle 空 かつ Title 空: 直前にグループがあれば継続行(handle 欠落 variant/image)
105
+ return current_group_handle is None
106
+
107
+
108
+ def _classify_row(canonical: dict[str, str], is_start: bool) -> RowKind:
109
+ if is_start:
110
+ return RowKind.PRODUCT
111
+ title = (canonical.get("title") or "").strip()
112
+ has_image = bool((canonical.get("image_src") or "").strip())
113
+ has_option = bool(
114
+ (canonical.get("option1_value") or "").strip()
115
+ or (canonical.get("sku") or "").strip()
116
+ or (canonical.get("price") or "").strip()
117
+ )
118
+ # image 判定を variant より先に評価(spec Task1 Step2 の優先順位)
119
+ if not title and has_image and not has_option:
120
+ return RowKind.IMAGE
121
+ return RowKind.VARIANT
122
+
123
+
124
+ def load_csv(path: str) -> LoadResult:
125
+ with open(path, "rb") as fh:
126
+ raw = fh.read()
127
+ raw_byte_size = len(raw)
128
+ file_findings: list[Finding] = []
129
+
130
+ # --- F01a: BOM ---
131
+ had_bom = raw.startswith(b"\xef\xbb\xbf")
132
+ if had_bom:
133
+ raw = raw[3:]
134
+ file_findings.append(
135
+ _file_finding(
136
+ "F01a",
137
+ Severity.CRITICAL,
138
+ "UTF-8 BOM detected at file start.",
139
+ "BOM removed automatically.",
140
+ FixClass.PROVEN,
141
+ )
142
+ )
143
+
144
+ # --- F01b: UTF-8 デコード可否(推定変換しない) ---
145
+ encoding = "utf-8"
146
+ try:
147
+ text = raw.decode("utf-8")
148
+ except UnicodeDecodeError:
149
+ encoding = "unknown"
150
+ file_findings.append(
151
+ _file_finding(
152
+ "F01b",
153
+ Severity.CRITICAL,
154
+ "File is not valid UTF-8 (encoding guess required).",
155
+ "Re-export the CSV as UTF-8; this tool does not guess-convert encodings.",
156
+ FixClass.SUGGESTED,
157
+ )
158
+ )
159
+ # デコードできた範囲だけ replace で読み、構造検査は継続
160
+ text = raw.decode("utf-8", errors="replace")
161
+
162
+ # --- 巨大ファイルは行 parse をスキップ(F04a は file_rules が出す) ---
163
+ # csv.reader は field_size_limit(64MB) を超える単一セルで csv.Error を投げて
164
+ # クラッシュし、F04a critical Finding / errors.csv / report.md / exit code 1 に
165
+ # 到達しなくなる。単一セルはファイル全体以下なので、64MB 超セルが起きるのは
166
+ # 必ず raw_byte_size > 15MB(_MAX_BYTES) のとき。よって 15MB 超過ファイルは
167
+ # **行**の parse を試みず parse_skipped=True で返し、F04a は後段の rule_f04a が
168
+ # raw_byte_size から出す(ここで F04a を出すと file_rules と二重発火するため出さない)。
169
+ #
170
+ # ただしヘッダー 1 行だけは読む(round-2 修正)。ヘッダーを読まないと:
171
+ # (1) 巨大 order/customer CSV で PII ガードを迂回する(security blocking)
172
+ # (2) header=[] のため F03c が handle/title 欠落を誤発火する(spec-conformance)
173
+ # ヘッダー行に巨大セルがあって read 自体が失敗する場合は header=[] のまま続行する。
174
+ parse_skipped = raw_byte_size > MAX_IMPORT_BYTES
175
+ if parse_skipped:
176
+ header: list[str] = []
177
+ try:
178
+ header = next(csv.reader(io.StringIO(text)))
179
+ except (StopIteration, csv.Error):
180
+ header = []
181
+ # ヘッダーが読めたら PII 判定は必ず通す(巨大ファイルでも安全境界を外さない)。
182
+ pii = _detect_pii(header)
183
+ if pii:
184
+ file_findings.extend(pii)
185
+ return LoadResult(
186
+ header,
187
+ [],
188
+ {},
189
+ file_findings,
190
+ encoding,
191
+ raw_byte_size,
192
+ blocked=True,
193
+ parse_skipped=True,
194
+ )
195
+ canonical_map = {
196
+ col: to_canonical(col) for col in header if to_canonical(col) is not None
197
+ }
198
+ return LoadResult(
199
+ header=header,
200
+ rows=[],
201
+ canonical_map=canonical_map,
202
+ file_findings=file_findings,
203
+ encoding=encoding,
204
+ raw_byte_size=raw_byte_size,
205
+ blocked=False,
206
+ parse_skipped=True,
207
+ )
208
+
209
+ reader = csv.reader(io.StringIO(text))
210
+ try:
211
+ header = next(reader)
212
+ except StopIteration:
213
+ return LoadResult(
214
+ [], [], {}, file_findings, encoding, raw_byte_size, blocked=False
215
+ )
216
+
217
+ # --- PII ガード ---
218
+ pii = _detect_pii(header)
219
+ if pii:
220
+ file_findings.extend(pii)
221
+ return LoadResult(
222
+ header, [], {}, file_findings, encoding, raw_byte_size, blocked=True
223
+ )
224
+
225
+ canonical_map: dict[str, str] = {}
226
+ for col in header:
227
+ key = to_canonical(col)
228
+ if key is not None:
229
+ canonical_map[col] = key
230
+
231
+ rows: list[Row] = []
232
+ line_no = 0
233
+ group_seq = 0
234
+ group_id: str | None = None
235
+ # 現在のグループ代表 handle(最後に product-start を立てた行の handle 値)。
236
+ # 直前行の生 handle ではなくこれと比較する(carry-over 防止・round-2 #1)。
237
+ current_group_handle: str | None = None
238
+ for raw_cells in reader:
239
+ line_no += 1
240
+ cells = {
241
+ header[i]: (raw_cells[i] if i < len(raw_cells) else "")
242
+ for i in range(len(header))
243
+ }
244
+ # --- F04c: 行のセル数がヘッダー列数を超過(列ずれ) ---
245
+ # 未クォートのカンマや壊れた行で起きる。余剰セルは silently discard せず
246
+ # extra_cells に保持し(fixer が末尾へ出力)、critical Finding で報告する。
247
+ extra_cells: list[str] = []
248
+ if len(raw_cells) > len(header):
249
+ extra_cells = list(raw_cells[len(header) :])
250
+ file_findings.append(
251
+ _file_finding(
252
+ "F04c",
253
+ Severity.CRITICAL,
254
+ f"Row {line_no} has {len(raw_cells)} cells, more than the "
255
+ f"{len(header)} header columns (column misalignment).",
256
+ "Fix the row's quoting/delimiters so cell count matches the header.",
257
+ FixClass.NONE,
258
+ row=line_no,
259
+ )
260
+ )
261
+ canonical = {
262
+ canonical_map[col]: val
263
+ for col, val in cells.items()
264
+ if col in canonical_map
265
+ }
266
+ handle = (canonical.get("handle") or "").strip()
267
+ is_start = _is_product_start(canonical, handle, current_group_handle)
268
+ if is_start:
269
+ # product-start ごとに新グループ採番。同一 handle に複数 start が
270
+ # あれば別グループになり、R03 が複数本体行を検知できる。
271
+ group_seq += 1
272
+ group_id = f"g{group_seq}"
273
+ # グループ代表 handle を更新(本体行 handle 欠落時は空文字で記録)。
274
+ current_group_handle = handle
275
+ # 継続行(is_start=False)は group_id / current_group_handle を維持し継承する。
276
+ kind = _classify_row(canonical, is_start)
277
+ rows.append(
278
+ Row(
279
+ line_no=line_no,
280
+ cells=cells,
281
+ canonical=canonical,
282
+ product_group_id=group_id,
283
+ row_kind=kind,
284
+ is_product_start=is_start,
285
+ extra_cells=extra_cells,
286
+ )
287
+ )
288
+
289
+ return LoadResult(
290
+ header, rows, canonical_map, file_findings, encoding, raw_byte_size
291
+ )
@@ -0,0 +1,66 @@
1
+ from __future__ import annotations
2
+ from dataclasses import dataclass, field
3
+ from enum import Enum
4
+
5
+ # Shopify product CSV import の 15MB hard limit(F04a / loader の parse スキップ判定で共有)。
6
+ # loader と file_rules の双方が参照するため、依存末端の models に SoT を置く。
7
+ MAX_IMPORT_BYTES = 15 * 1024 * 1024
8
+
9
+
10
+ class Severity(str, Enum):
11
+ CRITICAL = "critical"
12
+ WARNING = "warning"
13
+ INFO = "info"
14
+
15
+
16
+ class FixClass(str, Enum):
17
+ PROVEN = "proven"
18
+ SUGGESTED = "suggested"
19
+ NONE = "none"
20
+
21
+
22
+ class RowKind(str, Enum):
23
+ PRODUCT = "product"
24
+ VARIANT = "variant"
25
+ IMAGE = "image"
26
+
27
+
28
+ class ImportIntent(str, Enum):
29
+ NEW = "new"
30
+ UPDATE = "update"
31
+ MIXED = "mixed"
32
+
33
+
34
+ @dataclass
35
+ class Row:
36
+ """1 CSV データ行。canonical アクセスは get(key) 経由。"""
37
+
38
+ line_no: int # 元CSV行番号(1始まり・ヘッダー除く)
39
+ cells: dict[str, str] # 元の列名 -> 値(列順は header で保持)
40
+ canonical: dict[str, str] # canonical key -> 値(解釈用)
41
+ product_group_id: str | None = None
42
+ row_kind: RowKind | None = None
43
+ is_product_start: bool = False
44
+ # ヘッダー列数を超えた余剰セル(F04c 列ずれ行)。silent discard せず保持し、
45
+ # fixer が出力行の末尾へそのまま付ける。通常行は空リスト。
46
+ extra_cells: list[str] = field(default_factory=list)
47
+
48
+ def get(self, canonical_key: str) -> str | None:
49
+ return self.canonical.get(canonical_key)
50
+
51
+
52
+ @dataclass
53
+ class Finding:
54
+ row: int | None
55
+ product_group_id: str | None
56
+ row_kind: RowKind | None
57
+ handle: str | None
58
+ sku: str | None
59
+ severity: Severity
60
+ rule_id: str
61
+ field: str | None
62
+ message: str
63
+ suggested_fix: str
64
+ fix_class: FixClass
65
+ auto_fixable: bool
66
+ auto_fixed: bool = False
@@ -0,0 +1,145 @@
1
+ from __future__ import annotations
2
+ import csv
3
+
4
+ from .models import Finding, Severity, FixClass
5
+
6
+ _ERROR_COLUMNS = [
7
+ "row",
8
+ "product_group_id",
9
+ "row_kind",
10
+ "handle",
11
+ "sku",
12
+ "severity",
13
+ "rule_id",
14
+ "field",
15
+ "message",
16
+ "suggested_fix",
17
+ "fix_class",
18
+ "auto_fixed",
19
+ ]
20
+
21
+ _UNCHECKED_EN = (
22
+ "Not checked by this tool: image URL public reachability, handle "
23
+ "overwrite/ignore against an existing store, metafield product reference "
24
+ "resolution, and option position consistency with existing products."
25
+ )
26
+ _UNCHECKED_JA = (
27
+ "本CLIが未検査の範囲: 画像URLの公開到達性、既存ストアとの handle "
28
+ "overwrite/ignore、metafield product reference の解決、既存商品との option 位置整合。"
29
+ )
30
+ _NO_BLOCKING = "No blocking findings detected within implemented checks"
31
+
32
+
33
+ def _cell(v) -> str:
34
+ if v is None:
35
+ return ""
36
+ if isinstance(v, (Severity, FixClass)):
37
+ return v.value
38
+ if hasattr(v, "value"):
39
+ return v.value
40
+ return str(v)
41
+
42
+
43
+ def write_errors_csv(findings: list[Finding], path: str) -> None:
44
+ with open(path, "w", newline="", encoding="utf-8") as fh:
45
+ writer = csv.writer(fh)
46
+ writer.writerow(_ERROR_COLUMNS)
47
+ for f in findings:
48
+ writer.writerow(
49
+ [
50
+ _cell(f.row),
51
+ _cell(f.product_group_id),
52
+ _cell(f.row_kind),
53
+ _cell(f.handle),
54
+ _cell(f.sku),
55
+ _cell(f.severity),
56
+ _cell(f.rule_id),
57
+ _cell(f.field),
58
+ _cell(f.message),
59
+ _cell(f.suggested_fix),
60
+ _cell(f.fix_class),
61
+ _cell(f.auto_fixed),
62
+ ]
63
+ )
64
+
65
+
66
+ def render_report_md(
67
+ findings: list[Finding],
68
+ *,
69
+ lang: str,
70
+ scanned_rows: int,
71
+ group_count: int,
72
+ applied: list[Finding],
73
+ ) -> str:
74
+ crit = [f for f in findings if f.severity == Severity.CRITICAL]
75
+ warn = [f for f in findings if f.severity == Severity.WARNING]
76
+ suggested = [f for f in findings if f.fix_class == FixClass.SUGGESTED]
77
+ lines: list[str] = []
78
+
79
+ if lang == "ja":
80
+ lines.append("# Shopify CSV Preflight Report")
81
+ lines.append("")
82
+ lines.append(
83
+ f"- 概要: 検査行数 {scanned_rows} / 商品グループ数 {group_count} / "
84
+ f"critical {len(crit)}件 / warning {len(warn)}件 / "
85
+ f"自動修正(proven) {len(applied)}件 / 提案(suggested) {len(suggested)}件"
86
+ )
87
+ crit_head, warn_head = (
88
+ "## Critical(インポート前に必ず直す)",
89
+ "## Warning(確認推奨)",
90
+ )
91
+ applied_head, sug_head = (
92
+ "## 自動修正した内容(proven)",
93
+ "## 提案(suggested・CSV未反映)",
94
+ )
95
+ unchecked_head, next_head = "## 未検査範囲", "## 次のアクション"
96
+ unchecked = _UNCHECKED_JA
97
+ verdict = (
98
+ (_NO_BLOCKING + "(import可能を保証しない範囲あり)")
99
+ if not crit
100
+ else "Critical があります。import 前に解消してください。import可能を保証しません。"
101
+ )
102
+ else:
103
+ lines.append("# Shopify CSV Preflight Report")
104
+ lines.append("")
105
+ lines.append(
106
+ f"- Summary: scanned rows {scanned_rows} / product groups {group_count} / "
107
+ f"{len(crit)} critical / {len(warn)} warning / "
108
+ f"{len(applied)} auto-fixed (proven) / {len(suggested)} suggested"
109
+ )
110
+ crit_head, warn_head = (
111
+ "## Critical (fix before import)",
112
+ "## Warning (review recommended)",
113
+ )
114
+ applied_head, sug_head = (
115
+ "## Auto-fixed (proven)",
116
+ "## Suggested (not applied to CSV)",
117
+ )
118
+ unchecked_head, next_head = "## Not checked", "## Next actions"
119
+ unchecked = _UNCHECKED_EN
120
+ verdict = (
121
+ (_NO_BLOCKING + " (import not guaranteed).")
122
+ if not crit
123
+ else "Critical findings present. Resolve before import. Import is not guaranteed."
124
+ )
125
+
126
+ def _section(head, items):
127
+ lines.append("")
128
+ lines.append(head)
129
+ if not items:
130
+ lines.append("- (none)")
131
+ for f in items:
132
+ loc = f"row {f.row}" if f.row else "file"
133
+ lines.append(f"- [{f.rule_id}] {loc}: {f.message}")
134
+
135
+ _section(crit_head, crit)
136
+ _section(warn_head, warn)
137
+ _section(applied_head, applied)
138
+ _section(sug_head, suggested)
139
+ lines.append("")
140
+ lines.append(unchecked_head)
141
+ lines.append(f"- {unchecked}")
142
+ lines.append("")
143
+ lines.append(next_head)
144
+ lines.append(f"- {verdict}")
145
+ return "\n".join(lines) + "\n"
@@ -0,0 +1,4 @@
1
+ from .file_rules import ALL_FILE_RULES
2
+ from .row_rules import ALL_ROW_RULES
3
+
4
+ ALL_RULES = [*ALL_FILE_RULES, *ALL_ROW_RULES]