sino-bc-cli 1.8.38 → 1.8.39
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/dist/index.js +1 -1
- package/dist/skill-manifest.json +11 -8
- package/dist/skills/biz-line-strategy-generate/SKILL.md +2 -0
- package/dist/skills/biz-line-strategy-generate/references/output-excel.md +6 -0
- package/dist/skills/biz-line-strategy-generate/references/runtime.md +8 -0
- package/dist/skills/biz-line-strategy-generate/scripts/build_report.py +299 -244
- package/dist/skills/biz-line-strategy-generate/scripts/detail_store.py +82 -0
- package/dist/skills/biz-line-strategy-generate/scripts/summarize.py +22 -22
- package/dist/skills/biz-line-strategy-generate/scripts/test_detail_store.py +122 -0
- package/dist/skills/biz-line-strategy-generate/scripts/test_excel_streaming.py +223 -0
- package/dist/skills/biz-line-strategy-generate/scripts/test_pipeline.py +4 -1
- package/dist/skills/biz-line-strategy-generate/scripts/test_workflow.py +40 -27
- package/package.json +1 -1
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""本条任务的明细落盘协议;多次导出和校验按行重读,不保留完整列表。"""
|
|
2
|
+
import json
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from uuid import uuid4
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _check_record(record):
|
|
8
|
+
if not isinstance(record, dict) or any(
|
|
9
|
+
not isinstance(key, str) or not isinstance(value, (str, int, float, bool, type(None)))
|
|
10
|
+
for key, value in record.items()):
|
|
11
|
+
raise ValueError('明细记录必须是字段到单元格值的对象')
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class DetailRows:
|
|
15
|
+
"""只保存路径和行数;每次遍历重新打开,不能复用一次性迭代器。"""
|
|
16
|
+
|
|
17
|
+
def __init__(self, path, count):
|
|
18
|
+
if type(count) is not int or count < 0:
|
|
19
|
+
raise ValueError('明细行数必须是非负整数')
|
|
20
|
+
self.path = Path(path)
|
|
21
|
+
self.count = count
|
|
22
|
+
|
|
23
|
+
def __len__(self):
|
|
24
|
+
return self.count
|
|
25
|
+
|
|
26
|
+
def __iter__(self):
|
|
27
|
+
count = 0
|
|
28
|
+
with self.path.open(encoding='utf-8') as stream:
|
|
29
|
+
for line in stream:
|
|
30
|
+
count += 1
|
|
31
|
+
if count > self.count:
|
|
32
|
+
raise ValueError('明细实际行数超过登记数量')
|
|
33
|
+
record = json.loads(line)
|
|
34
|
+
_check_record(record)
|
|
35
|
+
yield record
|
|
36
|
+
if count != self.count:
|
|
37
|
+
raise ValueError('明细实际行数少于登记数量')
|
|
38
|
+
|
|
39
|
+
def reference(self, root):
|
|
40
|
+
return {'version': 1, 'path': str(self.path.relative_to(root)), 'count': self.count}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def write_detail_rows(root, records):
|
|
44
|
+
"""完成全部记录后才发布引用;失败时关闭生成器并移除半成品。"""
|
|
45
|
+
final = Path(root) / f'details-{uuid4().hex}.jsonl'
|
|
46
|
+
partial = final.with_suffix('.part')
|
|
47
|
+
count = 0
|
|
48
|
+
try:
|
|
49
|
+
with partial.open('x', encoding='utf-8') as stream:
|
|
50
|
+
for record in records:
|
|
51
|
+
_check_record(record)
|
|
52
|
+
stream.write(json.dumps(record, ensure_ascii=False) + '\n')
|
|
53
|
+
count += 1
|
|
54
|
+
partial.replace(final)
|
|
55
|
+
return DetailRows(final, count)
|
|
56
|
+
finally:
|
|
57
|
+
close = getattr(records, 'close', None)
|
|
58
|
+
try:
|
|
59
|
+
if close is not None:
|
|
60
|
+
close()
|
|
61
|
+
finally:
|
|
62
|
+
partial.unlink(missing_ok=True)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def details_from_payload(payload, root):
|
|
66
|
+
"""新引用只读取本条目录;旧诊断数组保持兼容,不将损坏引用当空明细。"""
|
|
67
|
+
if 'detailSource' not in payload:
|
|
68
|
+
details = payload.get('details', [])
|
|
69
|
+
if not isinstance(details, list):
|
|
70
|
+
raise ValueError('payload.details 必须是数组')
|
|
71
|
+
return details
|
|
72
|
+
source = payload['detailSource']
|
|
73
|
+
if 'details' in payload or not isinstance(source, dict) or set(source) != {'version', 'path', 'count'}:
|
|
74
|
+
raise ValueError('明细引用格式无效或与内嵌明细冲突')
|
|
75
|
+
if type(source['version']) is not int or source['version'] != 1 or not isinstance(source['path'], str):
|
|
76
|
+
raise ValueError('明细引用版本或路径无效')
|
|
77
|
+
relative = Path(source['path'])
|
|
78
|
+
root = Path(root).resolve()
|
|
79
|
+
path = (root / relative).resolve()
|
|
80
|
+
if relative.is_absolute() or '..' in relative.parts or not path.is_relative_to(root) or path == root:
|
|
81
|
+
raise ValueError('明细文件必须位于本条任务目录内')
|
|
82
|
+
return DetailRows(path, source['count'])
|
|
@@ -14,6 +14,7 @@ SCRIPTS = Path(__file__).resolve().parent
|
|
|
14
14
|
if str(SCRIPTS) not in sys.path:
|
|
15
15
|
sys.path.insert(0, str(SCRIPTS))
|
|
16
16
|
|
|
17
|
+
from detail_store import write_detail_rows
|
|
17
18
|
from cli_json import BASE_METRICS, REPORT_METRICS, amount_change, evaluate_learning, metric_fields, metric_formula, learning_text
|
|
18
19
|
from cli_json import (
|
|
19
20
|
bid_adjust_stats,
|
|
@@ -380,7 +381,6 @@ def build_dimension_details(bl: Path, active_channels, bids):
|
|
|
380
381
|
('广告', 'ad_id+ad_name', tuple(CH_LABEL), 'ad_id'),
|
|
381
382
|
('创意类型', 'ad_format', tuple(CH_LABEL), None),
|
|
382
383
|
]
|
|
383
|
-
details = []
|
|
384
384
|
# 预算按渠道列成效;渠道编码就是此枚举的稳定 ID。
|
|
385
385
|
for ch in active_channels:
|
|
386
386
|
totals = {'curr': {}, 'prev': {}}
|
|
@@ -390,11 +390,11 @@ def build_dimension_details(bl: Path, active_channels, bids):
|
|
|
390
390
|
conflicts[period] = detail_conflicts(bl, stem)
|
|
391
391
|
for row in iter_stem_rows(bl, stem):
|
|
392
392
|
add_detail_metric_row(totals[period], row)
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
393
|
+
yield {'kind': '预算', 'action': '持续' if totals['prev'] else '新增',
|
|
394
|
+
'channel': CH_LABEL[ch], 'id': ch, 'name': CH_LABEL[ch],
|
|
395
|
+
**detail_metric_cells([totals['curr']] if totals['curr'] else [],
|
|
396
|
+
[totals['prev']] if totals['prev'] else [],
|
|
397
|
+
conflicts['curr'], conflicts['prev']), 'extra': ''}
|
|
398
398
|
|
|
399
399
|
for kind, stem, channels, id_key in dimensions:
|
|
400
400
|
key = stem.split('+')[-1] if id_key else stem
|
|
@@ -425,12 +425,12 @@ def build_dimension_details(bl: Path, active_channels, bids):
|
|
|
425
425
|
for ident in sorted(set(names['curr']) | set(names['prev'])):
|
|
426
426
|
action = ('持续' if ident in names['curr'] and ident in names['prev'] else
|
|
427
427
|
'新增' if ident in names['curr'] else '暂停')
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
428
|
+
yield {'kind': kind, 'action': action, 'channel': CH_LABEL[ch],
|
|
429
|
+
'id': ident, 'name': names['curr'].get(ident) or names['prev'][ident],
|
|
430
|
+
**detail_metric_cells([values['curr'][ident]] if values['curr'][ident] else [],
|
|
431
|
+
[values['prev'][ident]] if values['prev'][ident] else [],
|
|
432
|
+
conflicts['curr'], conflicts['prev']),
|
|
433
|
+
'extra': ''}
|
|
434
434
|
|
|
435
435
|
# 出价沿用既有上调/下调/持平动作;金额取广告组同 ID 的成效,补充只列首末出价。
|
|
436
436
|
for ch in active_channels:
|
|
@@ -451,13 +451,12 @@ def build_dimension_details(bl: Path, active_channels, bids):
|
|
|
451
451
|
action = item.get('action') or ('上调' if end > start else '下调' if end < start else '期内变化但首末持平')
|
|
452
452
|
changes = ';'.join(f"{field['field']} {field['from']:g} → {field['to']:g}"
|
|
453
453
|
for field in item.get('fields', []))
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
return details
|
|
454
|
+
yield {'kind': '出价', 'action': action, 'channel': CH_LABEL[ch],
|
|
455
|
+
'id': ident, 'name': item.get('name') or ident,
|
|
456
|
+
**detail_metric_cells([values['curr'][ident]] if values['curr'][ident] else [],
|
|
457
|
+
[values['prev'][ident]] if values['prev'][ident] else [],
|
|
458
|
+
conflicts['curr'], conflicts['prev']),
|
|
459
|
+
'extra': changes}
|
|
461
460
|
|
|
462
461
|
|
|
463
462
|
def summarize_request(req: Path):
|
|
@@ -898,8 +897,8 @@ def summarize_request(req: Path):
|
|
|
898
897
|
if audience_type_moved:
|
|
899
898
|
evidence['audience'].append('受众类型调整')
|
|
900
899
|
# 旧的覆盖、分布及逐枚举成效说明不再进入 Excel;明细统一按维度和指标重建。
|
|
901
|
-
details = build_dimension_details(bl, active_channels, bids)
|
|
902
|
-
payload = {"rows": [row], "
|
|
900
|
+
details = write_detail_rows(req, build_dimension_details(bl, active_channels, bids))
|
|
901
|
+
payload = {"rows": [row], "detailSource": details.reference(req)}
|
|
903
902
|
(req / "payload.json").write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
904
903
|
summary = {
|
|
905
904
|
"bizLineId": biz_id,
|
|
@@ -916,7 +915,8 @@ def summarize_request(req: Path):
|
|
|
916
915
|
"request_dir": str(req),
|
|
917
916
|
}
|
|
918
917
|
(req / "summary.json").write_text(json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
919
|
-
|
|
918
|
+
# 内存引用仅包含路径和行数,兼容工作流的多次遍历;磁盘 payload 不嵌入明细。
|
|
919
|
+
return {**payload, 'details': details}, summary
|
|
920
920
|
|
|
921
921
|
|
|
922
922
|
def main() -> int:
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""明细落盘协议:完整、可重复读取,失败不发布半成品。"""
|
|
2
|
+
import importlib.util
|
|
3
|
+
import json
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
import weakref
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
import tempfile
|
|
9
|
+
import unittest
|
|
10
|
+
from unittest.mock import patch
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class DetailStoreTests(unittest.TestCase):
|
|
14
|
+
def store(self):
|
|
15
|
+
self.assertIsNotNone(importlib.util.find_spec('detail_store'), '尚未提供落盘明细读取器')
|
|
16
|
+
import detail_store
|
|
17
|
+
return detail_store
|
|
18
|
+
|
|
19
|
+
def test_repeated_iteration_and_serializable_reference(self):
|
|
20
|
+
store = self.store()
|
|
21
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
22
|
+
root = Path(tmp)
|
|
23
|
+
rows = [{'kind': '广告', 'id': str(i), 'name': '中文\n名称'} for i in range(3)]
|
|
24
|
+
source = store.write_detail_rows(root, iter(rows))
|
|
25
|
+
self.assertEqual(len(source), 3)
|
|
26
|
+
self.assertEqual(list(source), rows)
|
|
27
|
+
self.assertEqual(list(source), rows)
|
|
28
|
+
packet = json.loads(json.dumps({'detailSource': source.reference(root)}))
|
|
29
|
+
self.assertEqual(list(store.details_from_payload(packet, root)), rows)
|
|
30
|
+
self.assertFalse(any(root.glob('*.part')))
|
|
31
|
+
self.assertEqual(list(store.write_detail_rows(root, iter([]))), [])
|
|
32
|
+
|
|
33
|
+
def test_count_corruption_and_ambiguous_source_fail(self):
|
|
34
|
+
store = self.store()
|
|
35
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
36
|
+
root = Path(tmp)
|
|
37
|
+
source = store.write_detail_rows(root, iter([{'id': '1'}]))
|
|
38
|
+
for count in (0, 2):
|
|
39
|
+
with self.assertRaises(ValueError):
|
|
40
|
+
list(store.DetailRows(source.path, count))
|
|
41
|
+
source.path.write_text('{bad}\n', encoding='utf-8')
|
|
42
|
+
with self.assertRaises(ValueError):
|
|
43
|
+
list(source)
|
|
44
|
+
for value in ([], {'version': 2, 'path': source.path.name, 'count': 1},
|
|
45
|
+
{'version': 1, 'path': '../outside.jsonl', 'count': 1},
|
|
46
|
+
{'version': 1, 'path': source.path.name, 'count': True}):
|
|
47
|
+
with self.assertRaises(ValueError):
|
|
48
|
+
store.details_from_payload({'detailSource': value}, root)
|
|
49
|
+
with self.assertRaises(ValueError):
|
|
50
|
+
store.details_from_payload({'detailSource': source.reference(root), 'details': []}, root)
|
|
51
|
+
self.assertEqual(store.details_from_payload({'details': [{'id': 'old'}]}, root), [{'id': 'old'}])
|
|
52
|
+
|
|
53
|
+
def test_partial_write_closes_generator_and_publishes_nothing(self):
|
|
54
|
+
store = self.store()
|
|
55
|
+
closed = []
|
|
56
|
+
def records():
|
|
57
|
+
try:
|
|
58
|
+
yield {'id': '1'}
|
|
59
|
+
raise OSError('模拟磁盘写入失败')
|
|
60
|
+
finally:
|
|
61
|
+
closed.append(True)
|
|
62
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
63
|
+
with self.assertRaises(OSError):
|
|
64
|
+
store.write_detail_rows(Path(tmp), records())
|
|
65
|
+
self.assertEqual(closed, [True])
|
|
66
|
+
self.assertEqual(list(Path(tmp).iterdir()), [])
|
|
67
|
+
|
|
68
|
+
def test_disk_failure_closes_suspended_generator(self):
|
|
69
|
+
store = self.store()
|
|
70
|
+
closed = []
|
|
71
|
+
def records():
|
|
72
|
+
try:
|
|
73
|
+
yield {'id': '1'}
|
|
74
|
+
yield {'id': '2'}
|
|
75
|
+
finally:
|
|
76
|
+
closed.append(True)
|
|
77
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
78
|
+
with patch.object(store.json, 'dumps', side_effect=OSError('磁盘故障模拟')):
|
|
79
|
+
with self.assertRaises(OSError):
|
|
80
|
+
store.write_detail_rows(Path(tmp), records())
|
|
81
|
+
self.assertEqual(closed, [True])
|
|
82
|
+
self.assertEqual(list(Path(tmp).iterdir()), [])
|
|
83
|
+
|
|
84
|
+
def test_previous_source_stays_valid_after_another_summary(self):
|
|
85
|
+
store = self.store()
|
|
86
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
87
|
+
old = store.write_detail_rows(Path(tmp), iter([{'id': 'old'}]))
|
|
88
|
+
new = store.write_detail_rows(Path(tmp), iter([{'id': 'new'}]))
|
|
89
|
+
self.assertEqual(list(old), [{'id': 'old'}])
|
|
90
|
+
self.assertEqual(list(new), [{'id': 'new'}])
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def test_closed_generator_releases_analysis_objects(self):
|
|
94
|
+
store = self.store()
|
|
95
|
+
references = []
|
|
96
|
+
class Aggregate:
|
|
97
|
+
pass
|
|
98
|
+
def records():
|
|
99
|
+
aggregate = Aggregate()
|
|
100
|
+
references.append(weakref.ref(aggregate))
|
|
101
|
+
yield {'id': '1'}
|
|
102
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
103
|
+
source = store.write_detail_rows(Path(tmp), records())
|
|
104
|
+
self.assertIsNone(references[0]())
|
|
105
|
+
self.assertEqual(set(vars(source)), {'path', 'count'})
|
|
106
|
+
|
|
107
|
+
def test_diagnostic_incomplete_source_does_not_publish(self):
|
|
108
|
+
store = self.store()
|
|
109
|
+
script = Path(__file__).with_name('build_report.py')
|
|
110
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
111
|
+
root = Path(tmp)
|
|
112
|
+
source = store.write_detail_rows(root, iter([{'kind': '广告', 'id': '1'}]))
|
|
113
|
+
source.path.write_text('', encoding='utf-8')
|
|
114
|
+
packet = {'rows': [{'bizLine': '样本'}], 'detailSource': source.reference(root)}
|
|
115
|
+
output = root / 'broken.xlsx'
|
|
116
|
+
result = subprocess.run([sys.executable, '-B', str(script), '--input', '-', '--output', str(output)],
|
|
117
|
+
input=json.dumps(packet), text=True, capture_output=True, cwd=root)
|
|
118
|
+
self.assertNotEqual(result.returncode, 0)
|
|
119
|
+
self.assertFalse(output.exists())
|
|
120
|
+
|
|
121
|
+
if __name__ == '__main__':
|
|
122
|
+
unittest.main()
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import subprocess
|
|
3
|
+
import sys
|
|
4
|
+
import tempfile
|
|
5
|
+
import unittest
|
|
6
|
+
import zipfile
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from unittest.mock import patch
|
|
9
|
+
import build_report as report
|
|
10
|
+
from detail_store import write_detail_rows
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ExcelStreamingTests(unittest.TestCase):
|
|
14
|
+
def test_export_and_validation_do_not_materialize_xml(self):
|
|
15
|
+
with tempfile.TemporaryDirectory() as directory:
|
|
16
|
+
path = str(Path(directory) / 'test.xlsx')
|
|
17
|
+
details = [{'id': str(i), 'name': '名称'} for i in range(50)]
|
|
18
|
+
original_write = zipfile.ZipFile.writestr
|
|
19
|
+
def write(archive, name, *args, **kwargs):
|
|
20
|
+
if 'sharedStrings' in name or 'worksheets/sheet' in name:
|
|
21
|
+
raise AssertionError('整表 XML 写入')
|
|
22
|
+
return original_write(archive, name, *args, **kwargs)
|
|
23
|
+
with patch.object(zipfile.ZipFile, 'writestr', write):
|
|
24
|
+
report.write_xlsx([{'bizLine': '样本'}], path, details)
|
|
25
|
+
original = zipfile.ZipFile.read
|
|
26
|
+
def read(archive, name, *args, **kwargs):
|
|
27
|
+
if 'sharedStrings' in name or 'worksheets/sheet' in name:
|
|
28
|
+
raise AssertionError('整表 XML')
|
|
29
|
+
return original(archive, name, *args, **kwargs)
|
|
30
|
+
with patch.object(zipfile.ZipFile, 'read', read):
|
|
31
|
+
self.assertEqual(report.validate_xlsx(path, [{'bizLine': '样本'}], details)['details'], 50)
|
|
32
|
+
|
|
33
|
+
def test_bad_coordinate_is_rejected(self):
|
|
34
|
+
with tempfile.TemporaryDirectory() as directory:
|
|
35
|
+
path = Path(directory) / 'test.xlsx'
|
|
36
|
+
report.write_xlsx([{'bizLine': '样本'}], path, [{'id': '1'}])
|
|
37
|
+
with zipfile.ZipFile(path) as archive:
|
|
38
|
+
contents = {name: archive.read(name) for name in archive.namelist()}
|
|
39
|
+
contents['xl/worksheets/sheet2.xml'] = contents['xl/worksheets/sheet2.xml'].replace(b'r="A2"', b'r="Z9"')
|
|
40
|
+
with zipfile.ZipFile(path, 'w') as archive:
|
|
41
|
+
for name, content in contents.items():
|
|
42
|
+
archive.writestr(name, content)
|
|
43
|
+
with self.assertRaises(ValueError):
|
|
44
|
+
report.validate_xlsx(path, [{'bizLine': '样本'}], [{'id': '1'}])
|
|
45
|
+
|
|
46
|
+
def test_index_keeps_format_keys_and_bounded_hot_cache(self):
|
|
47
|
+
with tempfile.TemporaryDirectory() as directory:
|
|
48
|
+
with report._string_index(directory) as connection:
|
|
49
|
+
strings = report._Strings(connection)
|
|
50
|
+
self.assertEqual(strings.intern('【预算】'), 0)
|
|
51
|
+
self.assertEqual(strings.intern('【预算】', True), 1)
|
|
52
|
+
self.assertEqual(strings.intern('【预算】', (('【预算】', True),)), 2)
|
|
53
|
+
self.assertEqual(strings.intern('【预算】'), 0)
|
|
54
|
+
for index in range(4000):
|
|
55
|
+
strings.intern(str(index))
|
|
56
|
+
strings.intern('长' * 32767)
|
|
57
|
+
self.assertLessEqual(len(strings.cache), 512)
|
|
58
|
+
self.assertTrue(all(isinstance(key[1], bool) and len(key[0]) <= 512 for key in strings.cache))
|
|
59
|
+
self.assertEqual(list(Path(directory).iterdir()), [])
|
|
60
|
+
|
|
61
|
+
def test_hot_string_avoids_json_serialization(self):
|
|
62
|
+
with tempfile.TemporaryDirectory() as directory, report._string_index(directory) as connection:
|
|
63
|
+
strings = report._Strings(connection)
|
|
64
|
+
strings.intern('重复文字')
|
|
65
|
+
with patch.object(report.json, 'dumps', side_effect=AssertionError('命中缓存仍序列化')):
|
|
66
|
+
self.assertEqual(strings.intern('重复文字'), 0)
|
|
67
|
+
|
|
68
|
+
def test_xml_items_reject_invalid_hierarchy(self):
|
|
69
|
+
ns = report._NS['s']
|
|
70
|
+
cases = [
|
|
71
|
+
('row', f'<worksheet xmlns="{ns}"><notSheetData><row/></notSheetData></worksheet>'),
|
|
72
|
+
('row', f'<wrong xmlns="{ns}"><sheetData><row/></sheetData></wrong>'),
|
|
73
|
+
('row', f'<worksheet xmlns="{ns}"><sheetData/><sheetData/></worksheet>'),
|
|
74
|
+
('row', f'<worksheet xmlns="{ns}"><extra><sheetData><row/></sheetData></extra></worksheet>'),
|
|
75
|
+
('si', f'<wrong xmlns="{ns}"><si><t>文字</t></si></wrong>'),
|
|
76
|
+
('si', f'<sst xmlns="{ns}"><extra><si><t>文字</t></si></extra></sst>'),
|
|
77
|
+
]
|
|
78
|
+
for tag, xml in cases:
|
|
79
|
+
with self.subTest(xml=xml), tempfile.TemporaryDirectory() as directory:
|
|
80
|
+
path = Path(directory) / 'bad.zip'
|
|
81
|
+
with zipfile.ZipFile(path, 'w') as archive:
|
|
82
|
+
archive.writestr('part.xml', xml)
|
|
83
|
+
with zipfile.ZipFile(path) as archive:
|
|
84
|
+
with self.assertRaises(ValueError):
|
|
85
|
+
list(report._xml_items(archive, 'part.xml', tag))
|
|
86
|
+
|
|
87
|
+
def test_validation_queries_missing_strings_by_row(self):
|
|
88
|
+
with tempfile.TemporaryDirectory() as directory:
|
|
89
|
+
path = Path(directory) / 'rows.xlsx'
|
|
90
|
+
details = [{key: f'{key}-{i}' for key, _ in report.DETAIL_COLUMNS} for i in range(20)]
|
|
91
|
+
report.write_detail_workbook(details, path)
|
|
92
|
+
with zipfile.ZipFile(path) as archive, report._string_index(directory) as connection:
|
|
93
|
+
report._load_strings(archive, connection)
|
|
94
|
+
queries = []
|
|
95
|
+
connection.set_trace_callback(lambda sql: queries.append(sql) if sql.startswith('SELECT') else None)
|
|
96
|
+
report._validate_sheet(archive, 'xl/worksheets/sheet1.xml', details, report.DETAIL_COLUMNS, connection)
|
|
97
|
+
self.assertLessEqual(len(queries), len(details) + 1)
|
|
98
|
+
|
|
99
|
+
def test_failed_write_removes_partial_file_and_index(self):
|
|
100
|
+
for exception in (OSError('磁盘写入失败'), KeyboardInterrupt()):
|
|
101
|
+
with self.subTest(exception=type(exception).__name__), tempfile.TemporaryDirectory() as directory:
|
|
102
|
+
path = Path(directory) / 'partial.xlsx'
|
|
103
|
+
with patch.object(report._Strings, 'write', side_effect=exception):
|
|
104
|
+
with self.assertRaises(type(exception)):
|
|
105
|
+
report.write_detail_workbook([{'id': '1'}], path)
|
|
106
|
+
self.assertEqual(list(Path(directory).iterdir()), [])
|
|
107
|
+
|
|
108
|
+
def test_index_creation_failure_and_corrupt_zip_fail(self):
|
|
109
|
+
with tempfile.TemporaryDirectory() as directory:
|
|
110
|
+
path = Path(directory) / 'test.xlsx'
|
|
111
|
+
with patch.object(report.sqlite3, 'connect', side_effect=report.sqlite3.OperationalError('无法创建索引')):
|
|
112
|
+
with self.assertRaises(report.sqlite3.OperationalError):
|
|
113
|
+
report.write_detail_workbook([{'id': '1'}], path)
|
|
114
|
+
self.assertEqual(list(Path(directory).iterdir()), [])
|
|
115
|
+
report.write_detail_workbook([{'id': '1'}], path)
|
|
116
|
+
path.write_bytes(path.read_bytes()[:-22])
|
|
117
|
+
with self.assertRaises(zipfile.BadZipFile):
|
|
118
|
+
report.validate_detail_workbook(path, [{'id': '1'}])
|
|
119
|
+
self.assertFalse(list(Path(directory).glob('.excel-index-*')))
|
|
120
|
+
|
|
121
|
+
def test_late_corruption_and_missing_or_extra_rows(self):
|
|
122
|
+
cases = {
|
|
123
|
+
'末行错值': lambda xml: xml.replace(b'<v>48</v>', b'<v>0</v>'),
|
|
124
|
+
'缺行': lambda xml: __import__('re').sub(rb'<row r="31".*?</row>', b'', xml),
|
|
125
|
+
'多行': lambda xml: xml.replace(b'</sheetData>', b'<row r="32"></row></sheetData>'),
|
|
126
|
+
'越界': lambda xml: xml.replace(b'<v>48</v>', b'<v>999999</v>'),
|
|
127
|
+
'负索引': lambda xml: xml.replace(b'<v>48</v>', b'<v>-1</v>'),
|
|
128
|
+
'XML末尾损坏': lambda xml: xml[:-3],
|
|
129
|
+
'错误明细父节点': lambda xml: xml.replace(b'sheetData', b'notSheetData'),
|
|
130
|
+
'重复明细容器': lambda xml: xml.replace(b'</worksheet>', b'<sheetData/></worksheet>'),
|
|
131
|
+
'错误根节点': lambda xml: xml.replace(b'worksheet', b'wrongRoot'),
|
|
132
|
+
}
|
|
133
|
+
details = [{'id': str(i)} for i in range(30)]
|
|
134
|
+
for label, mutate in cases.items():
|
|
135
|
+
with self.subTest(label=label), tempfile.TemporaryDirectory() as directory:
|
|
136
|
+
path = Path(directory) / 'test.xlsx'
|
|
137
|
+
report.write_detail_workbook(details, path)
|
|
138
|
+
with zipfile.ZipFile(path) as archive:
|
|
139
|
+
content = {name: archive.read(name) for name in archive.namelist()}
|
|
140
|
+
original = content['xl/worksheets/sheet1.xml']
|
|
141
|
+
content['xl/worksheets/sheet1.xml'] = mutate(original)
|
|
142
|
+
self.assertNotEqual(original, content['xl/worksheets/sheet1.xml'])
|
|
143
|
+
with zipfile.ZipFile(path, 'w') as archive:
|
|
144
|
+
for name, value in content.items():
|
|
145
|
+
archive.writestr(name, value)
|
|
146
|
+
with self.assertRaises((ValueError, report.ET.ParseError)):
|
|
147
|
+
report.validate_detail_workbook(path, details)
|
|
148
|
+
|
|
149
|
+
def test_ai_read_scans_shared_strings_to_end_without_reading_whole_part(self):
|
|
150
|
+
ai = '速览 **重点**\n【预算策略】\n预算\n【广告产品策略】\n产品\n【受众策略】\n受众\n【创意策略】\n创意'
|
|
151
|
+
with tempfile.TemporaryDirectory() as directory:
|
|
152
|
+
path = Path(directory) / 'ai.xlsx'
|
|
153
|
+
report.write_xlsx([{'bizLine': '样本'}], path, [{'name': str(i)} for i in range(2000)], ai_strategy=ai)
|
|
154
|
+
original_read = zipfile.ZipFile.read
|
|
155
|
+
def read(archive, name, *args, **kwargs):
|
|
156
|
+
if name == 'xl/sharedStrings.xml':
|
|
157
|
+
raise AssertionError('整表共享字符串')
|
|
158
|
+
return original_read(archive, name, *args, **kwargs)
|
|
159
|
+
with patch.object(zipfile.ZipFile, 'read', read):
|
|
160
|
+
self.assertEqual(report.read_ai_sheet(path)['一页速览'], '速览 重点')
|
|
161
|
+
with zipfile.ZipFile(path) as archive:
|
|
162
|
+
contents = {name: archive.read(name) for name in archive.namelist()}
|
|
163
|
+
contents['xl/sharedStrings.xml'] += b'<broken'
|
|
164
|
+
with zipfile.ZipFile(path, 'w') as archive:
|
|
165
|
+
for name, value in contents.items():
|
|
166
|
+
archive.writestr(name, value)
|
|
167
|
+
with self.assertRaises(report.ET.ParseError):
|
|
168
|
+
report.read_ai_sheet(path)
|
|
169
|
+
|
|
170
|
+
def test_diagnostic_file_and_stdin_support_both_detail_formats(self):
|
|
171
|
+
with tempfile.TemporaryDirectory() as directory:
|
|
172
|
+
root = Path(directory)
|
|
173
|
+
source = write_detail_rows(root, [{'id': '1', 'name': '明细'}])
|
|
174
|
+
payloads = [
|
|
175
|
+
{'rows': [{'bizLine': '样本'}], 'details': [{'id': '1', 'name': '明细'}]},
|
|
176
|
+
{'rows': [{'bizLine': '样本'}], 'detailSource':
|
|
177
|
+
{'version': 1, 'path': source.path.name, 'count': 1}},
|
|
178
|
+
]
|
|
179
|
+
for number, payload in enumerate(payloads):
|
|
180
|
+
for mode in ('file', 'stdin'):
|
|
181
|
+
path = root / f'{number}-{mode}.xlsx'
|
|
182
|
+
input_path = root / 'payload.json'
|
|
183
|
+
input_path.write_text(json.dumps(payload), encoding='utf-8')
|
|
184
|
+
result = subprocess.run([sys.executable, '-B', str(Path(report.__file__).resolve()),
|
|
185
|
+
'--input', str(input_path) if mode == 'file' else '-',
|
|
186
|
+
'--output', str(path)], input=json.dumps(payload), text=True,
|
|
187
|
+
capture_output=True, cwd=root)
|
|
188
|
+
self.assertEqual(result.returncode, 0, result.stderr)
|
|
189
|
+
self.assertEqual(report.validate_xlsx(path, payload['rows'], source)['details'], 1)
|
|
190
|
+
payloads[1]['detailSource']['version'] = 999
|
|
191
|
+
payloads[1]['details'] = payloads[0]['details']
|
|
192
|
+
result = subprocess.run([sys.executable, '-B', str(Path(report.__file__).resolve()),
|
|
193
|
+
'--input', '-', '--output', str(root / 'bad.xlsx')],
|
|
194
|
+
input=json.dumps(payloads[1]), text=True, capture_output=True, cwd=root)
|
|
195
|
+
self.assertNotEqual(result.returncode, 0)
|
|
196
|
+
self.assertFalse((root / 'bad.xlsx').exists())
|
|
197
|
+
|
|
198
|
+
def test_file_source_index_stays_with_source_and_existing_output_is_preserved(self):
|
|
199
|
+
with tempfile.TemporaryDirectory() as directory:
|
|
200
|
+
root = Path(directory)
|
|
201
|
+
source_root, output_root = root / 'source', root / 'published'
|
|
202
|
+
source_root.mkdir()
|
|
203
|
+
output_root.mkdir()
|
|
204
|
+
source = write_detail_rows(source_root, [{'id': '1'}])
|
|
205
|
+
output = output_root / 'test.xlsx'
|
|
206
|
+
original = report._string_index
|
|
207
|
+
observed = []
|
|
208
|
+
def index(path):
|
|
209
|
+
observed.append(Path(path))
|
|
210
|
+
return original(path)
|
|
211
|
+
with patch.object(report, '_string_index', index):
|
|
212
|
+
report.write_detail_workbook(source, output)
|
|
213
|
+
report.validate_detail_workbook(output, source)
|
|
214
|
+
self.assertEqual(observed, [source_root, source_root])
|
|
215
|
+
before = output.read_bytes()
|
|
216
|
+
with self.assertRaises(FileExistsError):
|
|
217
|
+
report.write_detail_workbook(source, output)
|
|
218
|
+
self.assertEqual(output.read_bytes(), before)
|
|
219
|
+
self.assertEqual(list(source_root.glob('.excel-index-*')), [])
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
if __name__ == '__main__':
|
|
223
|
+
unittest.main()
|
|
@@ -11,6 +11,7 @@ from unittest.mock import patch
|
|
|
11
11
|
sys.dont_write_bytecode = True
|
|
12
12
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
13
13
|
from run_workflow import run_batch
|
|
14
|
+
from detail_store import write_detail_rows
|
|
14
15
|
from strategy_output import submit_tag_choices
|
|
15
16
|
from test_workflow import arguments, small_payload
|
|
16
17
|
from test_ai_strategy import AI_TEXT
|
|
@@ -27,8 +28,10 @@ class SlidingPipeline(unittest.TestCase):
|
|
|
27
28
|
for key in ('budget', 'product', 'audience')}
|
|
28
29
|
def analyze(directory):
|
|
29
30
|
payload = small_payload()
|
|
31
|
+
details = write_detail_rows(directory, iter(payload.pop('details')))
|
|
32
|
+
payload['detailSource'] = details.reference(directory)
|
|
30
33
|
(directory / 'payload.json').write_text(json.dumps(payload), encoding='utf-8')
|
|
31
|
-
return payload, {'detail_count':
|
|
34
|
+
return {**payload, 'details': details}, {'detail_count': len(details), 'curr_total': 100}
|
|
32
35
|
with patch('run_workflow.fetch_business', side_effect=fetch), \
|
|
33
36
|
patch('run_workflow.summarize_request', side_effect=analyze), \
|
|
34
37
|
patch('strategy_output.TagCache.get', return_value=dictionaries):
|