skilldrop-cli 1.6.0 → 1.7.0

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 (123) hide show
  1. package/README.md +2 -0
  2. package/catalogue.json +18 -2
  3. package/model-routing.json +16 -0
  4. package/package.json +1 -1
  5. package/packs/event-planner/pack.json +61 -0
  6. package/packs/event-planner/skills/run-of-show/SKILL.md +141 -0
  7. package/packs/event-planner/skills/run-of-show/evals/eval_queries.json +30 -0
  8. package/packs/event-planner/skills/run-of-show/evals/evals.json +103 -0
  9. package/packs/event-planner/skills/run-of-show/evals/files/1/agenda.md +16 -0
  10. package/packs/event-planner/skills/run-of-show/evals/files/2/schedule.csv +8 -0
  11. package/packs/event-planner/skills/run-of-show/evals/files/3/notes.txt +3 -0
  12. package/packs/event-planner/skills/run-of-show/evals/files/4/sessions_export.csv +25 -0
  13. package/packs/event-planner/skills/run-of-show/evals/files/4/venue_and_crew.txt +27 -0
  14. package/packs/event-planner/skills/run-of-show/evals/files/5/runsheet_v4.csv +28 -0
  15. package/packs/event-planner/skills/run-of-show/evals/files/5/venue_and_suppliers.txt +22 -0
  16. package/packs/event-planner/skills/run-of-show/evals/files/6/acts_from_booker.csv +26 -0
  17. package/packs/event-planner/skills/run-of-show/evals/files/6/venue_rooms_and_crew.txt +14 -0
  18. package/packs/event-planner/skills/run-of-show/evals/files/7/runsheet_draft_v4.csv +25 -0
  19. package/packs/event-planner/skills/run-of-show/evals/files/7/venue_and_fixed_times.txt +24 -0
  20. package/packs/event-planner/skills/run-of-show/examples/product-launch.md +77 -0
  21. package/packs/event-planner/skills/run-of-show/examples/product-launch.svg +58 -0
  22. package/packs/event-planner/skills/run-of-show/manifest.json +44 -0
  23. package/packs/event-planner/skills/run-of-show/scripts/show_check.py +295 -0
  24. package/packs/event-planner/skills/run-of-show/templates/show.json +15 -0
  25. package/packs/event-planner/skills/seating-plan/SKILL.md +152 -0
  26. package/packs/event-planner/skills/seating-plan/evals/eval_queries.json +30 -0
  27. package/packs/event-planner/skills/seating-plan/evals/evals.json +134 -0
  28. package/packs/event-planner/skills/seating-plan/evals/files/1/guests.csv +31 -0
  29. package/packs/event-planner/skills/seating-plan/evals/files/2/table-plan.txt +8 -0
  30. package/packs/event-planner/skills/seating-plan/evals/files/3/brief.txt +4 -0
  31. package/packs/event-planner/skills/seating-plan/evals/files/3/guests.csv +49 -0
  32. package/packs/event-planner/skills/seating-plan/evals/files/4/rsvp_export.csv +127 -0
  33. package/packs/event-planner/skills/seating-plan/evals/files/4/seating_notes.txt +21 -0
  34. package/packs/event-planner/skills/seating-plan/evals/files/5/organiser_notes.txt +10 -0
  35. package/packs/event-planner/skills/seating-plan/evals/files/5/registrations.csv +93 -0
  36. package/packs/event-planner/skills/seating-plan/evals/files/5/table_plan_v3.csv +94 -0
  37. package/packs/event-planner/skills/seating-plan/evals/files/6/bridgets_notes.txt +30 -0
  38. package/packs/event-planner/skills/seating-plan/evals/files/6/ticket_export.csv +117 -0
  39. package/packs/event-planner/skills/seating-plan/evals/files/6/venue_floorplan_notes.txt +14 -0
  40. package/packs/event-planner/skills/seating-plan/evals/files/7/keep_apart_CONFIDENTIAL.txt +9 -0
  41. package/packs/event-planner/skills/seating-plan/evals/files/7/prom_seating_v3.csv +101 -0
  42. package/packs/event-planner/skills/seating-plan/evals/files/7/ticket_list.csv +101 -0
  43. package/packs/event-planner/skills/seating-plan/evals/files/7/venue_info.txt +16 -0
  44. package/packs/event-planner/skills/seating-plan/evals/files/8/registrations_export.csv +147 -0
  45. package/packs/event-planner/skills/seating-plan/evals/files/8/room_layout.md +22 -0
  46. package/packs/event-planner/skills/seating-plan/evals/files/8/seating_requests.txt +31 -0
  47. package/packs/event-planner/skills/seating-plan/evals/files/9/guest_list.csv +84 -0
  48. package/packs/event-planner/skills/seating-plan/evals/files/9/notes.txt +16 -0
  49. package/packs/event-planner/skills/seating-plan/evals/files/9/table_plan.csv +85 -0
  50. package/packs/event-planner/skills/seating-plan/examples/wedding-dinner.md +61 -0
  51. package/packs/event-planner/skills/seating-plan/examples/wedding-dinner.svg +39 -0
  52. package/packs/event-planner/skills/seating-plan/manifest.json +44 -0
  53. package/packs/event-planner/skills/seating-plan/scripts/seating_check.py +324 -0
  54. package/packs/event-planner/skills/seating-plan/templates/seating.json +28 -0
  55. package/packs/experience-design/pack.json +5 -0
  56. package/packs/experience-design/skills/locale-check/SKILL.md +139 -0
  57. package/packs/experience-design/skills/locale-check/evals/eval_queries.json +38 -0
  58. package/packs/experience-design/skills/locale-check/evals/evals.json +104 -0
  59. package/packs/experience-design/skills/locale-check/evals/files/1/budget.json +1 -0
  60. package/packs/experience-design/skills/locale-check/evals/files/1/locales/ar.json +16 -0
  61. package/packs/experience-design/skills/locale-check/evals/files/1/locales/de.json +12 -0
  62. package/packs/experience-design/skills/locale-check/evals/files/1/locales/en.json +12 -0
  63. package/packs/experience-design/skills/locale-check/evals/files/1/locales/pl.json +12 -0
  64. package/packs/experience-design/skills/locale-check/evals/files/2/app/src/main/res/values/strings.xml +12 -0
  65. package/packs/experience-design/skills/locale-check/evals/files/2/app/src/main/res/values-es/strings.xml +11 -0
  66. package/packs/experience-design/skills/locale-check/evals/files/2/app/src/main/res/values-fr/strings.xml +12 -0
  67. package/packs/experience-design/skills/locale-check/evals/files/3/po/ja.po +20 -0
  68. package/packs/experience-design/skills/locale-check/evals/files/3/po/messages.pot +23 -0
  69. package/packs/experience-design/skills/locale-check/evals/files/3/po/ru.po +22 -0
  70. package/packs/experience-design/skills/locale-check/evals/files/4/budgets.json +15 -0
  71. package/packs/experience-design/skills/locale-check/evals/files/4/locales/ar.json +265 -0
  72. package/packs/experience-design/skills/locale-check/evals/files/4/locales/de.json +265 -0
  73. package/packs/experience-design/skills/locale-check/evals/files/4/locales/en.json +265 -0
  74. package/packs/experience-design/skills/locale-check/evals/files/4/locales/fr.json +265 -0
  75. package/packs/experience-design/skills/locale-check/evals/files/4/locales/pl.json +265 -0
  76. package/packs/experience-design/skills/locale-check/evals/files/5/app/src/main/res/values/strings.xml +77 -0
  77. package/packs/experience-design/skills/locale-check/evals/files/5/app/src/main/res/values-es/strings.xml +63 -0
  78. package/packs/experience-design/skills/locale-check/evals/files/5/app/src/main/res/values-ja/strings.xml +61 -0
  79. package/packs/experience-design/skills/locale-check/evals/files/6/po/cs.po +605 -0
  80. package/packs/experience-design/skills/locale-check/evals/files/6/po/marlin.pot +602 -0
  81. package/packs/experience-design/skills/locale-check/evals/files/6/po/pt_BR.po +602 -0
  82. package/packs/experience-design/skills/locale-check/evals/files/6/po/ru.po +608 -0
  83. package/packs/experience-design/skills/locale-check/evals/files/7/Tidewatch/Resources/en.lproj/Localizable.strings +160 -0
  84. package/packs/experience-design/skills/locale-check/evals/files/7/Tidewatch/Resources/en.lproj/Localizable.stringsdict +70 -0
  85. package/packs/experience-design/skills/locale-check/evals/files/7/Tidewatch/Resources/he.lproj/Localizable.strings +157 -0
  86. package/packs/experience-design/skills/locale-check/evals/files/7/Tidewatch/Resources/he.lproj/Localizable.stringsdict +86 -0
  87. package/packs/experience-design/skills/locale-check/evals/files/7/Tidewatch/Resources/it.lproj/Localizable.strings +160 -0
  88. package/packs/experience-design/skills/locale-check/evals/files/7/Tidewatch/Resources/it.lproj/Localizable.stringsdict +68 -0
  89. package/packs/experience-design/skills/locale-check/evals/files/7/Tidewatch/Resources/ko.lproj/Localizable.strings +160 -0
  90. package/packs/experience-design/skills/locale-check/evals/files/7/Tidewatch/Resources/ko.lproj/Localizable.stringsdict +62 -0
  91. package/packs/experience-design/skills/locale-check/examples/settings-release.md +107 -0
  92. package/packs/experience-design/skills/locale-check/manifest.json +45 -0
  93. package/packs/experience-design/skills/locale-check/scripts/locale_check.py +1105 -0
  94. package/packs/operations-manager/pack.json +60 -0
  95. package/packs/operations-manager/skills/shift-roster/SKILL.md +166 -0
  96. package/packs/operations-manager/skills/shift-roster/evals/eval_queries.json +38 -0
  97. package/packs/operations-manager/skills/shift-roster/evals/evals.json +135 -0
  98. package/packs/operations-manager/skills/shift-roster/evals/files/1/shifts.csv +3 -0
  99. package/packs/operations-manager/skills/shift-roster/evals/files/1/staff.csv +8 -0
  100. package/packs/operations-manager/skills/shift-roster/evals/files/2/rota.csv +22 -0
  101. package/packs/operations-manager/skills/shift-roster/evals/files/2/staff.csv +6 -0
  102. package/packs/operations-manager/skills/shift-roster/evals/files/3/team.md +14 -0
  103. package/packs/operations-manager/skills/shift-roster/evals/files/4/availability_export_wk47.csv +23 -0
  104. package/packs/operations-manager/skills/shift-roster/evals/files/4/rota_notes.txt +20 -0
  105. package/packs/operations-manager/skills/shift-roster/evals/files/5/rota_draft_16-29nov.csv +107 -0
  106. package/packs/operations-manager/skills/shift-roster/evals/files/5/staff_list.csv +17 -0
  107. package/packs/operations-manager/skills/shift-roster/evals/files/6/availability_notes.txt +24 -0
  108. package/packs/operations-manager/skills/shift-roster/evals/files/6/peoplehub_staff_export.csv +14 -0
  109. package/packs/operations-manager/skills/shift-roster/evals/files/6/rota_template.csv +25 -0
  110. package/packs/operations-manager/skills/shift-roster/evals/files/7/leave_and_availability.txt +18 -0
  111. package/packs/operations-manager/skills/shift-roster/evals/files/7/rota_wc_30nov.csv +47 -0
  112. package/packs/operations-manager/skills/shift-roster/evals/files/7/staff_list.csv +15 -0
  113. package/packs/operations-manager/skills/shift-roster/evals/files/8/availability_form_responses.csv +22 -0
  114. package/packs/operations-manager/skills/shift-roster/evals/files/8/cover_requirements.csv +15 -0
  115. package/packs/operations-manager/skills/shift-roster/evals/files/8/staff_export.csv +29 -0
  116. package/packs/operations-manager/skills/shift-roster/evals/files/9/leave_and_restrictions.txt +16 -0
  117. package/packs/operations-manager/skills/shift-roster/evals/files/9/rota_export_07-13Dec.csv +85 -0
  118. package/packs/operations-manager/skills/shift-roster/evals/files/9/staff_list.csv +27 -0
  119. package/packs/operations-manager/skills/shift-roster/examples/cafe-week.md +72 -0
  120. package/packs/operations-manager/skills/shift-roster/examples/cafe-week.svg +123 -0
  121. package/packs/operations-manager/skills/shift-roster/manifest.json +34 -0
  122. package/packs/operations-manager/skills/shift-roster/scripts/roster_check.py +633 -0
  123. package/packs/operations-manager/skills/shift-roster/templates/roster.json +22 -0
@@ -0,0 +1,1105 @@
1
+ #!/usr/bin/env python3
2
+ """Check translated string files against the source strings before they ship. Stdlib only.
3
+
4
+ Reads the source file and one or more target files in any of these formats, detected by
5
+ extension or content:
6
+ JSON (flat or nested; i18next plurals as key_one / key_other ...), ARB, gettext .po/.pot,
7
+ Android strings.xml (string, plurals, string-array), iOS .strings (UTF-8 or UTF-16) and
8
+ iOS .stringsdict.
9
+ The locale of each file comes from its path (de.json, fr-CA.json, values-fr/strings.xml,
10
+ fr.lproj/Localizable.strings, fr.po, app_fr.arb, locales/fr/common.json), from an ARB
11
+ @@locale or a .po Language header, or from --locale.
12
+
13
+ It FAILS a target where
14
+ - a source key is missing, or its translation is empty
15
+ - a placeholder differs from the source: {name}, {{name}}, %s, %d, %1$s, %@, %(name)s, and
16
+ ICU arguments; a positional printf argument whose type changes, or plain %s / %d
17
+ arguments in a different order (they would swap at runtime)
18
+ - an ICU message that parses in the source doesn't parse in the target, or a select
19
+ keyword (male / female / other) was translated or dropped
20
+ - a plural form the target language needs is missing (CLDR cardinal categories: ar needs
21
+ zero, one, two, few, many and other; pl one, few, many and other; ja only other); for
22
+ gettext, the number of msgstr[n] forms differs from the language's nplurals
23
+ - markup tags (<b>, <a href>, <0>...</0>, <xliff:g>) differ from the source or don't balance
24
+ - a string is over its character budget (--budget)
25
+ - a target file doesn't parse at all
26
+ and WARNS where
27
+ - a key in the target is not in the source
28
+ - a translation is identical to the source (possibly untranslated), for text over 3 letters
29
+ - leading or trailing whitespace changed, or the source's ending punctuation was dropped
30
+ - a word mixes Latin, Cyrillic or Greek letters (a look-alike typed by mistake)
31
+ - a plural form the language uses only for large numbers (fr, es, it, pt "many") is absent
32
+ - a gettext entry is marked fuzzy (msgfmt leaves it out, so the source text shows)
33
+
34
+ --budget FILE is JSON: {"keys": {"checkout.pay": 18}, "ratio": 1.4, "ratio_min_source": 10}
35
+ or a CSV with columns key,max. A per-key limit applies to every locale; the ratio applies to
36
+ keys without one, when the source string is at least ratio_min_source characters long.
37
+ Lengths count visible characters: markup tags and placeholders are not counted.
38
+ Without --budget no length is checked, and the report says so.
39
+
40
+ Usage:
41
+ python3 locale_check.py en.json de.json fr.json [--budget budget.json] [--json] [-o OUT]
42
+ python3 locale_check.py values/strings.xml values-pl/strings.xml values-ar/strings.xml
43
+ python3 locale_check.py messages.pot fr.po --locale fr
44
+
45
+ Exit codes: 0 no failures, 1 a failure, 2 bad input (a file missing, an unknown format, a
46
+ source that doesn't parse, or bad arguments).
47
+ """
48
+ import argparse
49
+ import csv
50
+ import io
51
+ import json
52
+ import math
53
+ import os
54
+ import plistlib
55
+ import re
56
+ import sys
57
+ import unicodedata
58
+ import xml.etree.ElementTree as ET
59
+
60
+ # --------------------------------------------------------------------------- languages
61
+
62
+ # CLDR cardinal plural categories (CLDR 44) for the languages most products ship.
63
+ CLDR = {}
64
+ for _langs, _cats in (
65
+ ("ja zh ko vi th id ms km lo my yo ig jv su", ("other",)),
66
+ ("en de nl sv da nb nn no fi et el hu tr bg af sw ur hi bn fa ta te ml mr gu kn is fil az ka "
67
+ "kk ky mn sq eu gl ne pa si zu xh am ps", ("one", "other")),
68
+ ("fr es it pt ca", ("one", "many", "other")),
69
+ ("ru uk be", ("one", "few", "many", "other")),
70
+ ("pl", ("one", "few", "many", "other")),
71
+ ("cs sk", ("one", "few", "many", "other")),
72
+ ("lt", ("one", "few", "many", "other")),
73
+ ("hr sr bs", ("one", "few", "other")),
74
+ ("ro mo", ("one", "few", "other")),
75
+ ("lv", ("zero", "one", "other")),
76
+ ("sl", ("one", "two", "few", "other")),
77
+ ("he iw", ("one", "two", "other")),
78
+ ("ar", ("zero", "one", "two", "few", "many", "other")),
79
+ ("cy", ("zero", "one", "two", "few", "many", "other")),
80
+ ("ga", ("one", "two", "few", "many", "other")),
81
+ ("mt", ("one", "two", "few", "many", "other")),
82
+ ):
83
+ for _l in _langs.split():
84
+ CLDR[_l] = _cats
85
+ # Categories a language has that only very large numbers reach: missing is a warning.
86
+ SOFT = {"fr": {"many"}, "es": {"many"}, "it": {"many"}, "pt": {"many"}, "ca": {"many"}}
87
+ # gettext nplurals as the usual Plural-Forms headers set them.
88
+ NPLURALS = {}
89
+ for _langs, _n in (("ja zh ko vi th id ms km lo my", 1),
90
+ ("en de nl sv da nb nn no fi et el hu tr bg af sw ur hi bn fa ta te ml mr gu kn "
91
+ "is fil fr es it pt ca az ka kk ky mn sq eu gl ne pa si zu he iw", 2),
92
+ ("ru uk be pl cs sk lt hr sr bs ro mo lv", 3), ("sl cy", 4), ("ga", 5), ("ar", 6)):
93
+ for _l in _langs.split():
94
+ NPLURALS[_l] = _n
95
+ CATEGORIES = ("zero", "one", "two", "few", "many", "other")
96
+ LANG_RE = re.compile(r"^([a-z]{2,3})(?:[-_](?:[A-Z][a-z]{3}|[A-Za-z]{2}|\d{3}|r[A-Z]{2}))*$")
97
+
98
+
99
+ def lang_of(code):
100
+ """'pt-BR' -> 'pt', 'zh_Hant' -> 'zh', 'values-pt-rBR' handled by the caller."""
101
+ return re.split(r"[-_]", code.strip())[0].lower() if code else ""
102
+
103
+
104
+ def detect_locale(path):
105
+ """The locale a file's path names, or ''."""
106
+ parts = re.split(r"[\\/]", os.path.abspath(path))
107
+ name = parts[-1]
108
+ stem = name.split(".")[0]
109
+ m = re.match(r"^values-([a-z]{2,3})(?:-r([A-Z]{2}))?", parts[-2] if len(parts) > 1 else "")
110
+ if m:
111
+ return m.group(1) + ("-" + m.group(2) if m.group(2) else "")
112
+ for p in reversed(parts[:-1]):
113
+ m = re.match(r"^([A-Za-z]{2,3}(?:[-_][A-Za-z]{2,4})?)\.lproj$", p)
114
+ if m:
115
+ return m.group(1)
116
+ if LANG_RE.match(stem) and lang_of(stem) in CLDR:
117
+ return stem
118
+ for tok in reversed(re.split(r"[_.]", name)[:-1]):
119
+ if LANG_RE.match(tok) and lang_of(tok) in CLDR:
120
+ return tok
121
+ for p in reversed(parts[-3:-1]):
122
+ if LANG_RE.match(p) and lang_of(p) in CLDR:
123
+ return p
124
+ if len(parts) > 1 and parts[-2] in ("values", "Base.lproj", "en.lproj"):
125
+ return "en"
126
+ return ""
127
+
128
+
129
+ # --------------------------------------------------------------------------- entries
130
+
131
+ class Entry(object):
132
+ """One string, or one plural string as {category: text}."""
133
+
134
+ __slots__ = ("key", "value", "line", "plural", "fuzzy", "gettext_n", "icu_ok")
135
+
136
+ def __init__(self, key, value, line=None, plural=False, fuzzy=False, gettext_n=None):
137
+ self.key, self.value, self.line, self.plural = key, value, line, plural
138
+ self.fuzzy, self.gettext_n, self.icu_ok = fuzzy, gettext_n, None
139
+
140
+ def texts(self):
141
+ return list(self.value.values()) if self.plural else [self.value]
142
+
143
+
144
+ class BadInput(Exception):
145
+ pass
146
+
147
+
148
+ class ParseError(Exception):
149
+ pass
150
+
151
+
152
+ def read_text(path):
153
+ try:
154
+ raw = open(path, "rb").read()
155
+ except OSError as e:
156
+ raise BadInput("can't read %s: %s" % (path, e.strerror or e))
157
+ for bom, enc in ((b"\xef\xbb\xbf", "utf-8-sig"), (b"\xff\xfe", "utf-16"), (b"\xfe\xff", "utf-16")):
158
+ if raw.startswith(bom):
159
+ return raw.decode(enc, errors="replace")
160
+ if len(raw) > 1 and raw[1:2] == b"\x00" and raw[0:1] != b"\x00":
161
+ return raw.decode("utf-16-le", errors="replace")
162
+ return raw.decode("utf-8", errors="replace")
163
+
164
+
165
+ def detect_format(path, text):
166
+ ext = os.path.splitext(path)[1].lower()
167
+ if ext in (".json", ".arb", ".po", ".pot", ".strings", ".stringsdict", ".xml"):
168
+ return {"json": "json", "arb": "arb", "po": "po", "pot": "po", "strings": "strings",
169
+ "stringsdict": "stringsdict", "xml": "android"}[ext[1:]]
170
+ head = text.lstrip()[:400]
171
+ if head.startswith("{"):
172
+ return "arb" if '"@@locale"' in text else "json"
173
+ if "<resources" in head:
174
+ return "android"
175
+ if "<plist" in head or "<!DOCTYPE plist" in head:
176
+ return "stringsdict"
177
+ if re.search(r'^\s*msgid\s+"', text, re.M):
178
+ return "po"
179
+ if re.search(r'^\s*"(?:[^"\\]|\\.)*"\s*=\s*"', text, re.M):
180
+ return "strings"
181
+ raise BadInput("%s: unknown format (use .json, .arb, .po, .xml, .strings or .stringsdict)" % path)
182
+
183
+
184
+ def json_key_lines(text):
185
+ """{dotted key path: line} for every object key in a JSON text, without a full parser."""
186
+ lines, stack, i, n, line = {}, [], 0, len(text), 1
187
+ pending, last_str = None, None
188
+ while i < n:
189
+ c = text[i]
190
+ if c == "\n":
191
+ line += 1
192
+ elif c == '"':
193
+ j, buf = i + 1, []
194
+ while j < n and text[j] != '"':
195
+ if text[j] == "\\":
196
+ j += 1
197
+ if j < n and text[j] == "\n":
198
+ line += 1
199
+ j += 1
200
+ last_str = (text[i + 1:j], line)
201
+ i = j
202
+ elif c == ":" and last_str is not None:
203
+ pending = last_str
204
+ key = ".".join([k for k in stack if k is not None] + [pending[0]])
205
+ lines.setdefault(key, pending[1])
206
+ last_str = None
207
+ elif c in "{[":
208
+ stack.append(pending[0] if pending else None)
209
+ pending, last_str = None, None
210
+ elif c in "}]":
211
+ if stack:
212
+ stack.pop()
213
+ pending, last_str = None, None
214
+ elif c == ",":
215
+ pending, last_str = None, None
216
+ i += 1
217
+ return lines
218
+
219
+
220
+ def flatten(obj, prefix=""):
221
+ if isinstance(obj, dict):
222
+ for k, v in obj.items():
223
+ yield from flatten(v, prefix + ("." if prefix else "") + str(k))
224
+ elif isinstance(obj, list):
225
+ for i, v in enumerate(obj):
226
+ yield from flatten(v, prefix + "." + str(i))
227
+ elif isinstance(obj, str):
228
+ yield prefix, obj
229
+ elif obj is None or isinstance(obj, (int, float, bool)):
230
+ yield prefix, "" if obj is None else str(obj)
231
+
232
+
233
+ PLURAL_SUFFIX = re.compile(r"^(.*)_(zero|one|two|few|many|other)$")
234
+
235
+
236
+ def parse_json(text, arb=False):
237
+ try:
238
+ doc = json.loads(text)
239
+ except ValueError as e:
240
+ raise ParseError("not valid JSON: %s" % e)
241
+ except RecursionError:
242
+ raise ParseError("nested too deeply to read")
243
+ if not isinstance(doc, dict):
244
+ raise ParseError("the top level is not an object of strings")
245
+ locale = doc.get("@@locale") if arb and isinstance(doc.get("@@locale"), str) else ""
246
+ lines = json_key_lines(text)
247
+ try:
248
+ flat = [(k, v) for k, v in flatten(doc) if not (arb and k.split(".")[0].startswith("@"))]
249
+ except RecursionError:
250
+ raise ParseError("nested too deeply to read")
251
+ keys = {k for k, _ in flat}
252
+ entries, grouped = {}, {}
253
+ for k, v in flat:
254
+ m = PLURAL_SUFFIX.match(k)
255
+ if m and (m.group(1) + "_other") in keys:
256
+ grouped.setdefault(m.group(1), {})[m.group(2)] = (v, lines.get(k))
257
+ continue
258
+ entries[k] = Entry(k, v, lines.get(k))
259
+ for base, forms in grouped.items():
260
+ line = min((ln for _, ln in forms.values() if ln), default=None)
261
+ if base in entries: # a bare key beside its plural forms is the fallback form
262
+ forms.setdefault("other", (entries.pop(base).value, line))
263
+ entries[base] = Entry(base, {c: t for c, (t, _) in forms.items()}, line, plural=True)
264
+ return entries, locale
265
+
266
+
267
+ def po_unquote(chunks):
268
+ out = []
269
+ for c in chunks:
270
+ try:
271
+ out.append(json.loads(c))
272
+ except ValueError:
273
+ out.append(c.strip('"'))
274
+ return "".join(out)
275
+
276
+
277
+ def parse_po(text, source=False):
278
+ entries, header_lang, nplurals = {}, "", None
279
+ items, cur, field, fuzzy = [], None, None, False
280
+
281
+ def close():
282
+ if cur and "msgid" in cur["f"]:
283
+ items.append(cur)
284
+
285
+ for no, raw in enumerate(text.replace("\r\n", "\n").split("\n"), 1):
286
+ s = raw.strip()
287
+ if not s:
288
+ close()
289
+ cur, field = None, None
290
+ continue
291
+ if s.startswith("#"):
292
+ if s.startswith("#,") and "fuzzy" in s:
293
+ fuzzy = True
294
+ continue
295
+ m = re.match(r'^(msgctxt|msgid_plural|msgid|msgstr(?:\[\d+\])?)\s+(".*")\s*$', s)
296
+ if m:
297
+ kw = m.group(1)
298
+ if cur is None or (kw in ("msgctxt", "msgid") and any(k.startswith("msgstr") for k in cur["f"])):
299
+ close()
300
+ cur = {"f": {}, "line": no, "fuzzy": fuzzy}
301
+ fuzzy = False
302
+ if kw == "msgid":
303
+ cur["line"] = no
304
+ field = kw
305
+ cur["f"].setdefault(kw, []).append(m.group(2))
306
+ elif s.startswith('"') and cur is not None and field:
307
+ cur["f"][field].append(s)
308
+ else:
309
+ raise ParseError("line %d: can't read %r" % (no, s[:40]))
310
+ close()
311
+ for it in items:
312
+ fields = it["f"]
313
+ msgid = po_unquote(fields["msgid"])
314
+ if msgid == "" and "msgctxt" not in fields:
315
+ hdr = po_unquote(fields.get("msgstr", []))
316
+ m = re.search(r"^Language:\s*(\S+)", hdr, re.M)
317
+ header_lang = m.group(1) if m else ""
318
+ m = re.search(r"nplurals\s*=\s*(\d+)", hdr)
319
+ nplurals = int(m.group(1)) if m else None
320
+ continue
321
+ key = (po_unquote(fields["msgctxt"]) + "\x04" if "msgctxt" in fields else "") + msgid
322
+ if "msgid_plural" in fields:
323
+ forms = {}
324
+ for f, v in fields.items():
325
+ m = re.match(r"msgstr\[(\d+)\]", f)
326
+ if m:
327
+ forms[int(m.group(1))] = po_unquote(v)
328
+ plural_src = po_unquote(fields["msgid_plural"])
329
+ if source:
330
+ value = {"one": forms.get(0) or msgid, "other": forms.get(1) or plural_src}
331
+ else:
332
+ value = {"[%d]" % i: forms[i] for i in sorted(forms)}
333
+ e = Entry(key, value, it["line"], plural=True, fuzzy=it["fuzzy"], gettext_n=len(forms))
334
+ else:
335
+ msgstr = po_unquote(fields.get("msgstr", []))
336
+ e = Entry(key, (msgstr or msgid) if source else msgstr, it["line"], fuzzy=it["fuzzy"])
337
+ entries[key] = e
338
+ if not entries and not header_lang:
339
+ raise ParseError("no msgid entries")
340
+ return entries, header_lang, nplurals
341
+
342
+
343
+ def inner_xml(el):
344
+ """An element's content with its markup, as Android keeps it."""
345
+ parts = [el.text or ""]
346
+ for child in el:
347
+ s = ET.tostring(child, encoding="unicode")
348
+ parts.append(re.sub(r'\sxmlns:\w+="[^"]*"', "", s))
349
+ return "".join(parts)
350
+
351
+
352
+ def android_raw_problem(raw):
353
+ """aapt rejects an apostrophe without a backslash outside a double-quoted string."""
354
+ body = raw.strip()
355
+ if len(body) >= 2 and body[0] == '"' and body[-1] == '"':
356
+ return None
357
+ text = re.sub(r"<[^>]*>", "", body)
358
+ if re.search(r"(?<!\\)'", text):
359
+ return "an apostrophe without a backslash: the Android build fails (write \\' )"
360
+ return None
361
+
362
+
363
+ def android_unescape(s):
364
+ s = s.strip()
365
+ if len(s) >= 2 and s[0] == '"' and s[-1] == '"':
366
+ s = s[1:-1]
367
+ return re.sub(r"\\([nt'\"@?\\])", lambda m: {"n": "\n", "t": "\t"}.get(m.group(1), m.group(1)), s)
368
+
369
+
370
+ def parse_android(text):
371
+ try:
372
+ root = ET.fromstring(text.encode("utf-8"))
373
+ except ET.ParseError as e:
374
+ raise ParseError("not valid XML: %s" % e)
375
+ if root.tag != "resources":
376
+ raise ParseError("the root element is <%s>, not <resources>" % root.tag)
377
+
378
+ def line_of(name, tag):
379
+ m = re.search(r'<%s\b[^>]*\bname="%s"' % (tag, re.escape(name)), text)
380
+ return text.count("\n", 0, m.start()) + 1 if m else None
381
+
382
+ entries = {}
383
+ for el in root:
384
+ name = el.get("name")
385
+ if not name or el.get("translatable") == "false":
386
+ continue
387
+ if el.tag == "string":
388
+ raw = inner_xml(el)
389
+ entries[name] = Entry(name, android_unescape(raw), line_of(name, "string"))
390
+ entries[name].icu_ok = android_raw_problem(raw)
391
+ elif el.tag == "plurals":
392
+ raws = {it.get("quantity"): inner_xml(it) for it in el if it.get("quantity")}
393
+ forms = {q: android_unescape(r) for q, r in raws.items()}
394
+ entries[name] = Entry(name, forms, line_of(name, "plurals"), plural=True)
395
+ entries[name].icu_ok = next((android_raw_problem(r) for r in raws.values() if android_raw_problem(r)), None)
396
+ elif el.tag == "string-array":
397
+ ln = line_of(name, "string-array")
398
+ for i, it in enumerate(el):
399
+ k = "%s[%d]" % (name, i)
400
+ entries[k] = Entry(k, android_unescape(inner_xml(it)), ln)
401
+ return entries
402
+
403
+
404
+ STRINGS_RE = re.compile(r'"((?:[^"\\]|\\.)*)"\s*=\s*"((?:[^"\\]|\\.)*)"\s*;|([A-Za-z_][\w.]*)\s*=\s*"((?:[^"\\]|\\.)*)"\s*;')
405
+
406
+
407
+ def strings_unescape(s):
408
+ def rep(m):
409
+ c = m.group(1)
410
+ if c.startswith("U") or c.startswith("u"):
411
+ try:
412
+ return chr(int(c[1:], 16))
413
+ except ValueError:
414
+ return c
415
+ return {"n": "\n", "t": "\t", "r": "\r", "0": "\0"}.get(c, c)
416
+ return re.sub(r"\\([Uu][0-9A-Fa-f]{4}|.)", rep, s)
417
+
418
+
419
+ def parse_strings(text):
420
+ body = re.sub(r"/\*.*?\*/", lambda m: re.sub(r"[^\n]", " ", m.group(0)), text, flags=re.S)
421
+ body = re.sub(r"(?m)^\s*//.*$", "", body)
422
+ entries = {}
423
+ for m in STRINGS_RE.finditer(body):
424
+ key = strings_unescape(m.group(1) if m.group(1) is not None else m.group(3))
425
+ val = strings_unescape(m.group(2) if m.group(2) is not None else m.group(4))
426
+ entries[key] = Entry(key, val, body.count("\n", 0, m.start()) + 1)
427
+ rest = STRINGS_RE.sub("", body)
428
+ stray = re.search(r'"[^"\n]*"\s*=|=\s*"', rest)
429
+ if stray:
430
+ raise ParseError("line %d: an entry that isn't \"key\" = \"value\";" % (body.count("\n", 0, stray.start()) + 1))
431
+ if not entries and rest.strip():
432
+ raise ParseError('no "key" = "value"; entries')
433
+ return entries
434
+
435
+
436
+ def parse_stringsdict(text):
437
+ try:
438
+ doc = plistlib.loads(text.encode("utf-8"))
439
+ except Exception as e: # plistlib raises several types for bad input
440
+ raise ParseError("not a valid plist: %s" % e)
441
+ if not isinstance(doc, dict):
442
+ raise ParseError("the plist's top level is not a dictionary")
443
+ entries = {}
444
+ for key, spec in doc.items():
445
+ if not isinstance(spec, dict):
446
+ continue
447
+ m = re.search(r"^\s*<key>%s</key>" % re.escape(key), text, re.M)
448
+ line = text.count("\n", 0, m.start()) + 1 if m else None
449
+ fmt = spec.get("NSStringLocalizedFormatKey")
450
+ if isinstance(fmt, str):
451
+ entries[key] = Entry(key, fmt, line)
452
+ for var, rule in spec.items():
453
+ if isinstance(rule, dict) and rule.get("NSStringFormatSpecTypeKey") == "NSStringPluralRuleType":
454
+ forms = {c: rule[c] for c in CATEGORIES if isinstance(rule.get(c), str)}
455
+ k = "%s#%s" % (key, var)
456
+ entries[k] = Entry(k, forms, line, plural=True)
457
+ return entries
458
+
459
+
460
+ def load(path, source, locale_override=""):
461
+ text = read_text(path)
462
+ fmt = detect_format(path, text)
463
+ locale, nplurals = "", None
464
+ if fmt in ("json", "arb"):
465
+ entries, locale = parse_json(text, arb=fmt == "arb")
466
+ elif fmt == "po":
467
+ entries, locale, nplurals = parse_po(text, source=source)
468
+ elif fmt == "android":
469
+ entries = parse_android(text)
470
+ elif fmt == "strings":
471
+ entries = parse_strings(text)
472
+ else:
473
+ entries = parse_stringsdict(text)
474
+ locale = locale_override or locale or detect_locale(path)
475
+ return {"path": path, "format": fmt, "locale": locale, "entries": entries, "nplurals": nplurals}
476
+
477
+
478
+ # --------------------------------------------------------------------------- placeholders
479
+
480
+ PRINTF = re.compile(r"%(?:%|(?:(\d+)\$|\((\w+)\))?[-+ 0#']*(?:\d+|\*)?(?:\.(?:\d+|\*))?"
481
+ r"(?:hh|h|ll|l|L|q|j|z|t)?([diouxXeEfFgGaAcCsSpn@]))")
482
+ DOUBLE = re.compile(r"\{\{\s*([^{}\s,]+)[^{}]*\}\}")
483
+ SINGLE = re.compile(r"(?<!\{)\{\s*([A-Za-z_][\w.]*|\d+)\s*\}(?!\})")
484
+ TAG = re.compile(r"<(/?)([A-Za-z][\w:.-]*|\d+)(\s[^<>]*?)?(/?)>")
485
+ TYPE = {"i": "d", "u": "d", "o": "d", "x": "d", "X": "d", "F": "f", "e": "f", "E": "f",
486
+ "g": "f", "G": "f", "a": "f", "A": "f", "S": "s", "C": "c"}
487
+
488
+
489
+ def printf_args(s):
490
+ """{position or name: type}, plus the error when plain and positional are mixed."""
491
+ out, plain, i = {}, 0, 0
492
+ for m in PRINTF.finditer(s):
493
+ if m.group(0) == "%%":
494
+ continue
495
+ t = TYPE.get(m.group(3), m.group(3))
496
+ if m.group(1):
497
+ out[int(m.group(1))] = t
498
+ elif m.group(2):
499
+ out[m.group(2)] = t
500
+ else:
501
+ plain += 1
502
+ out[plain] = t
503
+ return out
504
+
505
+
506
+ def printf_tokens(s):
507
+ """{position or name: the specifier as written}, numbered as printf_args numbers them."""
508
+ out, plain = {}, 0
509
+ for m in PRINTF.finditer(s):
510
+ if m.group(0) == "%%":
511
+ continue
512
+ if m.group(1):
513
+ out[int(m.group(1))] = m.group(0)
514
+ elif m.group(2):
515
+ out[m.group(2)] = m.group(0)
516
+ else:
517
+ plain += 1
518
+ out[plain] = m.group(0)
519
+ return out
520
+
521
+
522
+ def brace_args(s):
523
+ names = [m.group(1) for m in DOUBLE.finditer(s)]
524
+ names += ["{%s}" % m.group(1) for m in SINGLE.finditer(DOUBLE.sub("", s))]
525
+ return names
526
+
527
+
528
+ # --------------------------------------------------------------------------- ICU
529
+
530
+ class IcuError(Exception):
531
+ pass
532
+
533
+
534
+ def icu_parse(s):
535
+ """Parse an ICU MessageFormat string. Returns [(name, type, {keyword: ...})] for every
536
+ argument, nested ones included. Raises IcuError when the message doesn't parse."""
537
+ args, pos = [], [0]
538
+ n = len(s)
539
+
540
+ def message(depth, in_plural):
541
+ if depth > 50:
542
+ raise IcuError("nested more than 50 levels deep")
543
+ while pos[0] < n:
544
+ c = s[pos[0]]
545
+ if c == "'":
546
+ if pos[0] + 1 < n and s[pos[0] + 1] == "'":
547
+ pos[0] += 2
548
+ elif pos[0] + 1 < n and s[pos[0] + 1] in "{}#|":
549
+ end = s.find("'", pos[0] + 1)
550
+ pos[0] = n if end < 0 else end + 1
551
+ else:
552
+ pos[0] += 1
553
+ elif c == "{":
554
+ pos[0] += 1
555
+ argument(depth)
556
+ elif c == "}":
557
+ if depth == 0:
558
+ raise IcuError("an unmatched '}' at %d" % pos[0])
559
+ return
560
+ else:
561
+ pos[0] += 1
562
+ if depth:
563
+ raise IcuError("a '{' that is never closed")
564
+
565
+ def ws():
566
+ while pos[0] < n and s[pos[0]].isspace():
567
+ pos[0] += 1
568
+
569
+ def ident():
570
+ ws()
571
+ m = re.compile(r"[^\s{},'#]+").match(s, pos[0])
572
+ if not m:
573
+ raise IcuError("expected a name at %d" % pos[0])
574
+ pos[0] = m.end()
575
+ return m.group(0)
576
+
577
+ def argument(depth):
578
+ name = ident()
579
+ ws()
580
+ if pos[0] < n and s[pos[0]] == "}":
581
+ pos[0] += 1
582
+ args.append((name, "", {}))
583
+ return
584
+ if pos[0] >= n or s[pos[0]] != ",":
585
+ raise IcuError("expected ',' or '}' after %r" % name)
586
+ pos[0] += 1
587
+ kind = ident().lower()
588
+ ws()
589
+ if kind in ("plural", "selectordinal", "select"):
590
+ if pos[0] >= n or s[pos[0]] != ",":
591
+ raise IcuError("expected ',' after %s" % kind)
592
+ pos[0] += 1
593
+ branches = {}
594
+ while True:
595
+ ws()
596
+ if pos[0] < n and s[pos[0]] == "}":
597
+ pos[0] += 1
598
+ break
599
+ if pos[0] >= n:
600
+ raise IcuError("a %s that is never closed" % kind)
601
+ key = ident()
602
+ if key.startswith("offset:"):
603
+ continue
604
+ ws()
605
+ if pos[0] >= n or s[pos[0]] != "{":
606
+ raise IcuError("expected '{' after %r in %s" % (key, name))
607
+ pos[0] += 1
608
+ message(depth + 1, kind != "select")
609
+ if pos[0] >= n or s[pos[0]] != "}":
610
+ raise IcuError("a branch of %s that is never closed" % name)
611
+ pos[0] += 1
612
+ branches[key] = True
613
+ if "other" not in branches:
614
+ raise IcuError("%s %r has no 'other' branch" % (kind, name))
615
+ args.append((name, kind, branches))
616
+ else:
617
+ if pos[0] < n and s[pos[0]] == ",":
618
+ end = s.find("}", pos[0])
619
+ if end < 0:
620
+ raise IcuError("an argument that is never closed")
621
+ pos[0] = end
622
+ ws()
623
+ if pos[0] >= n or s[pos[0]] != "}":
624
+ raise IcuError("expected '}' to close %r" % name)
625
+ pos[0] += 1
626
+ args.append((name, kind, {}))
627
+
628
+ message(0, False)
629
+ return args
630
+
631
+
632
+ ICU_COMPLEX = re.compile(r"\{\s*[\w.]+\s*,\s*(?:plural|select|selectordinal|number|date|time|spellout|ordinal)\b")
633
+
634
+
635
+ def looks_icu(s):
636
+ """An ICU message with a formatted argument. A string with only {name} placeholders is
637
+ checked as plain text, so a dropped placeholder gets its own finding and fix."""
638
+ return "{{" not in s and bool(ICU_COMPLEX.search(s))
639
+
640
+
641
+ # --------------------------------------------------------------------------- checks
642
+
643
+ def visible(s):
644
+ """Characters a reader sees: markup and placeholders out, combining marks not counted."""
645
+ s = TAG.sub("", s)
646
+ s = PRINTF.sub(lambda m: "%" if m.group(0) == "%%" else "", s)
647
+ s = DOUBLE.sub("", s)
648
+ s = SINGLE.sub("", s)
649
+ return sum(1 for ch in unicodedata.normalize("NFC", s) if not unicodedata.combining(ch))
650
+
651
+
652
+ def tags_of(s):
653
+ return [(m.group(1) == "/", m.group(2).lower(), m.group(4) == "/") for m in TAG.finditer(s)]
654
+
655
+
656
+ def tag_problem(src, tgt):
657
+ a, b = tags_of(src), tags_of(tgt)
658
+ if sorted(a) != sorted(b):
659
+ names = lambda ts: ", ".join("<%s%s%s>" % ("/" if c else "", t, "/" if sc else "") for c, t, sc in ts) or "none"
660
+ return "markup differs from the source: source has %s, target has %s" % (names(a), names(b))
661
+ stack = []
662
+ for close, name, self_close in b:
663
+ if self_close:
664
+ continue
665
+ if not close:
666
+ stack.append(name)
667
+ elif not stack or stack.pop() != name:
668
+ return "markup tags don't nest: </%s> closes the wrong tag" % name
669
+ if stack:
670
+ return "markup tag <%s> is never closed" % stack[-1]
671
+ return None
672
+
673
+
674
+ SCRIPT_OF = (("LATIN", "Latin"), ("CYRILLIC", "Cyrillic"), ("GREEK", "Greek"))
675
+
676
+
677
+ def mixed_script(s):
678
+ for word in re.findall(r"\w+", s):
679
+ seen = set()
680
+ for ch in word:
681
+ if ch.isalpha():
682
+ name = unicodedata.name(ch, "")
683
+ for k, v in SCRIPT_OF:
684
+ if name.startswith(k):
685
+ seen.add(v)
686
+ if len(seen) > 1:
687
+ return word, sorted(seen)
688
+ return None
689
+
690
+
691
+ END_PUNCT = set(".!?:…。!?:؟;·।")
692
+
693
+
694
+ def letters(s):
695
+ return sum(1 for ch in TAG.sub("", PRINTF.sub("", SINGLE.sub("", DOUBLE.sub("", s)))) if ch.isalpha())
696
+
697
+
698
+ class Report(object):
699
+ def __init__(self):
700
+ self.failures, self.warnings = [], []
701
+
702
+ def add(self, level, locale, key, where, problem, source="", target="", fix=""):
703
+ row = {"locale": locale, "key": key.replace("\x04", " | "), "where": where, "problem": problem,
704
+ "source": source, "target": target}
705
+ if fix:
706
+ row["fix"] = fix
707
+ (self.failures if level == "fail" else self.warnings).append(row)
708
+
709
+
710
+ def where(t, e):
711
+ return "%s:%s" % (os.path.basename(t["path"]), e.line) if e and e.line else os.path.basename(t["path"])
712
+
713
+
714
+ def short(v):
715
+ if isinstance(v, dict):
716
+ return "; ".join("%s: %s" % (k, x) for k, x in v.items())
717
+ return v
718
+
719
+
720
+ def compare_placeholders(src_texts, tgt_text):
721
+ """A problem with one target string's placeholders against the source's, or None."""
722
+ src_join = " ".join(src_texts)
723
+ sp, tp = {}, printf_args(tgt_text)
724
+ shown = {}
725
+ for s in src_texts:
726
+ for k, v in printf_args(s).items():
727
+ sp.setdefault(k, v)
728
+ for k, v in printf_tokens(s).items():
729
+ shown.setdefault(("s", k), v)
730
+ for k, v in printf_tokens(tgt_text).items():
731
+ shown.setdefault(("t", k), v)
732
+
733
+ def fmt_printf(k, t, side="s"):
734
+ return shown.get((side, k)) or ("%%%s$%s" % (k, t) if isinstance(k, int) else "%%(%s)%s" % (k, t))
735
+ if len(src_texts) == 1:
736
+ if sp != tp:
737
+ missing = [k for k in sp if k not in tp]
738
+ extra = [k for k in tp if k not in sp]
739
+ changed = [k for k in sp if k in tp and sp[k] != tp[k]]
740
+ bits = []
741
+ if missing:
742
+ bits.append("missing %s" % ", ".join(fmt_printf(k, sp[k]) for k in missing))
743
+ if extra:
744
+ bits.append("extra %s" % ", ".join(fmt_printf(k, tp[k], "t") for k in extra))
745
+ if changed:
746
+ named = all(not isinstance(k, int) for k in changed)
747
+ bits.append("the arguments are %s in the source but %s here, %s" % (
748
+ " ".join(fmt_printf(k, sp[k]) for k in sorted(sp, key=str)),
749
+ " ".join(fmt_printf(k, tp[k], "t") for k in sorted(tp, key=str)),
750
+ "a changed type that msgfmt --check and translation builds reject" if named else
751
+ "so the values swap at runtime or the app crashes formatting them"))
752
+ if not bits:
753
+ bits.append("arguments reordered")
754
+ return "printf placeholders differ: " + "; ".join(bits)
755
+ else:
756
+ extra = [k for k in tp if k not in sp or sp[k] != tp[k]]
757
+ if extra:
758
+ return "printf placeholder not in the source: %s" % ", ".join(fmt_printf(k, tp[k], "t") for k in extra)
759
+ sb, tb = brace_args(src_join) if len(src_texts) > 1 else brace_args(src_texts[0]), brace_args(tgt_text)
760
+ if len(src_texts) == 1:
761
+ if sorted(sb) != sorted(tb):
762
+ missing = [x for x in set(sb) if sb.count(x) > tb.count(x)]
763
+ extra = [x for x in set(tb) if tb.count(x) > sb.count(x)]
764
+ bits = (["missing " + ", ".join(sorted(fmt_brace(x) for x in missing))] if missing else []) + \
765
+ (["extra " + ", ".join(sorted(fmt_brace(x) for x in extra))] if extra else [])
766
+ return "placeholders differ: " + "; ".join(bits)
767
+ else:
768
+ extra = sorted(set(tb) - set(sb))
769
+ if extra:
770
+ return "placeholder not in the source: " + ", ".join(fmt_brace(x) for x in extra)
771
+ return None
772
+
773
+
774
+ def fmt_printf(k, t):
775
+ return "%%%s$%s" % (k, t) if isinstance(k, int) else "%%(%s)%s" % (k, t)
776
+
777
+
778
+ def fmt_brace(x):
779
+ return x if x.startswith("{") else "{{%s}}" % x
780
+
781
+
782
+ def required_plurals(lang):
783
+ cats = CLDR.get(lang)
784
+ return set(cats) if cats else None
785
+
786
+
787
+ def check_target(src, tgt, budget, rep):
788
+ loc = tgt["locale"] or "?"
789
+ lang = lang_of(tgt["locale"])
790
+ S, T = src["entries"], tgt["entries"]
791
+ gettext_n = NPLURALS.get(lang)
792
+ if tgt["format"] == "po" and tgt["nplurals"] and gettext_n and tgt["nplurals"] != gettext_n:
793
+ rep.add("fail", loc, "(Plural-Forms header)", os.path.basename(tgt["path"]),
794
+ "the header sets nplurals=%d; %s needs %d" % (tgt["nplurals"], lang, gettext_n))
795
+ for key, se in S.items():
796
+ te = T.get(key)
797
+ w = where(tgt, te)
798
+ if te is None:
799
+ rep.add("fail", loc, key, os.path.basename(tgt["path"]), "missing", short(se.value), "")
800
+ continue
801
+ if tgt["format"] == "android" and te.icu_ok:
802
+ rep.add("fail", loc, key, w, te.icu_ok, short(se.value), short(te.value))
803
+ if te.fuzzy:
804
+ rep.add("warn", loc, key, w, "marked fuzzy: msgfmt leaves it out, so the source text shows",
805
+ short(se.value), short(te.value))
806
+ if se.plural and not te.plural:
807
+ te = Entry(te.key, {"other": te.value}, te.line, plural=True)
808
+ if not se.plural and te.plural:
809
+ rep.add("fail", loc, key, w, "a plural set where the source has one string", se.value, short(te.value))
810
+ continue
811
+ if se.plural:
812
+ check_plural(se, te, tgt, lang, loc, w, rep)
813
+ src_texts = [x for x in se.value.values() if x]
814
+ else:
815
+ src_texts = [se.value]
816
+ if te.value.strip() == "" and se.value.strip():
817
+ rep.add("fail", loc, key, w, "empty translation", se.value, te.value)
818
+ continue
819
+ for label, tv in (te.value.items() if te.plural else [("", te.value)]):
820
+ if te.plural and tv == "":
821
+ continue
822
+ tag = ("[%s] " % label) if label else ""
823
+ sv = src_texts[0] if len(src_texts) == 1 else (se.value.get(label) or se.value.get("other") or src_texts[0])
824
+ icu_src = [x for x in src_texts if looks_icu(x)]
825
+ icu_handled = False
826
+ if icu_src:
827
+ try:
828
+ sargs = [a for x in icu_src for a in icu_parse(x)]
829
+ src_ok = True
830
+ except IcuError:
831
+ src_ok = False
832
+ if src_ok:
833
+ icu_handled = True
834
+ try:
835
+ targs = icu_parse(tv)
836
+ except IcuError as e:
837
+ rep.add("fail", loc, key, w, tag + "ICU message doesn't parse: %s" % e, sv, tv)
838
+ continue
839
+ check_icu(sargs, targs, lang, loc, key, w, tag, sv, tv, rep)
840
+ if not icu_handled:
841
+ last = label == "other" or (tgt["format"] == "po" and label == "[%d]" % (len(te.value) - 1))
842
+ strict = (not se.plural) or (last and len(te.value) > 1)
843
+ p = compare_placeholders([se.value.get("other", sv)] if se.plural and strict else
844
+ (src_texts if se.plural else [sv]), tv)
845
+ if p:
846
+ rep.add("fail", loc, key, w, tag + p, sv, tv, fix_for(sv, tv))
847
+ elif PRINTF.search(sv.replace("%%", "")) or PRINTF.search(tv.replace("%%", "")):
848
+ p = compare_placeholders([sv], tv)
849
+ if p and "printf" in p:
850
+ rep.add("fail", loc, key, w, tag + p, sv, tv)
851
+ t = tag_problem(sv, tv)
852
+ if t:
853
+ rep.add("fail", loc, key, w, tag + t, sv, tv)
854
+ limit = budget_limit(budget, key, sv)
855
+ if limit is not None and visible(tv) > limit:
856
+ rep.add("fail", loc, key, w, tag + "%d characters, over the budget of %d" % (visible(tv), limit), sv, tv)
857
+ style_warnings(sv, tv, loc, key, w, tag, rep, tgt)
858
+ for key, te in T.items():
859
+ if key not in S and tgt["format"] == "android" and te.icu_ok:
860
+ rep.add("fail", loc, key, where(tgt, te), te.icu_ok, "", short(te.value))
861
+ if key not in S:
862
+ rep.add("warn", loc, key, where(tgt, te), "not in the source (stale or misspelled key)", "", short(te.value))
863
+
864
+
865
+ def check_plural(se, te, tgt, lang, loc, w, rep):
866
+ if tgt["format"] == "po":
867
+ need = NPLURALS.get(lang)
868
+ have = te.gettext_n if te.gettext_n is not None else len(te.value)
869
+ if need and have != need:
870
+ rep.add("fail", loc, se.key, w, "has %d plural form%s; %s needs %d (msgstr[0] to msgstr[%d])" % (
871
+ have, "" if have == 1 else "s", lang, need, need - 1),
872
+ short(se.value), short(te.value))
873
+ for k, v in te.value.items():
874
+ if not v.strip():
875
+ rep.add("fail", loc, se.key, w, "plural form %s is empty" % k, short(se.value), short(te.value))
876
+ return
877
+ need = required_plurals(lang)
878
+ have = {k for k, v in te.value.items() if v != ""}
879
+ if need is None:
880
+ if "other" not in have:
881
+ rep.add("fail", loc, se.key, w, "no 'other' plural form", short(se.value), short(te.value))
882
+ return
883
+ missing = need - have
884
+ soft = missing & SOFT.get(lang, set())
885
+ hard = missing - soft
886
+ if hard:
887
+ rep.add("fail", loc, se.key, w, "plural forms %s missing; %s needs %s" % (
888
+ ", ".join(c for c in CATEGORIES if c in hard), lang, ", ".join(c for c in CATEGORIES if c in need)),
889
+ short(se.value), short(te.value))
890
+ if soft:
891
+ rep.add("warn", loc, se.key, w, "plural form 'many' missing; %s uses it for large numbers such as 1 000 000" % lang,
892
+ short(se.value), short(te.value))
893
+ extra = {k for k in have if k in CATEGORIES} - need
894
+ if extra:
895
+ rep.add("warn", loc, se.key, w, "plural forms %s are never used in %s" % (
896
+ ", ".join(c for c in CATEGORIES if c in extra), lang), short(se.value), short(te.value))
897
+
898
+
899
+ def check_icu(sargs, targs, lang, loc, key, w, tag, sv, tv, rep):
900
+ sn = {(a, k if k in ("plural", "select", "selectordinal") else "") for a, k, _ in sargs}
901
+ tn = {(a, k if k in ("plural", "select", "selectordinal") else "") for a, k, _ in targs}
902
+ if sn != tn:
903
+ missing = sorted("{%s}" % a for a, _ in sn - tn)
904
+ extra = sorted("{%s}" % a for a, _ in tn - sn)
905
+ bits = (["missing " + ", ".join(missing)] if missing else []) + (["extra " + ", ".join(extra)] if extra else [])
906
+ rep.add("fail", loc, key, w, tag + "ICU arguments differ: " + "; ".join(bits or ["an argument changed type"]), sv, tv)
907
+ sel_src = {a: set(b) for a, k, b in sargs if k == "select"}
908
+ for a, k, b in targs:
909
+ if k == "select" and a in sel_src:
910
+ lost = sel_src[a] - set(b)
911
+ if lost:
912
+ rep.add("fail", loc, key, w, tag + "select %r lost keyword %s (keywords are code, not text)" % (
913
+ a, ", ".join(sorted(lost))), sv, tv)
914
+ if k == "plural":
915
+ need = required_plurals(lang)
916
+ if need:
917
+ have = {x for x in b if x in CATEGORIES}
918
+ missing = need - have - SOFT.get(lang, set())
919
+ if missing:
920
+ rep.add("fail", loc, key, w, tag + "ICU plural %r is missing %s; %s needs %s" % (
921
+ a, ", ".join(c for c in CATEGORIES if c in missing), lang,
922
+ ", ".join(c for c in CATEGORIES if c in need)), sv, tv)
923
+ bad = [x for x in b if x not in CATEGORIES and not re.match(r"^=\d+$", x)]
924
+ if bad:
925
+ rep.add("fail", loc, key, w, tag + "ICU plural %r has keyword %s, which isn't a plural category" % (
926
+ a, ", ".join(sorted(bad))), sv, tv)
927
+
928
+
929
+ def fix_for(sv, tv):
930
+ """A mechanical fix when exactly one placeholder was dropped and the source has it once:
931
+ say what to restore, never where in the sentence, which takes the language."""
932
+ sb, tb = brace_args(sv), brace_args(tv)
933
+ sp, tp = printf_args(sv), printf_args(tv)
934
+ gone = [x for x in set(sb) if sb.count(x) > tb.count(x)]
935
+ if len(gone) == 1 and sp == tp and not [x for x in tb if x not in sb]:
936
+ return "restore %s" % fmt_brace(gone[0])
937
+ if sb == tb and len(sp) > 1 and sorted(sp.values()) == sorted(tp.values()) and set(sp) == set(tp) and sp != tp:
938
+ # Number each of the target's plain specifiers with the source argument of its type, in order.
939
+ free = {t: [k for k in sorted(k for k in sp if isinstance(k, int)) if sp[k] == t] for t in set(sp.values())}
940
+ out, ok = [], True
941
+
942
+ def num(m):
943
+ nonlocal ok
944
+ if m.group(0) == "%%" or m.group(1) or m.group(2):
945
+ return m.group(0)
946
+ t = TYPE.get(m.group(3), m.group(3))
947
+ if not free.get(t):
948
+ ok = False
949
+ return m.group(0)
950
+ k = free[t].pop(0)
951
+ return m.group(0).replace("%", "%%%d$" % k, 1)
952
+ fixed = PRINTF.sub(num, tv)
953
+ return ("use %s" % fixed) if ok and len({v for v in sp.values()}) == len(sp) else \
954
+ "number the arguments in source order (%s) so a translation can move them" % " ".join(
955
+ "%%%d$%s" % (k, v) for k, v in sorted(sp.items()))
956
+ return ""
957
+
958
+
959
+ def style_warnings(sv, tv, loc, key, w, tag, rep, tgt):
960
+ if sv == tv and letters(sv) > 3 and not re.match(r"^[\w.+-]+@|^https?://", sv) and lang_of(tgt["locale"]) != "en":
961
+ rep.add("warn", loc, key, w, tag + "identical to the source: untranslated?", sv, tv)
962
+ if (sv[:1].isspace()) != (tv[:1].isspace()) or (sv[-1:].isspace()) != (tv[-1:].isspace()):
963
+ rep.add("warn", loc, key, w, tag + "leading or trailing space changed", repr(sv), repr(tv))
964
+ s_end = sv.rstrip()[-1:] if sv.rstrip() else ""
965
+ t_end = tv.rstrip()[-1:] if tv.rstrip() else ""
966
+ if s_end in END_PUNCT and t_end not in END_PUNCT and tv.strip():
967
+ rep.add("warn", loc, key, w, tag + "ending punctuation %r dropped" % s_end, sv, tv)
968
+ mix = mixed_script(tv)
969
+ if mix:
970
+ rep.add("warn", loc, key, w, tag + "%r mixes %s letters (a look-alike typed by mistake?)" % (
971
+ mix[0], " and ".join(mix[1])), sv, tv)
972
+
973
+
974
+ # --------------------------------------------------------------------------- budget
975
+
976
+ def load_budget(path):
977
+ text = read_text(path)
978
+ if path.lower().endswith(".csv"):
979
+ keys = {}
980
+ rows = csv.DictReader(io.StringIO(text))
981
+ cols = {c.lower().strip(): c for c in (rows.fieldnames or [])}
982
+ if "key" not in cols or not ({"max", "max_chars", "limit"} & set(cols)):
983
+ raise BadInput("%s: a budget CSV needs columns key and max" % path)
984
+ mc = cols.get("max") or cols.get("max_chars") or cols.get("limit")
985
+ for r in rows:
986
+ try:
987
+ keys[r[cols["key"]].strip()] = int(r[mc])
988
+ except (TypeError, ValueError):
989
+ raise BadInput("%s: %r is not a number of characters" % (path, r.get(mc)))
990
+ return {"keys": keys, "ratio": None, "ratio_min_source": 10}
991
+ try:
992
+ doc = json.loads(text)
993
+ except ValueError as e:
994
+ raise BadInput("%s: not valid JSON: %s" % (path, e))
995
+ if not isinstance(doc, dict):
996
+ raise BadInput("%s: the budget must be an object" % path)
997
+ keys = doc.get("keys", {})
998
+ if not isinstance(keys, dict) or not all(isinstance(v, int) and not isinstance(v, bool) for v in keys.values()):
999
+ raise BadInput("%s: \"keys\" must map each key to a whole number of characters" % path)
1000
+ ratio = doc.get("ratio")
1001
+ if ratio is not None and (not isinstance(ratio, (int, float)) or isinstance(ratio, bool) or ratio <= 0):
1002
+ raise BadInput("%s: \"ratio\" must be a positive number" % path)
1003
+ rmin = doc.get("ratio_min_source", 10)
1004
+ if not isinstance(rmin, int) or isinstance(rmin, bool):
1005
+ raise BadInput("%s: \"ratio_min_source\" must be a whole number" % path)
1006
+ return {"keys": keys, "ratio": ratio, "ratio_min_source": rmin}
1007
+
1008
+
1009
+ def budget_limit(budget, key, sv):
1010
+ if not budget:
1011
+ return None
1012
+ k = key.replace("\x04", " | ")
1013
+ if k in budget["keys"]:
1014
+ return budget["keys"][k]
1015
+ if budget["ratio"] and visible(sv) >= budget["ratio_min_source"]:
1016
+ return int(math.ceil(visible(sv) * budget["ratio"]))
1017
+ return None
1018
+
1019
+
1020
+ # --------------------------------------------------------------------------- output
1021
+
1022
+ def esc(s, width=90):
1023
+ s = "" if s is None else str(s)
1024
+ s = s.replace("\n", "\\n").replace("|", "\\|")
1025
+ return s if len(s) <= width else s[:width - 3] + "..."
1026
+
1027
+
1028
+ def render(src, targets, rep, budget):
1029
+ out = ["# Translation check", ""]
1030
+ out.append("Source: `%s` (%s), %d strings." % (os.path.basename(src["path"]), src["locale"] or "locale unknown",
1031
+ len(src["entries"])))
1032
+ for t in targets:
1033
+ out.append("Target: `%s` (%s), %d strings." % (os.path.basename(t["path"]), t["locale"] or "locale unknown",
1034
+ len(t["entries"])))
1035
+ out.append("")
1036
+ nf, nw = len(rep.failures), len(rep.warnings)
1037
+ out.append("**%s.** %d warning(s)." % ("No failures" if not nf else "%d failure(s)" % nf, nw))
1038
+ if not budget:
1039
+ out.append("No character budget given, so string lengths were not checked.")
1040
+ out.append("")
1041
+ for title, rows in (("Failures", rep.failures), ("Warnings", rep.warnings)):
1042
+ if not rows:
1043
+ continue
1044
+ out += ["## " + title, "", "| Locale | Key | Problem | Where | Source | Target | Fix |", "|---|---|---|---|---|---|---|"]
1045
+ for r in sorted(rows, key=lambda r: (r["locale"], r["key"])):
1046
+ out.append("| %s | `%s` | %s | %s | %s | %s | %s |" % (
1047
+ esc(r["locale"]), esc(r["key"]), esc(r["problem"], 240), esc(r["where"]), esc(r["source"]),
1048
+ esc(r["target"]), esc(r.get("fix", ""), 160)))
1049
+ out.append("")
1050
+ return "\n".join(out) + "\n"
1051
+
1052
+
1053
+ def main(argv=None):
1054
+ ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
1055
+ ap.add_argument("source")
1056
+ ap.add_argument("targets", nargs="+")
1057
+ ap.add_argument("--locale", action="append", default=[],
1058
+ help="the locale of each target, in order (overrides the path)")
1059
+ ap.add_argument("--source-locale", default="")
1060
+ ap.add_argument("--budget", help="JSON or CSV of character limits")
1061
+ ap.add_argument("--json", action="store_true")
1062
+ ap.add_argument("-o", "--out")
1063
+ a = ap.parse_args(argv)
1064
+ try:
1065
+ try:
1066
+ src = load(a.source, True, a.source_locale)
1067
+ except ParseError as e:
1068
+ raise BadInput("%s: %s" % (a.source, e))
1069
+ budget = load_budget(a.budget) if a.budget else None
1070
+ rep, targets = Report(), []
1071
+ for i, path in enumerate(a.targets):
1072
+ override = a.locale[i] if i < len(a.locale) else ""
1073
+ try:
1074
+ t = load(path, False, override)
1075
+ except ParseError as e:
1076
+ loc = override or detect_locale(path) or "?"
1077
+ rep.add("fail", loc, "(file)", os.path.basename(path), "the file doesn't parse: %s" % e)
1078
+ continue
1079
+ targets.append(t)
1080
+ check_target(src, t, budget, rep)
1081
+ except BadInput as e:
1082
+ print("error: %s" % e, file=sys.stderr)
1083
+ return 2
1084
+ if a.json:
1085
+ text = json.dumps({"source": {"file": src["path"], "locale": src["locale"], "strings": len(src["entries"])},
1086
+ "targets": [{"file": t["path"], "locale": t["locale"], "format": t["format"],
1087
+ "strings": len(t["entries"])} for t in targets],
1088
+ "lengths_checked": bool(budget), "failures": rep.failures, "warnings": rep.warnings},
1089
+ ensure_ascii=False, indent=2) + "\n"
1090
+ else:
1091
+ text = render(src, targets, rep, budget)
1092
+ if a.out:
1093
+ try:
1094
+ with open(a.out, "w", encoding="utf-8") as fh:
1095
+ fh.write(text)
1096
+ except OSError as e:
1097
+ print("error: can't write %s: %s" % (a.out, e.strerror or e), file=sys.stderr)
1098
+ return 2
1099
+ else:
1100
+ sys.stdout.write(text)
1101
+ return 1 if rep.failures else 0
1102
+
1103
+
1104
+ if __name__ == "__main__":
1105
+ sys.exit(main())