skilldrop-cli 1.6.0 → 1.8.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.
- package/README.md +3 -0
- package/catalogue.json +25 -2
- package/model-routing.json +20 -0
- package/package.json +1 -1
- package/packs/event-planner/pack.json +61 -0
- package/packs/event-planner/skills/run-of-show/SKILL.md +144 -0
- package/packs/event-planner/skills/run-of-show/evals/eval_queries.json +30 -0
- package/packs/event-planner/skills/run-of-show/evals/evals.json +103 -0
- package/packs/event-planner/skills/run-of-show/evals/files/1/agenda.md +16 -0
- package/packs/event-planner/skills/run-of-show/evals/files/2/schedule.csv +8 -0
- package/packs/event-planner/skills/run-of-show/evals/files/3/notes.txt +3 -0
- package/packs/event-planner/skills/run-of-show/evals/files/4/sessions_export.csv +25 -0
- package/packs/event-planner/skills/run-of-show/evals/files/4/venue_and_crew.txt +27 -0
- package/packs/event-planner/skills/run-of-show/evals/files/5/runsheet_v4.csv +28 -0
- package/packs/event-planner/skills/run-of-show/evals/files/5/venue_and_suppliers.txt +22 -0
- package/packs/event-planner/skills/run-of-show/evals/files/6/acts_from_booker.csv +26 -0
- package/packs/event-planner/skills/run-of-show/evals/files/6/venue_rooms_and_crew.txt +14 -0
- package/packs/event-planner/skills/run-of-show/evals/files/7/runsheet_draft_v4.csv +25 -0
- package/packs/event-planner/skills/run-of-show/evals/files/7/venue_and_fixed_times.txt +24 -0
- package/packs/event-planner/skills/run-of-show/examples/product-launch.md +77 -0
- package/packs/event-planner/skills/run-of-show/examples/product-launch.svg +58 -0
- package/packs/event-planner/skills/run-of-show/manifest.json +44 -0
- package/packs/event-planner/skills/run-of-show/scripts/show_check.py +295 -0
- package/packs/event-planner/skills/run-of-show/templates/show.json +15 -0
- package/packs/event-planner/skills/seating-plan/SKILL.md +155 -0
- package/packs/event-planner/skills/seating-plan/evals/eval_queries.json +30 -0
- package/packs/event-planner/skills/seating-plan/evals/evals.json +164 -0
- package/packs/event-planner/skills/seating-plan/evals/files/1/guests.csv +31 -0
- package/packs/event-planner/skills/seating-plan/evals/files/10/hall_notes.txt +13 -0
- package/packs/event-planner/skills/seating-plan/evals/files/10/quiz_night_registrations.csv +67 -0
- package/packs/event-planner/skills/seating-plan/evals/files/11/notes_for_the_plan.txt +21 -0
- package/packs/event-planner/skills/seating-plan/evals/files/11/rsvp_list.csv +77 -0
- package/packs/event-planner/skills/seating-plan/evals/files/11/table_plan_draft.csv +78 -0
- package/packs/event-planner/skills/seating-plan/evals/files/2/table-plan.txt +8 -0
- package/packs/event-planner/skills/seating-plan/evals/files/3/brief.txt +4 -0
- package/packs/event-planner/skills/seating-plan/evals/files/3/guests.csv +49 -0
- package/packs/event-planner/skills/seating-plan/evals/files/4/rsvp_export.csv +127 -0
- package/packs/event-planner/skills/seating-plan/evals/files/4/seating_notes.txt +21 -0
- package/packs/event-planner/skills/seating-plan/evals/files/5/organiser_notes.txt +10 -0
- package/packs/event-planner/skills/seating-plan/evals/files/5/registrations.csv +93 -0
- package/packs/event-planner/skills/seating-plan/evals/files/5/table_plan_v3.csv +94 -0
- package/packs/event-planner/skills/seating-plan/evals/files/6/bridgets_notes.txt +30 -0
- package/packs/event-planner/skills/seating-plan/evals/files/6/ticket_export.csv +117 -0
- package/packs/event-planner/skills/seating-plan/evals/files/6/venue_floorplan_notes.txt +14 -0
- package/packs/event-planner/skills/seating-plan/evals/files/7/keep_apart_CONFIDENTIAL.txt +9 -0
- package/packs/event-planner/skills/seating-plan/evals/files/7/prom_seating_v3.csv +101 -0
- package/packs/event-planner/skills/seating-plan/evals/files/7/ticket_list.csv +101 -0
- package/packs/event-planner/skills/seating-plan/evals/files/7/venue_info.txt +16 -0
- package/packs/event-planner/skills/seating-plan/evals/files/8/registrations_export.csv +147 -0
- package/packs/event-planner/skills/seating-plan/evals/files/8/room_layout.md +22 -0
- package/packs/event-planner/skills/seating-plan/evals/files/8/seating_requests.txt +31 -0
- package/packs/event-planner/skills/seating-plan/evals/files/9/guest_list.csv +84 -0
- package/packs/event-planner/skills/seating-plan/evals/files/9/notes.txt +16 -0
- package/packs/event-planner/skills/seating-plan/evals/files/9/table_plan.csv +85 -0
- package/packs/event-planner/skills/seating-plan/examples/wedding-dinner.md +61 -0
- package/packs/event-planner/skills/seating-plan/examples/wedding-dinner.svg +39 -0
- package/packs/event-planner/skills/seating-plan/manifest.json +44 -0
- package/packs/event-planner/skills/seating-plan/scripts/seating_check.py +324 -0
- package/packs/event-planner/skills/seating-plan/templates/seating.json +28 -0
- package/packs/experience-design/pack.json +5 -0
- package/packs/experience-design/skills/locale-check/SKILL.md +139 -0
- package/packs/experience-design/skills/locale-check/evals/eval_queries.json +38 -0
- package/packs/experience-design/skills/locale-check/evals/evals.json +104 -0
- package/packs/experience-design/skills/locale-check/evals/files/1/budget.json +1 -0
- package/packs/experience-design/skills/locale-check/evals/files/1/locales/ar.json +16 -0
- package/packs/experience-design/skills/locale-check/evals/files/1/locales/de.json +12 -0
- package/packs/experience-design/skills/locale-check/evals/files/1/locales/en.json +12 -0
- package/packs/experience-design/skills/locale-check/evals/files/1/locales/pl.json +12 -0
- package/packs/experience-design/skills/locale-check/evals/files/2/app/src/main/res/values/strings.xml +12 -0
- package/packs/experience-design/skills/locale-check/evals/files/2/app/src/main/res/values-es/strings.xml +11 -0
- package/packs/experience-design/skills/locale-check/evals/files/2/app/src/main/res/values-fr/strings.xml +12 -0
- package/packs/experience-design/skills/locale-check/evals/files/3/po/ja.po +20 -0
- package/packs/experience-design/skills/locale-check/evals/files/3/po/messages.pot +23 -0
- package/packs/experience-design/skills/locale-check/evals/files/3/po/ru.po +22 -0
- package/packs/experience-design/skills/locale-check/evals/files/4/budgets.json +15 -0
- package/packs/experience-design/skills/locale-check/evals/files/4/locales/ar.json +265 -0
- package/packs/experience-design/skills/locale-check/evals/files/4/locales/de.json +265 -0
- package/packs/experience-design/skills/locale-check/evals/files/4/locales/en.json +265 -0
- package/packs/experience-design/skills/locale-check/evals/files/4/locales/fr.json +265 -0
- package/packs/experience-design/skills/locale-check/evals/files/4/locales/pl.json +265 -0
- package/packs/experience-design/skills/locale-check/evals/files/5/app/src/main/res/values/strings.xml +77 -0
- package/packs/experience-design/skills/locale-check/evals/files/5/app/src/main/res/values-es/strings.xml +63 -0
- package/packs/experience-design/skills/locale-check/evals/files/5/app/src/main/res/values-ja/strings.xml +61 -0
- package/packs/experience-design/skills/locale-check/evals/files/6/po/cs.po +605 -0
- package/packs/experience-design/skills/locale-check/evals/files/6/po/marlin.pot +602 -0
- package/packs/experience-design/skills/locale-check/evals/files/6/po/pt_BR.po +602 -0
- package/packs/experience-design/skills/locale-check/evals/files/6/po/ru.po +608 -0
- package/packs/experience-design/skills/locale-check/evals/files/7/Tidewatch/Resources/en.lproj/Localizable.strings +160 -0
- package/packs/experience-design/skills/locale-check/evals/files/7/Tidewatch/Resources/en.lproj/Localizable.stringsdict +70 -0
- package/packs/experience-design/skills/locale-check/evals/files/7/Tidewatch/Resources/he.lproj/Localizable.strings +157 -0
- package/packs/experience-design/skills/locale-check/evals/files/7/Tidewatch/Resources/he.lproj/Localizable.stringsdict +86 -0
- package/packs/experience-design/skills/locale-check/evals/files/7/Tidewatch/Resources/it.lproj/Localizable.strings +160 -0
- package/packs/experience-design/skills/locale-check/evals/files/7/Tidewatch/Resources/it.lproj/Localizable.stringsdict +68 -0
- package/packs/experience-design/skills/locale-check/evals/files/7/Tidewatch/Resources/ko.lproj/Localizable.strings +160 -0
- package/packs/experience-design/skills/locale-check/evals/files/7/Tidewatch/Resources/ko.lproj/Localizable.stringsdict +62 -0
- package/packs/experience-design/skills/locale-check/examples/settings-release.md +107 -0
- package/packs/experience-design/skills/locale-check/manifest.json +45 -0
- package/packs/experience-design/skills/locale-check/scripts/locale_check.py +1105 -0
- package/packs/interior-designer/pack.json +60 -0
- package/packs/interior-designer/skills/furniture-layout/SKILL.md +182 -0
- package/packs/interior-designer/skills/furniture-layout/evals/eval_queries.json +11 -0
- package/packs/interior-designer/skills/furniture-layout/evals/evals.json +133 -0
- package/packs/interior-designer/skills/furniture-layout/evals/files/2/living-room-notes.md +24 -0
- package/packs/interior-designer/skills/furniture-layout/evals/files/3/office-brief.md +18 -0
- package/packs/interior-designer/skills/furniture-layout/evals/files/4/furniture_schedule.csv +15 -0
- package/packs/interior-designer/skills/furniture-layout/evals/files/4/site_survey.txt +22 -0
- package/packs/interior-designer/skills/furniture-layout/evals/files/5/layout_v2.csv +14 -0
- package/packs/interior-designer/skills/furniture-layout/evals/files/5/measurements.txt +16 -0
- package/packs/interior-designer/skills/furniture-layout/evals/files/6/access_consultant_email.txt +19 -0
- package/packs/interior-designer/skills/furniture-layout/evals/files/6/furniture_stock.csv +5 -0
- package/packs/interior-designer/skills/furniture-layout/evals/files/6/site_measurements.txt +14 -0
- package/packs/interior-designer/skills/furniture-layout/evals/files/7/room_214_layout.csv +11 -0
- package/packs/interior-designer/skills/furniture-layout/evals/files/7/room_and_brand_standard.txt +20 -0
- package/packs/interior-designer/skills/furniture-layout/evals/files/8/email_from_practice_manager.txt +44 -0
- package/packs/interior-designer/skills/furniture-layout/evals/files/8/furniture_we_are_keeping.csv +10 -0
- package/packs/interior-designer/skills/furniture-layout/evals/files/9/reading_room_schedule.csv +15 -0
- package/packs/interior-designer/skills/furniture-layout/evals/files/9/room_notes.txt +23 -0
- package/packs/interior-designer/skills/furniture-layout/examples/king-bed-bedroom.md +121 -0
- package/packs/interior-designer/skills/furniture-layout/examples/king-bed-bedroom.svg +57 -0
- package/packs/interior-designer/skills/furniture-layout/manifest.json +44 -0
- package/packs/interior-designer/skills/furniture-layout/scripts/layout_check.py +1130 -0
- package/packs/interior-designer/skills/furniture-layout/templates/layout.json +26 -0
- package/packs/operations-manager/pack.json +60 -0
- package/packs/operations-manager/skills/shift-roster/SKILL.md +169 -0
- package/packs/operations-manager/skills/shift-roster/evals/eval_queries.json +38 -0
- package/packs/operations-manager/skills/shift-roster/evals/evals.json +135 -0
- package/packs/operations-manager/skills/shift-roster/evals/files/1/shifts.csv +3 -0
- package/packs/operations-manager/skills/shift-roster/evals/files/1/staff.csv +8 -0
- package/packs/operations-manager/skills/shift-roster/evals/files/2/rota.csv +22 -0
- package/packs/operations-manager/skills/shift-roster/evals/files/2/staff.csv +6 -0
- package/packs/operations-manager/skills/shift-roster/evals/files/3/team.md +14 -0
- package/packs/operations-manager/skills/shift-roster/evals/files/4/availability_export_wk47.csv +23 -0
- package/packs/operations-manager/skills/shift-roster/evals/files/4/rota_notes.txt +20 -0
- package/packs/operations-manager/skills/shift-roster/evals/files/5/rota_draft_16-29nov.csv +107 -0
- package/packs/operations-manager/skills/shift-roster/evals/files/5/staff_list.csv +17 -0
- package/packs/operations-manager/skills/shift-roster/evals/files/6/availability_notes.txt +24 -0
- package/packs/operations-manager/skills/shift-roster/evals/files/6/peoplehub_staff_export.csv +14 -0
- package/packs/operations-manager/skills/shift-roster/evals/files/6/rota_template.csv +25 -0
- package/packs/operations-manager/skills/shift-roster/evals/files/7/leave_and_availability.txt +18 -0
- package/packs/operations-manager/skills/shift-roster/evals/files/7/rota_wc_30nov.csv +47 -0
- package/packs/operations-manager/skills/shift-roster/evals/files/7/staff_list.csv +15 -0
- package/packs/operations-manager/skills/shift-roster/evals/files/8/availability_form_responses.csv +22 -0
- package/packs/operations-manager/skills/shift-roster/evals/files/8/cover_requirements.csv +15 -0
- package/packs/operations-manager/skills/shift-roster/evals/files/8/staff_export.csv +29 -0
- package/packs/operations-manager/skills/shift-roster/evals/files/9/leave_and_restrictions.txt +16 -0
- package/packs/operations-manager/skills/shift-roster/evals/files/9/rota_export_07-13Dec.csv +85 -0
- package/packs/operations-manager/skills/shift-roster/evals/files/9/staff_list.csv +27 -0
- package/packs/operations-manager/skills/shift-roster/examples/cafe-week.md +72 -0
- package/packs/operations-manager/skills/shift-roster/examples/cafe-week.svg +123 -0
- package/packs/operations-manager/skills/shift-roster/manifest.json +34 -0
- package/packs/operations-manager/skills/shift-roster/scripts/roster_check.py +633 -0
- 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())
|