@1aboveio/skills 1.0.10 → 1.0.12

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.
Files changed (214) hide show
  1. package/.codex-plugin/plugin.json +1 -1
  2. package/.codex-plugin/resources.json +298 -216
  3. package/README.md +3 -3
  4. package/package.json +10 -8
  5. package/skills/app-dev/airflow/SKILL.md +1 -1
  6. package/skills/app-dev/android-foundation/SKILL.md +35 -37
  7. package/skills/app-dev/android-foundation/references/delivery-focus.md +4 -2
  8. package/skills/app-dev/android-foundation/references/feature-work.md +50 -0
  9. package/skills/app-dev/android-foundation/references/setup.md +24 -9
  10. package/skills/app-dev/android-foundation/references/workspace.md +81 -0
  11. package/skills/app-dev/android-foundation/scripts/workspace-runtime/bin/workspaces.mjs +52 -0
  12. package/skills/app-dev/android-foundation/scripts/workspace-runtime/native-workspace.mjs +239 -0
  13. package/skills/app-dev/android-foundation/scripts/workspace-runtime/workspace-lifecycle.mjs +2154 -0
  14. package/skills/app-dev/app-debug/SKILL.md +1 -1
  15. package/skills/app-dev/better-auth/SKILL.md +1 -1
  16. package/skills/app-dev/data-catalog/SKILL.md +1 -1
  17. package/skills/app-dev/data-quality/SKILL.md +1 -1
  18. package/skills/app-dev/ios-foundation/SKILL.md +15 -10
  19. package/skills/app-dev/ios-foundation/references/setup.md +25 -9
  20. package/skills/app-dev/ios-foundation/references/workspace.md +82 -0
  21. package/skills/app-dev/ios-foundation/scripts/workspace-runtime/bin/workspaces.mjs +52 -0
  22. package/skills/app-dev/ios-foundation/scripts/workspace-runtime/native-workspace.mjs +239 -0
  23. package/skills/app-dev/ios-foundation/scripts/workspace-runtime/workspace-lifecycle.mjs +2154 -0
  24. package/skills/app-dev/monorepo/SKILL.md +1 -1
  25. package/skills/app-dev/nextjs-fullstack/SKILL.md +1 -1
  26. package/skills/app-dev/prisma-setup/SKILL.md +1 -1
  27. package/skills/app-dev/pyspark/SKILL.md +1 -1
  28. package/skills/app-dev/python-backend/SKILL.md +1 -1
  29. package/skills/app-dev/shadcn/SKILL.md +1 -1
  30. package/skills/app-dev/zod-v4/SKILL.md +1 -1
  31. package/skills/cicd/cloud-build/SKILL.md +1 -1
  32. package/skills/cicd/cloud-debug/SKILL.md +1 -1
  33. package/skills/cicd/cloud-deploy/SKILL.md +1 -1
  34. package/skills/cicd/docker/SKILL.md +1 -1
  35. package/skills/cicd/github-actions/SKILL.md +1 -1
  36. package/skills/cicd/github-flow/SKILL.md +1 -1
  37. package/skills/cicd/google-cloud/SKILL.md +1 -1
  38. package/skills/cicd/mergify/SKILL.md +1 -1
  39. package/skills/cicd/podman/SKILL.md +1 -1
  40. package/skills/content/article-review/SKILL.md +1 -1
  41. package/skills/content/article-to-image/SKILL.md +1 -1
  42. package/skills/content/flowchart-svg/SKILL.md +1 -1
  43. package/skills/content/image-gen/SKILL.md +1 -1
  44. package/skills/content/mermaid/SKILL.md +1 -1
  45. package/skills/content/wechat-article-writer/SKILL.md +1 -1
  46. package/skills/content/wechat-publisher/SKILL.md +1 -1
  47. package/skills/content/wechat-search/SKILL.md +1 -1
  48. package/skills/content/xai-search/SKILL.md +1 -1
  49. package/skills/core/distill-writing/SKILL.md +3 -3
  50. package/skills/core/distill-writing/references/extraction.md +1 -1
  51. package/skills/core/doc-to-md/SKILL.md +1 -1
  52. package/skills/core/editorial-review/SKILL.md +82 -12
  53. package/skills/core/editorial-review/references/checklist.md +26 -1
  54. package/skills/core/house-style/SKILL.md +8 -3
  55. package/skills/core/house-style/references/analytical-reporting.md +123 -0
  56. package/skills/core/house-style/references/analytical-writing-patterns.md +79 -0
  57. package/skills/core/house-style/references/analytical-writing-research.md +129 -0
  58. package/skills/core/{write-doc → house-style}/references/bilingual.md +7 -5
  59. package/skills/core/house-style/references/definitions-and-metrics.md +86 -0
  60. package/skills/core/house-style/references/document-workflow.md +193 -0
  61. package/skills/core/house-style/references/initialization.md +66 -0
  62. package/skills/core/house-style/references/persistence.md +9 -4
  63. package/skills/core/house-style/references/rules.md +11 -4
  64. package/skills/core/house-style/scripts/init-guidance.mjs +213 -0
  65. package/skills/core/house-style/templates/analytical-report.md +54 -0
  66. package/skills/core/house-style/templates/gap-analysis.md +1 -1
  67. package/skills/core/plain-language/SKILL.md +2 -2
  68. package/skills/core/polish-cn/SKILL.md +9 -2
  69. package/skills/core/reg-parser/SKILL.md +3 -3
  70. package/skills/core/terminology/SKILL.md +8 -4
  71. package/skills/core/write-doc/SKILL.md +40 -151
  72. package/skills/core/write-report/SKILL.md +129 -0
  73. package/skills/core/{write-doc → write-report}/references/compliance-reports.md +4 -4
  74. package/skills/engineering/better-review/SKILL.md +37 -37
  75. package/skills/engineering/better-review/references/passes.md +35 -41
  76. package/skills/engineering/better-review/references/result.md +10 -9
  77. package/skills/engineering/e2e-test/SKILL.md +17 -17
  78. package/skills/engineering/e2e-test/references/authoring/playwright-config.md +11 -13
  79. package/skills/engineering/e2e-test/references/presentation-sweep.md +7 -7
  80. package/skills/engineering/e2e-test/references/reviewing.md +9 -11
  81. package/skills/engineering/e2e-test/references/route-manifest.md +1 -1
  82. package/skills/engineering/engineering/SKILL.md +201 -0
  83. package/skills/engineering/engineering/references/engineering-conventions.md +32 -0
  84. package/skills/engineering/engineering/references/feature-delivery.md +8 -0
  85. package/skills/engineering/engineering/references/init-scope.md +45 -0
  86. package/skills/engineering/engineering/references/scope-management.md +85 -0
  87. package/skills/engineering/engineering/scripts/init.mjs +160 -0
  88. package/skills/engineering/{contributing → engineering}/scripts/persist-conventions.mjs +1 -1
  89. package/skills/engineering/engineering-runtime/SKILL.md +1 -1
  90. package/skills/engineering/engineering-runtime/coherence/workflow.json +19 -19
  91. package/skills/engineering/engineering-runtime/scripts/workflow-policy.mjs +1 -1
  92. package/skills/engineering/ensure-coverage/SKILL.md +22 -109
  93. package/skills/engineering/ensure-coverage/references/breadth/coverage-ledger.md +7 -5
  94. package/skills/engineering/ensure-coverage/references/breadth/surface-baseline.md +4 -4
  95. package/skills/engineering/ensure-coverage/references/breadth/surface-discovery.md +3 -3
  96. package/skills/engineering/ensure-coverage/references/depth/characterization.md +4 -65
  97. package/skills/engineering/ensure-coverage/references/depth/grading.md +5 -7
  98. package/skills/engineering/ensure-coverage/references/depth/mock-policy.md +6 -12
  99. package/skills/engineering/ensure-coverage/references/enforcement/ci-contract.md +1 -1
  100. package/skills/engineering/ensure-coverage/references/presentation/axis.md +6 -6
  101. package/skills/engineering/ensure-coverage/references/process/audit-mode.md +7 -23
  102. package/skills/engineering/ensure-coverage/references/process/output-template.md +18 -133
  103. package/skills/engineering/ensure-coverage/references/process/test-strategy.md +7 -12
  104. package/skills/engineering/ensure-coverage/scripts/README.md +12 -4
  105. package/skills/engineering/golden-path/SKILL.md +1 -1
  106. package/skills/engineering/harness-runtime/SKILL.md +3 -1
  107. package/skills/engineering/harness-runtime/bin/workspaces.mjs +52 -0
  108. package/skills/engineering/harness-runtime/native-workspace.mjs +239 -0
  109. package/skills/engineering/harness-runtime/references/workspaces.md +78 -0
  110. package/skills/engineering/harness-runtime/workspace-lifecycle.mjs +2154 -0
  111. package/skills/engineering/resolve-issues/SKILL.md +40 -61
  112. package/skills/engineering/resolve-issues/references/ci.md +13 -3
  113. package/skills/engineering/resolve-issues/references/delivery-progress.md +4 -3
  114. package/skills/engineering/resolve-issues/references/done.md +11 -2
  115. package/skills/engineering/resolve-issues/references/expire.md +9 -2
  116. package/skills/engineering/resolve-issues/references/implementation.md +33 -17
  117. package/skills/engineering/resolve-issues/references/preflight.md +3 -1
  118. package/skills/engineering/resolve-issues/references/principles.md +52 -0
  119. package/skills/engineering/resolve-issues/references/review.md +25 -15
  120. package/skills/engineering/resolve-issues/references/task-plan.md +64 -27
  121. package/skills/engineering/resolve-issues/references/workspaces.md +164 -0
  122. package/skills/engineering/resolve-issues/scripts/cleanup-runs.mjs +185 -317
  123. package/skills/engineering/resolve-issues/scripts/next-actions.mjs +44 -7
  124. package/skills/engineering/resolve-issues/scripts/rehydrate.mjs +36 -2
  125. package/skills/engineering/resolve-issues/scripts/review-verdict.mjs +9 -8
  126. package/skills/engineering/resolve-issues/scripts/workspace-lifecycle.mjs +1 -0
  127. package/skills/engineering/resolve-release/SKILL.md +1 -1
  128. package/skills/engineering/rush-issues/SKILL.md +7 -5
  129. package/skills/engineering/rush-release/SKILL.md +1 -1
  130. package/skills/engineering/test-quality/SKILL.md +19 -4
  131. package/skills/engineering/test-quality/references/lint.md +1 -1
  132. package/skills/engineering/test-quality/references/principles.md +2 -2
  133. package/skills/engineering/test-quality/references/testing-conventions.md +1 -1
  134. package/skills/risk-compliance/compliance-audit/SKILL.md +4 -8
  135. package/skills/risk-compliance/compliance-audit/references/audience-and-reports.md +12 -0
  136. package/skills/risk-compliance/contract-compare/SKILL.md +17 -1
  137. package/skills/risk-compliance/contract-review/SKILL.md +6 -1
  138. package/skills/risk-compliance/contract-review/references/report-stage.md +17 -0
  139. package/skills/risk-compliance/doc-authenticity/SKILL.md +11 -1
  140. package/skills/risk-compliance/fraud-detection/LICENSE +3 -0
  141. package/skills/risk-compliance/fraud-detection/SKILL.md +148 -0
  142. package/skills/risk-compliance/{fraud-analysis → fraud-detection}/references/archetypes/first-party-fraud.md +3 -2
  143. package/skills/risk-compliance/fraud-detection/references/archetypes/third-party-fraud.md +47 -0
  144. package/skills/risk-compliance/fraud-detection/references/collection-payout-features.md +151 -0
  145. package/skills/risk-compliance/{fraud-analysis → fraud-detection}/references/contexts/bank-transfer.md +4 -3
  146. package/skills/risk-compliance/fraud-detection/references/contexts/card-payment.md +70 -0
  147. package/skills/risk-compliance/fraud-detection/references/contexts/payment-collection.md +34 -0
  148. package/skills/risk-compliance/fraud-detection/references/contexts/payout.md +34 -0
  149. package/skills/risk-compliance/fraud-detection/references/enrichment.md +74 -0
  150. package/skills/risk-compliance/fraud-detection/references/entities.md +48 -0
  151. package/skills/risk-compliance/{fraud-analysis → fraud-detection}/references/feature-engineering.md +69 -33
  152. package/skills/risk-compliance/fraud-detection/references/feature-sql.md +150 -0
  153. package/skills/risk-compliance/{fraud-analysis → fraud-detection}/references/report-rationale.md +4 -2
  154. package/skills/risk-compliance/{fraud-analysis → fraud-detection}/references/report-template.md +45 -23
  155. package/skills/risk-compliance/{fraud-analysis → fraud-detection}/references/review-checklist.md +60 -20
  156. package/skills/risk-compliance/fraud-detection/references/workflow.md +216 -0
  157. package/skills/risk-compliance/fraud-detection/release-check.mjs +95 -0
  158. package/skills/risk-compliance/payment-analysis/SKILL.md +36 -17
  159. package/skills/risk-compliance/payment-analysis/references/auth-rate-actions.md +2 -2
  160. package/skills/risk-compliance/payment-analysis/references/report-template.md +10 -2
  161. package/skills/risk-compliance/payment-analysis/references/visualization.md +11 -1
  162. package/skills/risk-compliance/portfolio-payment-analysis/LICENSE +3 -0
  163. package/skills/risk-compliance/portfolio-payment-analysis/SKILL.md +170 -0
  164. package/skills/risk-compliance/portfolio-payment-analysis/references/identity-and-reconciliation.md +46 -0
  165. package/skills/risk-compliance/portfolio-payment-analysis/references/metrics-and-reporting.md +170 -0
  166. package/skills/risk-compliance/portfolio-payment-analysis/references/risk-history.md +89 -0
  167. package/skills/risk-compliance/portfolio-payment-analysis/release-check.mjs +95 -0
  168. package/skills/risk-compliance/portfolio-payment-analysis/scripts/analyze.py +636 -0
  169. package/skills/risk-compliance/portfolio-payment-analysis/scripts/build_report.py +386 -0
  170. package/skills/risk-compliance/portfolio-payment-analysis/scripts/comparison_charts.py +425 -0
  171. package/skills/risk-compliance/portfolio-payment-analysis/scripts/csv_export.py +52 -0
  172. package/skills/risk-compliance/portfolio-payment-analysis/scripts/render.py +160 -0
  173. package/skills/risk-compliance/portfolio-payment-analysis/scripts/risk_history.py +435 -0
  174. package/skills/risk-compliance/similar-domain/SKILL.md +9 -4
  175. package/skills/risk-compliance/tld-detection/SKILL.md +124 -239
  176. package/skills/risk-compliance/tld-detection/agents/openai.yaml +1 -1
  177. package/skills/risk-compliance/tld-detection/references/clean.md +18 -51
  178. package/skills/risk-compliance/tld-detection/references/fx-conversion.md +56 -219
  179. package/skills/risk-compliance/tld-detection/references/metric-contract.md +109 -0
  180. package/skills/risk-compliance/tld-detection/references/pattern.md +99 -0
  181. package/skills/risk-compliance/tld-detection/references/report-template.md +49 -272
  182. package/skills/risk-compliance/tld-detection/scripts/detect_ladders.py +46 -13
  183. package/skills/risk-compliance/tld-detection/scripts/detect_patterns.py +346 -0
  184. package/skills/risk-compliance/tld-detection/scripts/detect_streams.py +51 -19
  185. package/skills/risk-compliance/tld-detection/scripts/profile_groups.py +109 -0
  186. package/skills/risk-compliance/tld-detection/scripts/render_charts.py +218 -0
  187. package/skills/risk-compliance/tld-detection/scripts/tld_common.py +243 -0
  188. package/skills/engineering/contributing/SKILL.md +0 -70
  189. package/skills/risk-compliance/fraud-analysis/SKILL.md +0 -114
  190. package/skills/risk-compliance/fraud-analysis/references/archetypes/third-party-fraud.md +0 -27
  191. package/skills/risk-compliance/fraud-analysis/references/contexts/card-payment.md +0 -30
  192. package/skills/risk-compliance/fraud-analysis/references/contexts/payment-collection.md +0 -20
  193. package/skills/risk-compliance/fraud-analysis/references/contexts/payout.md +0 -20
  194. package/skills/risk-compliance/fraud-analysis/references/workflow.md +0 -175
  195. package/skills/risk-compliance/tld-detection/references/chinese-polish.md +0 -55
  196. package/skills/risk-compliance/tld-detection/references/clustering.md +0 -235
  197. package/skills/risk-compliance/tld-detection/references/decision.md +0 -189
  198. package/skills/risk-compliance/tld-detection/references/streams.md +0 -141
  199. package/skills/risk-compliance/tld-detection/references/terminology.md +0 -95
  200. package/skills/risk-compliance/tld-detection/references/workflow.md +0 -95
  201. /package/skills/{engineering/contributing → core/write-report}/LICENSE +0 -0
  202. /package/skills/core/{write-doc → write-report}/references/reports/audit-report-template.md +0 -0
  203. /package/skills/core/{write-doc → write-report}/references/reports/external-assessment-template.md +0 -0
  204. /package/skills/core/{write-doc → write-report}/references/reports/management-report-template.md +0 -0
  205. /package/skills/core/{write-doc → write-report}/references/reports/vars-report-template.md +0 -0
  206. /package/skills/{engineering/contributing → core/write-report}/release-check.mjs +0 -0
  207. /package/skills/{risk-compliance/fraud-analysis → engineering/engineering}/LICENSE +0 -0
  208. /package/skills/engineering/{contributing → engineering}/references/tracker-conventions.md +0 -0
  209. /package/skills/{risk-compliance/fraud-analysis → engineering/engineering}/release-check.mjs +0 -0
  210. /package/skills/engineering/{contributing → engineering}/scripts/persist-conventions-core.mjs +0 -0
  211. /package/skills/risk-compliance/{fraud-analysis → fraud-detection}/references/archetypes/authorized-payment-scam.md +0 -0
  212. /package/skills/risk-compliance/{fraud-analysis → fraud-detection}/references/mechanisms/account-takeover.md +0 -0
  213. /package/skills/risk-compliance/{fraud-analysis → fraud-detection}/references/taxonomy.md +0 -0
  214. /package/skills/risk-compliance/{fraud-analysis → fraud-detection}/references/terminology.md +0 -0
@@ -0,0 +1,425 @@
1
+ """Standalone, offline week-comparison charts for report renderers.
2
+
3
+ Append CSS to the report stylesheet and JS to its script. chart() returns an
4
+ HTML fragment and its Markdown equivalent; identifiers should be unique per page.
5
+ """
6
+ import hashlib
7
+ import html
8
+ import math
9
+ import re
10
+ from decimal import Decimal, InvalidOperation
11
+
12
+ CSS = """
13
+ .comparison-chart{max-width:100%;min-width:0;margin:24px 0;scroll-margin-top:90px}.comparison-chart h3{margin-bottom:8px}.comparison-chart select{max-width:100%;font:inherit;padding:6px}.comparison-chart select:focus-visible{outline:3px solid #c16613;outline-offset:3px}.comparison-legend{display:flex;flex-wrap:wrap;gap:8px 20px;margin:12px 0}.comparison-legend span{overflow-wrap:anywhere}.comparison-key{display:inline-block;width:12px;height:12px;margin-right:6px}.comparison-previous{background:#727c80}.comparison-current{background:#087b71}.comparison-chart svg{display:block;width:100%;height:auto;max-width:100%}.comparison-chart svg text{font-family:inherit;font-size:13px;fill:#344a51}.comparison-chart .comparison-table{width:100%;table-layout:fixed;border-collapse:collapse}.comparison-chart th,.comparison-chart td{white-space:normal;overflow-wrap:anywhere;text-align:left;padding:8px;border-bottom:1px solid #d8e2df}.comparison-chart details{max-width:100%}.comparison-chart select{min-height:44px}.comparison-chart [data-comparison-plot]{overflow-x:auto}.comparison-chart [data-comparison-plot]:focus-visible{outline:3px solid #c16613}@media(max-width:700px){.comparison-chart svg{min-width:760px;max-width:none}.comparison-chart [data-comparison-plot]::before{content:'横向滚动查看图表';display:block;font-size:12px;color:#536770;margin-bottom:8px}}
14
+ """
15
+
16
+ CSS += """
17
+ .comparison-chart details{overflow-x:auto}.comparison-chart[data-layout="time_series_chart"] .comparison-table{min-width:760px}.comparison-compact[data-layout="pie_pair"] .comparison-panel svg{min-width:0;max-width:300px}.comparison-compact [data-comparison-plot]{overflow-x:visible}.comparison-compact svg text{font-size:16px}.comparison-panels{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px}.comparison-panel{min-width:0}.comparison-panel h4{margin:4px 0}@media(max-width:700px){.comparison-compact svg{min-width:0;max-width:100%}.comparison-compact [data-comparison-plot]::before{content:none}.comparison-panels{grid-template-columns:1fr}.comparison-compact[data-layout="paired_bars"] [data-comparison-plot]{overflow-x:auto}.comparison-compact[data-layout="paired_bars"] svg{min-width:900px}.comparison-panel{overflow-x:auto}.comparison-compact .comparison-panel svg{min-width:520px;max-width:none}}
18
+ """
19
+
20
+ # SVGs are pre-rendered from the same data as the table. Cloning a template
21
+ # avoids parsing source labels as HTML or evaluating data as code in the browser.
22
+ JS = """
23
+ (()=>{let opened=[];
24
+ window.addEventListener('beforeprint',()=>{
25
+ if(opened.length)return;
26
+ opened=[...document.querySelectorAll('details:not([open])')];
27
+ opened.forEach(detail=>detail.open=true);
28
+ });
29
+ window.addEventListener('afterprint',()=>{opened.forEach(detail=>detail.open=false);opened=[];});
30
+ document.querySelectorAll('.comparison-chart').forEach(chart=>{
31
+ const select=chart.querySelector('select'), target=chart.querySelector('[data-comparison-plot]');
32
+ if(!select||!target)return;
33
+ select.addEventListener('change',()=>{
34
+ const template=chart.querySelectorAll('template')[select.selectedIndex];
35
+ if(!template)return;
36
+ target.replaceChildren(template.content.cloneNode(true));
37
+ chart.dataset.metric=select.options[select.selectedIndex].dataset.key;
38
+ });
39
+ });})();
40
+ """
41
+
42
+
43
+ def _esc(value):
44
+ return html.escape(str(value), quote=True)
45
+
46
+
47
+ def _md(value):
48
+ text = html.escape(str(value), quote=False).replace('\n', ' ')
49
+ for char in ('\\', '*', '_', '[', ']', '`', '#', '|'):
50
+ text = text.replace(char, '\\' + char)
51
+ return text
52
+
53
+
54
+ def _number(value):
55
+ if value is None or isinstance(value, bool):
56
+ return None
57
+ try:
58
+ number = Decimal(str(value))
59
+ return number if number.is_finite() else None
60
+ except (InvalidOperation, ValueError):
61
+ return None
62
+
63
+
64
+ def _display(value, fmt):
65
+ number = _number(value)
66
+ if number is None:
67
+ return ''
68
+ if fmt == 'percent':
69
+ return f'{number * 100:,.2f}%'
70
+ if fmt == 'usd':
71
+ return f'USD {number:,.2f}'
72
+ return f'{number:,.0f}'
73
+
74
+
75
+ def _value(category, series, metric):
76
+ return category.get('values', {}).get(series['key'], {}).get(metric['key'])
77
+
78
+
79
+ def _svg(block, metric):
80
+ categories, series = block['categories'], block['series']
81
+ height = 75 + len(categories) * 48
82
+ left, right = 230, 640
83
+ values = [_number(_value(c, s, metric)) for c in categories for s in series]
84
+ maximum = Decimal(1) if metric['format'] == 'percent' else max([v for v in values if v is not None and v > 0] or [Decimal(1)])
85
+ title = block['title'] + ' — ' + metric['label']
86
+ out = [f'<svg viewBox="0 0 800 {height}" role="img" aria-label="{_esc(title)}"><title>{_esc(title)}</title>']
87
+ for i in range(5):
88
+ x = left + (right-left)*i/4
89
+ label = f'{i*25}%' if metric['format'] == 'percent' else _display(maximum*Decimal(i)/4, metric['format'])
90
+ out.append(f'<line x1="{x}" y1="25" x2="{x}" y2="{height-12}" stroke="#e2eae6"/><text x="{x}" y="18" text-anchor="middle">{_esc(label)}</text>')
91
+ for row, category in enumerate(categories):
92
+ y = 50 + row*48
93
+ label = str(category['label'])
94
+ short = label if len(label) <= 24 else label[:23]+'…'
95
+ out.append(f'<text x="8" y="{y+8}"><title>{_esc(label)}</title>{_esc(short)}</text>')
96
+ for index, s in enumerate(series):
97
+ value = _number(_value(category, s, metric))
98
+ display = _display(_value(category, s, metric), metric['format'])
99
+ color = '#727c80' if s['key'] == 'previous' else '#087b71'
100
+ yy = y + index*19
101
+ tooltip = _esc(f'{label} — {s["label"]}: {display}')
102
+ out.append(f'<text x="790" y="{yy+5}" text-anchor="end" style="fill:{color}">{_esc(display)}</text>')
103
+ if value is None:
104
+ continue
105
+ ratio = max(Decimal(0), min(Decimal(1), value/maximum))
106
+ x = left + (right-left)*float(ratio)
107
+ if metric['format'] == 'percent':
108
+ out.append(f'<circle cx="{x:.2f}" cy="{yy}" r="6" fill="{color}"><title>{tooltip}</title></circle>')
109
+ elif value == 0:
110
+ out.append(f'<text x="{left+5}" y="{yy+5}"><title>{tooltip}</title>0</text>')
111
+ else:
112
+ out.append(f'<rect x="{left}" y="{yy-6}" width="{x-left:.2f}" height="12" fill="{color}"><title>{tooltip}</title></rect>')
113
+ out.append('</svg>')
114
+ return ''.join(out)
115
+
116
+
117
+ _BANDS = ('[0,25)', '[25,50)', '[50,100)', '[100,200)', '[200,500)', '[500,+inf)')
118
+
119
+
120
+ def _axis_label(value, fmt):
121
+ if fmt == 'percent':
122
+ scaled=value*100
123
+ return f'{scaled:.0f}%' if scaled==scaled.to_integral_value() else f'{scaled:.2f}%'
124
+ if abs(value) >= 1000000:
125
+ return f'{value / Decimal(1000000):.1f}m'
126
+ if abs(value) >= 1000:
127
+ return f'{value / Decimal(1000):.1f}k'
128
+ return f'{value:,.0f}'
129
+
130
+
131
+ def _compact_svg(block, metric, categories, series, maximum=None):
132
+ values = [_number(_value(c, s, metric)) for c in categories for s in series]
133
+ if maximum is None:
134
+ maximum = Decimal(1) if metric['format'] == 'percent' else max([v for v in values if v is not None and v > 0] or [Decimal(1)])
135
+ paired = len(series) == 2
136
+ width = 1000 if paired else 600
137
+ pitch = 34 if paired else 32
138
+ height = 50 + len(categories) * pitch
139
+ left, right = (240, 840) if paired else (185, 470)
140
+ title = block['title'] + ' — ' + metric['label'] + (' — ' + series[0]['label'] if len(series) == 1 else '')
141
+ out = [f'<svg viewBox="0 0 {width} {height}" role="img" aria-label="{_esc(title)}"><title>{_esc(title)}</title>']
142
+ for i in range(5):
143
+ x = left + (right-left)*i/4
144
+ label = _axis_label(maximum*Decimal(i)/4, metric['format'])
145
+ out.append(f'<line x1="{x}" y1="25" x2="{x}" y2="{height-5}" stroke="#e2eae6"/><text x="{x}" y="18" text-anchor="middle">{_esc(label)}</text>')
146
+ for row, category in enumerate(categories):
147
+ y = 43 + row * pitch
148
+ label = str(category['label'])
149
+ limit = 28 if paired else 18
150
+ short = label if len(label) <= limit else label[:limit-1] + '…'
151
+ out.append(f'<text x="4" y="{y+4}"><title>{_esc(label)}</title>{_esc(short)}</text>')
152
+ for index, s in enumerate(series):
153
+ raw = _value(category, s, metric)
154
+ value, display = _number(raw), _display(raw, metric['format'])
155
+ color = '#727c80' if s['key'] == 'previous' else '#087b71'
156
+ yy = y + index * 14
157
+ tooltip = _esc(f'{label} — {s["label"]}: {display}')
158
+ out.append(f'<text x="{width-5}" y="{yy+4}" text-anchor="end" style="fill:{color};font-size:12px"><title>{tooltip}</title>{_esc(display)}</text>')
159
+ if value is None or value == 0:
160
+ continue
161
+ else:
162
+ ratio = max(Decimal(0), min(Decimal(1), value/maximum))
163
+ out.append(f'<rect x="{left}" y="{yy-5}" width="{(right-left)*float(ratio):.2f}" height="9" fill="{color}"><title>{tooltip}</title></rect>')
164
+ out.append('</svg>')
165
+ return ''.join(out)
166
+
167
+
168
+ def _histogram_svg(block, metric, categories, series, maximum):
169
+ title = block['title'] + ' — ' + metric['label'] + ' — ' + series['label']
170
+ out = [f'<svg viewBox="0 0 600 270" role="img" aria-label="{_esc(title)}"><title>{_esc(title)}</title>']
171
+ for i in range(5):
172
+ y = 210 - i * 45
173
+ label = _axis_label(maximum * Decimal(i) / 4, metric['format'])
174
+ out.append(f'<line x1="85" y1="{y}" x2="590" y2="{y}" stroke="#e2eae6"/><text x="80" y="{y+4}" text-anchor="end">{_esc(label)}</text>')
175
+ color = '#727c80' if series['key'] == 'previous' else '#087b71'
176
+ for i, category in enumerate(categories):
177
+ x = 85 + i * 84
178
+ raw = _value(category, series, metric)
179
+ value, display = _number(raw), _display(raw, metric['format'])
180
+ tooltip = _esc(f'{category["label"]} — {series["label"]}: {display}')
181
+ band_label = ('0–<25','25–<50','50–<100','100–<200','200–<500','500+')[i]
182
+ out.append(f'<text x="{x+42}" y="232" text-anchor="middle" style="font-size:14px">{_esc(band_label)}</text>')
183
+ if value is None or value == 0:
184
+ out.append(f'<text x="{x+42}" y="203" text-anchor="middle"><title>{tooltip}</title>{_esc(display)}</text>')
185
+ else:
186
+ bar_height = 180 * float(max(Decimal(0), min(Decimal(1), value/maximum)))
187
+ out.append(f'<rect x="{x}" y="{210-bar_height:.2f}" width="84" height="{bar_height:.2f}" fill="{color}" stroke="white"><title>{tooltip}</title></rect>')
188
+ return ''.join(out) + '</svg>'
189
+
190
+
191
+ def _compact_plot(block, metric, layout):
192
+ categories, series = block['categories'], block['series']
193
+ if layout == 'paired_bars':
194
+ return _compact_svg(block, metric, categories, series)
195
+ if layout == 'histogram_pair':
196
+ lookup = {c['label']: c for c in categories if c['label'] in _BANDS}
197
+ plotted = [lookup.get(label, {'label': label, 'values': {}}) for label in _BANDS]
198
+ extra = [c for c in categories if c['label'] not in _BANDS]
199
+ else:
200
+ plotted, extra = categories, []
201
+ values = [_number(_value(c, s, metric)) for c in plotted for s in series]
202
+ maximum = Decimal(1) if metric['format'] == 'percent' else max([v for v in values if v is not None and v > 0] or [Decimal(1)])
203
+ panels = []
204
+ for s in series:
205
+ svg = _histogram_svg(block, metric, plotted, s, maximum) if layout == 'histogram_pair' else _compact_svg(block, metric, plotted, [s], maximum=maximum)
206
+ panels.append('<section class="comparison-panel"><h4><i aria-hidden="true" class="comparison-key comparison-'+_esc(s['key'])+'"></i>'+_esc(s['label'])+'</h4>'+svg+'</section>')
207
+ note = '<p>金额区间宽度不相等;以等宽类别显示,500+ 无上限,不表示密度或连续区间宽度。</p>' if layout == 'histogram_pair' else ''
208
+ if extra:
209
+ note += '<p>区间外或未知(不作为金额区间绘制):' + '; '.join(_esc(c['label']) + ' — ' + ', '.join(_esc(s['label']) + ': ' + _esc(_display(_value(c, s, metric), metric['format'])) for s in series) for c in extra) + '</p>'
210
+ return note + '<div class="comparison-panels">' + ''.join(panels) + '</div>'
211
+
212
+
213
+ _PALETTE = ('#087b71', '#727c80', '#c16613', '#426b94', '#a05178', '#759b47', '#8c6954', '#6b61a8')
214
+ _SERIES_COLORS = {'visa_predispute':'#087b71','visa_dispute':'#426b94','mastercard_chargeback':'#c16613','visa':'#426b94','mastercard':'#c16613','mastercard_efm':'#c16613','vamp_ratio':'#6b61a8'}
215
+
216
+
217
+ def _pie_colors(categories):
218
+ known={'None':'#727c80','3DS attempted':'#c16613','3DS validated':'#087b71'}
219
+ used={known[c['label']] for c in categories if c['label'] in known}
220
+ colors=[]
221
+ for i,category in enumerate(categories):
222
+ color=known.get(category['label'])
223
+ if color is None:
224
+ color=next((c for c in _PALETTE if c not in used),_PALETTE[i%len(_PALETTE)])
225
+ used.add(color)
226
+ colors.append(color)
227
+ return colors
228
+
229
+
230
+ def _pie_plot(block, metric):
231
+ categories = block['categories']
232
+ colors = _pie_colors(categories)
233
+ legend = '<div class="comparison-legend">' + ''.join(
234
+ f'<span><i class="comparison-key" aria-hidden="true" style="background:{colors[i]}"></i>{_esc(c["label"])}</span>'
235
+ for i, c in enumerate(categories)) + '</div>'
236
+ panels = []
237
+ for series in block['series']:
238
+ values = [_number(_value(c, series, metric)) for c in categories]
239
+ known = [v for v in values if v is not None]
240
+ heading = '<h4>' + _esc(series['label']) + '</h4>'
241
+ if not known or any(v is None or v < 0 for v in values):
242
+ panels.append('<section class="comparison-panel">' + heading + '<p>数据不可用</p></section>')
243
+ continue
244
+ total = sum(known, Decimal(0))
245
+ if total == 0:
246
+ panels.append('<section class="comparison-panel">' + heading + '<p>合计为 0</p></section>')
247
+ continue
248
+ title = block['title'] + ' — ' + metric['label'] + ' — ' + series['label']
249
+ svg = [f'<svg viewBox="0 0 300 300" role="img" aria-label="{_esc(title)}"><title>{_esc(title)}</title>']
250
+ angle = -math.pi / 2
251
+ for i, (category, value) in enumerate(zip(categories, values)):
252
+ if value == 0:
253
+ continue
254
+ sweep = float(value / total) * 2 * math.pi
255
+ color = colors[i]
256
+ tooltip = _esc(f'{category["label"]} — {series["label"]}: {_display(value, metric["format"])}')
257
+ if value == total:
258
+ svg.append(f'<circle cx="150" cy="150" r="125" fill="{color}"><title>{tooltip}</title></circle>')
259
+ else:
260
+ x1, y1 = 150 + 125 * math.cos(angle), 150 + 125 * math.sin(angle)
261
+ x2, y2 = 150 + 125 * math.cos(angle + sweep), 150 + 125 * math.sin(angle + sweep)
262
+ svg.append(f'<path d="M 150 150 L {x1:.6f} {y1:.6f} A 125 125 0 {int(sweep > math.pi)} 1 {x2:.6f} {y2:.6f} Z" fill="{color}"><title>{tooltip}</title></path>')
263
+ angle += sweep
264
+ svg.append('</svg>')
265
+ amounts = '<div class="comparison-legend">' + ''.join(
266
+ f'<span><i class="comparison-key" aria-hidden="true" style="background:{colors[i]}"></i>{_esc(c["label"])}: {_esc(_display(v, metric["format"]))}</span>'
267
+ for i, (c, v) in enumerate(zip(categories, values))) + '</div>'
268
+ panels.append('<section class="comparison-panel">' + heading + ''.join(svg) + amounts + '</section>')
269
+ return legend + '<div class="comparison-panels">' + ''.join(panels) + '</div>'
270
+
271
+
272
+ def _time_series_content(block):
273
+ unit, series, points = block['unit'], block['series'], block['points']
274
+ if unit not in ('count', 'usd', 'percent'):
275
+ raise ValueError('unsupported time series unit')
276
+ keys = [s['key'] for s in series]
277
+ if len(set(keys)) != len(keys):
278
+ raise ValueError('duplicate time series key')
279
+ colors = []
280
+ for i, s in enumerate(series):
281
+ color = s.get('color', _SERIES_COLORS.get(s['key'], _PALETTE[i % len(_PALETTE)]))
282
+ if not isinstance(color, str) or not re.fullmatch(r'#[0-9a-fA-F]{6}', color):
283
+ raise ValueError('time series color must be a six-digit hex color')
284
+ colors.append(color)
285
+ values = [[_number(p.get('values', {}).get(s['key'])) for p in points] for s in series]
286
+ observed = [v for row in values for v in row if v is not None]
287
+ low = min([Decimal(0)] + observed)
288
+ high = max([Decimal(0)] + observed)
289
+ if unit == 'count':
290
+ low,high=Decimal(math.floor(low/4))*4,Decimal(math.ceil(high/4))*4
291
+ if high == low:
292
+ high = low + Decimal(4 if unit=='count' else 1)
293
+ width = max(800, 80 * len(points) + 100)
294
+ left, right, top, bottom = 85, width - 30, 30, 240
295
+ def x(i):
296
+ return (left + right) / 2 if len(points) == 1 else left + (right-left)*i/max(1, len(points)-1)
297
+ def y(value):
298
+ return bottom - float((value-low)/(high-low)) * (bottom-top)
299
+ title = _esc(block['title'])
300
+ svg = [f'<svg viewBox="0 0 {width} 290" role="img" aria-label="{title}"><title>{title}</title>']
301
+ for i in range(5):
302
+ tick = low + (high-low)*Decimal(i)/4
303
+ yy = bottom - (bottom-top)*i/4
304
+ svg.append(f'<line x1="{left}" y1="{yy}" x2="{right}" y2="{yy}" stroke="#e2eae6"/><text x="{left-8}" y="{yy+4}" text-anchor="end">{_esc(_axis_label(tick, unit))}</text>')
305
+ for i, p in enumerate(points):
306
+ axis_label = str(p['label']) + ('*' if p.get('partial') else '')
307
+ svg.append(f'<text x="{x(i):.2f}" y="265" text-anchor="middle">{_esc(axis_label)}</text>')
308
+ for s, color, row in zip(series, colors, values):
309
+ previous = None
310
+ for i, value in enumerate(row):
311
+ if value is None:
312
+ previous = None
313
+ continue
314
+ xx, yy = x(i), y(value)
315
+ if previous is not None:
316
+ svg.append(f'<line x1="{previous[0]:.2f}" y1="{previous[1]:.2f}" x2="{xx:.2f}" y2="{yy:.2f}" stroke="{color}" stroke-width="2"/>')
317
+ point = points[i]
318
+ dates = ' – '.join(str(point[k]) for k in ('start', 'end') if point.get(k))
319
+ tooltip = f'{point["label"]}' + (f' ({dates})' if dates else '') + f' — {s["label"]}: {_display(value, unit)}'
320
+ if block.get('date_basis'):
321
+ tooltip += ' · ' + block['date_basis']
322
+ coverage=point.get('coverage')
323
+ if isinstance(coverage,dict) and 'valid_file_dates' in coverage:
324
+ tooltip += ' · 有效文件日期 '+str(coverage['valid_file_dates'])
325
+ svg.append(f'<circle cx="{xx:.2f}" cy="{yy:.2f}" r="4" fill="{color}"><title>{_esc(tooltip)}</title></circle>')
326
+ previous = (xx, yy)
327
+ svg.append('</svg>')
328
+ legend = ''.join(f'<span><i class="comparison-key" aria-hidden="true" style="background:{color}"></i>{_esc(s["label"])}</span>' for s, color in zip(series, colors))
329
+ headers = ['指标'] + [p['label'] for p in points]
330
+ rows=[]
331
+ if any(p.get('partial') for p in points):
332
+ rows.append(['期间范围(含首尾)'] + [
333
+ '不完整:' + ' 至 '.join(str(p.get(k) or '') for k in ('start', 'end')) if p.get('partial') else ''
334
+ for p in points])
335
+ for s in series:
336
+ rows.append([s['label']]+[_display(p.get('values',{}).get(s['key']),unit) for p in points])
337
+ if unit=='percent' and any(s['key'] in p.get('rate_details',{}) for p in points):
338
+ details=[p.get('rate_details',{}).get(s['key'],{}) for p in points]
339
+ for key,label in [('numerator','分子笔数'),('denominator','分母笔数')]:
340
+ rows.append([s['label']+' · '+label]+[_display(d.get(key),'count') for d in details])
341
+ rows.append([s['label']+' · 分母月份']+[str(d.get('denominator_start') or '')[:7] for d in details])
342
+ table = '<table class="comparison-table"><thead><tr>' + ''.join('<th scope="col">' + _esc(h) + '</th>' for h in headers) + '</tr></thead><tbody>'
343
+ table += ''.join('<tr><th scope="row">' + _esc(row[0]) + '</th>' + ''.join('<td>' + _esc(v) + '</td>' for v in row[1:]) + '</tr>' for row in rows) + '</tbody></table>'
344
+ note = '<p>' + _esc(block['status_note']) + '</p>' if block.get('status_note') else ''
345
+ if any(p.get('partial') for p in points):
346
+ note += '<p>* 不完整期间;具体日期见数据明细。</p>'
347
+ content = note + f'<div class="comparison-legend">{legend}</div>' + ''.join(svg) + '<details><summary>查看数据明细</summary>' + table + '</details>'
348
+ markdown = ''
349
+ if block.get('status_note'):
350
+ markdown += _md(block['status_note']) + '\n\n'
351
+ markdown += '| ' + ' | '.join(map(_md, headers)) + ' |\n| ' + ' | '.join('---' for _ in headers) + ' |\n'
352
+ markdown += '\n'.join('| ' + ' | '.join(map(_md, row)) + ' |' for row in rows)
353
+ return content, markdown, (note, legend, ''.join(svg), table)
354
+
355
+
356
+ def _time_series_chart(block, identifier):
357
+ prefix = 'comparison-' + hashlib.sha256(str(identifier).encode('utf-8')).hexdigest()[:20]
358
+ title = _esc(block['title'])
359
+ if 'views' not in block:
360
+ _, md, (note, legend, svg, table) = _time_series_content(block)
361
+ return (f'<div class="comparison-chart" id="{prefix}" data-layout="time_series_chart"><h3>{title}</h3>{note}'
362
+ f'<div class="comparison-legend">{legend}</div><div data-comparison-plot tabindex="0">{svg}</div>'
363
+ f'<details><summary>查看数据明细</summary>{table}</details></div>',
364
+ '### ' + _md(block['title']) + '\n\n' + md)
365
+
366
+ views = block['views']
367
+ if not isinstance(views, list) or not views:
368
+ raise ValueError('time series views must be a non-empty list')
369
+ keys = [view.get('key') for view in views]
370
+ if any(not isinstance(key, str) or not re.fullmatch(r'[A-Za-z][A-Za-z0-9_-]*', key) for key in keys) or len(set(keys)) != len(keys):
371
+ raise ValueError('invalid or duplicate time series view key')
372
+ if block.get('default_view') not in keys:
373
+ raise ValueError('default time series view is not present')
374
+ selected = keys.index(block['default_view'])
375
+ rendered = [_time_series_content({**block, **view}) for view in views]
376
+ options = ''.join(f'<option value="{i}" data-key="{_esc(view["key"])}"' +
377
+ (' selected' if i == selected else '') + f'>{_esc(view["label"])}</option>'
378
+ for i, view in enumerate(views))
379
+ result = f'<div class="comparison-chart" id="{prefix}" data-layout="time_series_chart" data-metric="{_esc(keys[selected])}"><h3>{title}</h3>'
380
+ result += f'<label for="{prefix}-metric">选择指标</label> <select id="{prefix}-metric" aria-controls="{prefix}-plot">{options}</select>'
381
+ result += f'<div id="{prefix}-plot" data-comparison-plot tabindex="0">{rendered[selected][0]}</div>'
382
+ result += ''.join('<template>' + content + '</template>' for content, _, _ in rendered) + '</div>'
383
+ markdown = '### ' + _md(block['title']) + '\n\n' + '\n\n'.join(
384
+ '#### ' + _md(view['label']) + '\n\n' + md for view, (_, md, _) in zip(views, rendered))
385
+ return result, markdown
386
+
387
+
388
+ def chart(block, identifier):
389
+ """Return (HTML, Markdown) for a comparison_chart or time_series_chart block."""
390
+ if block.get('type') == 'time_series_chart':
391
+ return _time_series_chart(block, identifier)
392
+ metrics, series = block['metrics'], block['series']
393
+ if not metrics or len(series) != 2 or {s['key'] for s in series} != {'previous', 'current'}:
394
+ raise ValueError('comparison chart requires metrics and previous/current series')
395
+ if any(m['format'] not in ('count', 'percent', 'usd') for m in metrics):
396
+ raise ValueError('unsupported comparison metric format')
397
+ layout = block.get('layout')
398
+ if layout not in (None, 'paired_bars', 'histogram_pair', 'bars_pair', 'pie_pair'):
399
+ raise ValueError('unsupported comparison layout')
400
+ if layout in ('histogram_pair', 'pie_pair') and any(m['format'] == 'percent' for m in metrics):
401
+ raise ValueError('histogram and pie metrics must be counts or amounts')
402
+ selected = next((i for i, m in enumerate(metrics) if m['key'] == block['default_metric']), None)
403
+ if selected is None:
404
+ raise ValueError('default metric is not present')
405
+ prefix = 'comparison-' + hashlib.sha256(str(identifier).encode('utf-8')).hexdigest()[:20]
406
+ svgs = [_pie_plot(block, metric) if layout == 'pie_pair' else _compact_plot(block, metric, layout) if layout else _svg(block, metric) for metric in metrics]
407
+ options = ''.join(f'<option value="{i}" data-key="{_esc(m["key"])}"'+(' selected' if i == selected else '')+f'>{_esc(m["label"])}</option>' for i, m in enumerate(metrics))
408
+ legend = ''.join(f'<span><i aria-hidden="true" class="comparison-key comparison-{_esc(s["key"])}"></i>{_esc(s["label"])}</span>' for s in series)
409
+ if layout in ('histogram_pair','bars_pair','pie_pair'):legend=''
410
+ description = '<p>'+_esc(block['description'])+'</p>' if block.get('description') else ''
411
+ headers = ['分类', '指标'] + [s['label'] for s in series]
412
+ rows = [[c['label'], m['label']] + [_display(_value(c, s, m), m['format']) for s in series] for c in block['categories'] for m in metrics]
413
+ table = '<table class="comparison-table"><thead><tr>'+''.join('<th scope="col">'+_esc(h)+'</th>' for h in headers)+'</tr></thead><tbody>'
414
+ table += ''.join('<tr><th scope="row">'+_esc(row[0])+'</th>'+''.join('<td>'+_esc(v)+'</td>' for v in row[1:])+'</tr>' for row in rows)+'</tbody></table>'
415
+ compact_class = ' comparison-compact' if layout else ''
416
+ result = f'<div class="comparison-chart{compact_class}" id="{prefix}" data-layout="{_esc(layout or "default")}" data-metric="{_esc(metrics[selected]["key"])}"><h3>{_esc(block["title"])}</h3>{description}'
417
+ result += f'<label for="{prefix}-metric">选择指标</label> <select id="{prefix}-metric" aria-controls="{prefix}-plot">{options}</select><div class="comparison-legend">{legend}</div>'
418
+ result += f'<div id="{prefix}-plot" data-comparison-plot tabindex="0" aria-label="可横向滚动的对比图">{svgs[selected]}</div>' + ''.join('<template>'+svg+'</template>' for svg in svgs)
419
+ result += '<details><summary>查看数据明细</summary>'+table+'</details></div>'
420
+ markdown = '### '+_md(block['title'])+'\n\n'
421
+ if block.get('description'):
422
+ markdown += _md(block['description'])+'\n\n'
423
+ markdown += '| '+' | '.join(map(_md, headers))+' |\n| '+' | '.join('---' for _ in headers)+' |\n'
424
+ markdown += '\n'.join('| '+' | '.join(map(_md, row))+' |' for row in rows)
425
+ return result, markdown
@@ -0,0 +1,52 @@
1
+ """Spreadsheet-safe CSV writers.
2
+
3
+ Text beginning with a formula marker or whitespace gets a leading apostrophe.
4
+ The apostrophe is part of the CSV value; JSON and source data remain unchanged.
5
+ Numeric strings are only exempted in explicitly designated numeric columns, after
6
+ strict validation. Never designate identifiers or other source text as numeric.
7
+ """
8
+ import csv
9
+ import re
10
+
11
+ _NUMERIC = re.compile(r'-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?\Z')
12
+
13
+
14
+ def _cell(value, numeric=False):
15
+ if isinstance(value, str):
16
+ if numeric and _NUMERIC.fullmatch(value):
17
+ return value
18
+ if value and (value[0] in '=+-@' or value[0].isspace()):
19
+ return "'" + value
20
+ return value
21
+
22
+
23
+ class _SafeWriter:
24
+ def __init__(self, writer, numeric_columns, dictionary=False):
25
+ self._writer = writer
26
+ self._numeric_columns = frozenset(numeric_columns)
27
+ self._dictionary = dictionary
28
+
29
+ def writerow(self, row):
30
+ if self._dictionary:
31
+ return self._writer.writerow({key: _cell(value, key in self._numeric_columns)
32
+ for key, value in row.items()})
33
+ return self._writer.writerow([_cell(value, index in self._numeric_columns)
34
+ for index, value in enumerate(row)])
35
+
36
+ def writerows(self, rows):
37
+ for row in rows:
38
+ self.writerow(row)
39
+
40
+ def writeheader(self):
41
+ # DictWriter's header contains field names, which are text, not numbers.
42
+ return self._writer.writerow({key: _cell(key) for key in self._writer.fieldnames})
43
+
44
+
45
+ def safe_writer(handle, *, numeric_columns=(), **kwargs):
46
+ """Like csv.writer; numeric_columns contains zero-based numeric column indices."""
47
+ return _SafeWriter(csv.writer(handle, **kwargs), numeric_columns)
48
+
49
+
50
+ def safe_dict_writer(handle, fieldnames, *, numeric_columns=(), **kwargs):
51
+ """Like csv.DictWriter; numeric_columns contains numeric field names."""
52
+ return _SafeWriter(csv.DictWriter(handle, fieldnames, **kwargs), numeric_columns, True)