cnice 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
cnice/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Formulaic correspondence phrases: German salutations and locale-specific valedictions."""
2
+
3
+ from . import farewell, greet
4
+
5
+ __all__ = ["greet", "farewell"]
cnice/__main__.py ADDED
@@ -0,0 +1,59 @@
1
+ """JSON command-line bridge for callers in any programming language."""
2
+ import argparse
3
+ import base64
4
+ import dataclasses
5
+ import json
6
+ import sys
7
+ from cnice import farewell, greet
8
+
9
+ FUNCTIONS = {}
10
+ for _module in (greet, farewell):
11
+ _namespace = _module.__name__.split(".")[-1]
12
+ for _name in _module.__all__:
13
+ FUNCTIONS[_name] = getattr(_module, _name)
14
+ FUNCTIONS[f"{_namespace}.{_name}"] = getattr(_module, _name)
15
+
16
+
17
+ def _json_default(value):
18
+ if dataclasses.is_dataclass(value):
19
+ return dataclasses.asdict(value)
20
+ if isinstance(value, bytes):
21
+ return base64.b64encode(value).decode("ascii")
22
+ raise TypeError(f"Cannot serialize {type(value).__name__}")
23
+
24
+
25
+ def _reject_constant(value):
26
+ raise ValueError(f"{value} is not a JSON number")
27
+
28
+
29
+ def main():
30
+ parser = argparse.ArgumentParser(description="Call cnice functions with JSON arguments.")
31
+ parser.add_argument("function", choices=sorted(FUNCTIONS))
32
+ parser.add_argument("arguments", nargs="?", default="[]", help="JSON array of positional arguments or object of keyword arguments; '-' reads UTF-8 stdin")
33
+ args = parser.parse_args()
34
+ try:
35
+ values = json.loads(sys.stdin.buffer.read().decode("utf-8") if args.arguments == "-" else args.arguments, parse_constant=_reject_constant)
36
+ if not isinstance(values, (list, dict)):
37
+ raise ValueError("arguments must be a JSON array or object")
38
+ # Image operations accept the JSON object returned by normalize.
39
+ if args.function in ("exceeds_limits", "scale_to_fit", "signature_size"):
40
+ raw = values[0] if isinstance(values, list) else values["image"]
41
+ if isinstance(raw, dict):
42
+ raw = dict(raw)
43
+ raw["bytes"] = base64.b64decode(raw["bytes"], validate=True)
44
+ from cnice import greet as _greet
45
+
46
+ image = _greet.DecodedImage(**raw)
47
+ if isinstance(values, list):
48
+ values[0] = image
49
+ else:
50
+ values["image"] = image
51
+ fn = FUNCTIONS[args.function]
52
+ result = fn(*values) if isinstance(values, list) else fn(**values)
53
+ print(json.dumps(result, default=_json_default, ensure_ascii=True, allow_nan=False))
54
+ except (ValueError, TypeError, KeyError, IndexError, AttributeError, OverflowError) as error:
55
+ parser.error(str(error))
56
+
57
+
58
+ if __name__ == "__main__":
59
+ main()
cnice/_tables.py ADDED
@@ -0,0 +1,381 @@
1
+ # Generated by scripts/sync-python.py; edit tables/salutation.json and tables/farewell/*.json instead.
2
+ TABLES = {'salutation': {'fallback': 'en',
3
+ 'locales': {'de': {'source_norms': 'DIN 5008 (comma); honorific/title protocol '
4
+ 'shared across de-*',
5
+ 'honorifics': {'frau': {'group': 'f', 'display': 'Frau'},
6
+ 'herr': {'group': 'm', 'display': 'Herr'},
7
+ 'herrn': {'group': 'm', 'display': 'Herr'}},
8
+ 'titles': {'dr': 'Dr.',
9
+ 'prof': 'Professor',
10
+ 'professor': 'Professor',
11
+ 'dipl-ing': 'Dipl.-Ing.',
12
+ 'dipling': 'Dipl.-Ing.',
13
+ 'mag': 'Mag.',
14
+ 'magister': 'Mag.',
15
+ 'dipl.-ing': 'Dipl.-Ing.',
16
+ 'dipl.ing': 'Dipl.-Ing.'},
17
+ 'sole_titles': ['Professor'],
18
+ 'filler': ['ba',
19
+ 'bsc',
20
+ 'dipl-ing',
21
+ 'dipl.-ing',
22
+ 'dipl.ing',
23
+ 'dipling',
24
+ 'dr',
25
+ 'frau',
26
+ 'herr',
27
+ 'herrn',
28
+ 'ma',
29
+ 'mag',
30
+ 'magister',
31
+ 'miss',
32
+ 'mr',
33
+ 'mrs',
34
+ 'ms',
35
+ 'msc',
36
+ 'phd',
37
+ 'prof',
38
+ 'professor'],
39
+ 'named': {'m': 'Sehr geehrter {honorific} {titles} {surname}',
40
+ 'f': 'Sehr geehrte {honorific} {titles} {surname}'},
41
+ 'formal': 'Sehr geehrte Damen und Herren',
42
+ 'comma': True},
43
+ 'de-ch': {'source_norms': 'SN 010130 (no comma); honorific/title '
44
+ 'protocol shared across de-*',
45
+ 'honorifics': {'frau': {'group': 'f', 'display': 'Frau'},
46
+ 'herr': {'group': 'm', 'display': 'Herr'},
47
+ 'herrn': {'group': 'm', 'display': 'Herr'}},
48
+ 'titles': {'dr': 'Dr.',
49
+ 'prof': 'Professor',
50
+ 'professor': 'Professor',
51
+ 'dipl-ing': 'Dipl.-Ing.',
52
+ 'dipling': 'Dipl.-Ing.',
53
+ 'mag': 'Mag.',
54
+ 'magister': 'Mag.',
55
+ 'dipl.-ing': 'Dipl.-Ing.',
56
+ 'dipl.ing': 'Dipl.-Ing.'},
57
+ 'sole_titles': ['Professor'],
58
+ 'filler': ['ba',
59
+ 'bsc',
60
+ 'dipl-ing',
61
+ 'dipl.-ing',
62
+ 'dipl.ing',
63
+ 'dipling',
64
+ 'dr',
65
+ 'frau',
66
+ 'herr',
67
+ 'herrn',
68
+ 'ma',
69
+ 'mag',
70
+ 'magister',
71
+ 'miss',
72
+ 'mr',
73
+ 'mrs',
74
+ 'ms',
75
+ 'msc',
76
+ 'phd',
77
+ 'prof',
78
+ 'professor'],
79
+ 'named': {'m': 'Sehr geehrter {honorific} {titles} {surname}',
80
+ 'f': 'Sehr geehrte {honorific} {titles} {surname}'},
81
+ 'formal': 'Sehr geehrte Damen und Herren',
82
+ 'comma': False},
83
+ 'de-at': {'source_norms': 'DIN-oriented (comma; ÖNORM A 1080 withdrawn '
84
+ '2018); protocol shared across de-*',
85
+ 'honorifics': {'frau': {'group': 'f', 'display': 'Frau'},
86
+ 'herr': {'group': 'm', 'display': 'Herr'},
87
+ 'herrn': {'group': 'm', 'display': 'Herr'}},
88
+ 'titles': {'dr': 'Dr.',
89
+ 'prof': 'Professor',
90
+ 'professor': 'Professor',
91
+ 'dipl-ing': 'Dipl.-Ing.',
92
+ 'dipling': 'Dipl.-Ing.',
93
+ 'mag': 'Mag.',
94
+ 'magister': 'Mag.',
95
+ 'dipl.-ing': 'Dipl.-Ing.',
96
+ 'dipl.ing': 'Dipl.-Ing.'},
97
+ 'sole_titles': ['Professor'],
98
+ 'filler': ['ba',
99
+ 'bsc',
100
+ 'dipl-ing',
101
+ 'dipl.-ing',
102
+ 'dipl.ing',
103
+ 'dipling',
104
+ 'dr',
105
+ 'frau',
106
+ 'herr',
107
+ 'herrn',
108
+ 'ma',
109
+ 'mag',
110
+ 'magister',
111
+ 'miss',
112
+ 'mr',
113
+ 'mrs',
114
+ 'ms',
115
+ 'msc',
116
+ 'phd',
117
+ 'prof',
118
+ 'professor'],
119
+ 'named': {'m': 'Sehr geehrter {honorific} {titles} {surname}',
120
+ 'f': 'Sehr geehrte {honorific} {titles} {surname}'},
121
+ 'formal': 'Sehr geehrte Damen und Herren',
122
+ 'comma': True},
123
+ 'de-li': {'source_norms': 'Swiss-style rendering (no comma); protocol '
124
+ 'shared across de-*',
125
+ 'honorifics': {'frau': {'group': 'f', 'display': 'Frau'},
126
+ 'herr': {'group': 'm', 'display': 'Herr'},
127
+ 'herrn': {'group': 'm', 'display': 'Herr'}},
128
+ 'titles': {'dr': 'Dr.',
129
+ 'prof': 'Professor',
130
+ 'professor': 'Professor',
131
+ 'dipl-ing': 'Dipl.-Ing.',
132
+ 'dipling': 'Dipl.-Ing.',
133
+ 'mag': 'Mag.',
134
+ 'magister': 'Mag.',
135
+ 'dipl.-ing': 'Dipl.-Ing.',
136
+ 'dipl.ing': 'Dipl.-Ing.'},
137
+ 'sole_titles': ['Professor'],
138
+ 'filler': ['ba',
139
+ 'bsc',
140
+ 'dipl-ing',
141
+ 'dipl.-ing',
142
+ 'dipl.ing',
143
+ 'dipling',
144
+ 'dr',
145
+ 'frau',
146
+ 'herr',
147
+ 'herrn',
148
+ 'ma',
149
+ 'mag',
150
+ 'magister',
151
+ 'miss',
152
+ 'mr',
153
+ 'mrs',
154
+ 'ms',
155
+ 'msc',
156
+ 'phd',
157
+ 'prof',
158
+ 'professor'],
159
+ 'named': {'m': 'Sehr geehrter {honorific} {titles} {surname}',
160
+ 'f': 'Sehr geehrte {honorific} {titles} {surname}'},
161
+ 'formal': 'Sehr geehrte Damen und Herren',
162
+ 'comma': False},
163
+ 'fr': {'source_norms': 'French administrative correspondence norms '
164
+ "(formule d'appel / formule de politesse); "
165
+ "formal register, no 'Cher' in the default "
166
+ 'template (use override for cordial variants)',
167
+ 'honorifics': {'monsieur': {'group': 'm', 'display': 'Monsieur'},
168
+ 'm': {'group': 'm', 'display': 'Monsieur'},
169
+ 'madame': {'group': 'f', 'display': 'Madame'},
170
+ 'mme': {'group': 'f', 'display': 'Madame'}},
171
+ 'titles': {'professeur': 'le Professeur',
172
+ 'prof': 'le Professeur',
173
+ 'docteur': 'le Docteur',
174
+ 'dr': 'le Docteur'},
175
+ 'sole_titles': ['le Professeur'],
176
+ 'filler': ['docteur',
177
+ 'dr',
178
+ 'm',
179
+ 'madame',
180
+ 'mme',
181
+ 'monsieur',
182
+ 'prof',
183
+ 'professeur'],
184
+ 'named': {'m': '{honorific} {titles} {surname}',
185
+ 'f': '{honorific} {titles} {surname}'},
186
+ 'formal': 'Madame, Monsieur',
187
+ 'comma': True},
188
+ 'it': {'source_norms': 'Italian business correspondence (Post CH '
189
+ 'scrivere; standard manuals). Default register '
190
+ 'is Gentile; Egregio/Spett.le variants via '
191
+ 'explicit override',
192
+ 'honorifics': {'sig': {'group': 'm', 'display': 'Sig.'},
193
+ 'signor': {'group': 'm', 'display': 'Sig.'},
194
+ 'sig.ra': {'group': 'f', 'display': 'Sig.ra'},
195
+ 'signora': {'group': 'f', 'display': 'Sig.ra'}},
196
+ 'titles': {'prof': 'Prof.',
197
+ 'professore': 'Prof.',
198
+ 'prof.ssa': 'Prof.ssa',
199
+ 'professoressa': 'Prof.ssa',
200
+ 'dott': 'Dott.',
201
+ 'dottore': 'Dott.',
202
+ 'dott.ssa': 'Dott.ssa',
203
+ 'dottoressa': 'Dott.ssa',
204
+ 'avv': 'Avv.',
205
+ 'avvocato': 'Avv.',
206
+ 'ing': 'Ing.',
207
+ 'ingegnere': 'Ing.',
208
+ 'dr': 'Dr.'},
209
+ 'sole_titles': ['Prof.', 'Prof.ssa'],
210
+ 'filler': ['avv',
211
+ 'avvocato',
212
+ 'dott',
213
+ 'dott.ssa',
214
+ 'dottore',
215
+ 'dottoressa',
216
+ 'dr',
217
+ 'ing',
218
+ 'ingegnere',
219
+ 'prof',
220
+ 'prof.ssa',
221
+ 'professore',
222
+ 'professoressa',
223
+ 'sig',
224
+ 'sig.ra',
225
+ 'signor',
226
+ 'signora'],
227
+ 'named': {'m': 'Gentile {honorific} {titles} {surname}',
228
+ 'f': 'Gentile {honorific} {titles} {surname}'},
229
+ 'formal': 'Gentili Signore, Egregi Signori',
230
+ 'comma': True},
231
+ 'rm': {'source_norms': 'Rumantsch Grischun formal address attested in '
232
+ 'Lia Rumantscha and BAKOM administrative '
233
+ 'correspondence (Stimà signur / Stimada dunna; '
234
+ 'Stimadas dunnas, stimads signurs). Closing and '
235
+ 'academic-title coverage PENDING NATIVE REVIEW',
236
+ 'review': 'native-pending',
237
+ 'honorifics': {'signur': {'group': 'm', 'display': 'signur'},
238
+ 'signura': {'group': 'f', 'display': 'signura'}},
239
+ 'titles': {},
240
+ 'sole_titles': [],
241
+ 'filler': ['signur', 'signura'],
242
+ 'named': {'m': 'Stimà {honorific} {surname}',
243
+ 'f': 'Stimada {honorific} {surname}'},
244
+ 'formal': 'Stimadas dunnas, stimads signurs',
245
+ 'comma': True},
246
+ 'en': {'source_norms': 'Standard English business correspondence',
247
+ 'honorifics': {'mr': {'group': 'm', 'display': 'Mr'},
248
+ 'mrs': {'group': 'f', 'display': 'Mrs'},
249
+ 'ms': {'group': 'f', 'display': 'Ms'},
250
+ 'miss': {'group': 'f', 'display': 'Miss'}},
251
+ 'titles': {'professor': 'Professor',
252
+ 'prof': 'Professor',
253
+ 'dr': 'Dr'},
254
+ 'sole_titles': ['Professor'],
255
+ 'filler': ['ba',
256
+ 'bsc',
257
+ 'dr',
258
+ 'esq',
259
+ 'ii',
260
+ 'iii',
261
+ 'iv',
262
+ 'jr',
263
+ 'ma',
264
+ 'miss',
265
+ 'mr',
266
+ 'mrs',
267
+ 'ms',
268
+ 'msc',
269
+ 'phd',
270
+ 'prof',
271
+ 'professor',
272
+ 'sr'],
273
+ 'named': {'m': 'Dear {honorific} {titles} {surname}',
274
+ 'f': 'Dear {honorific} {titles} {surname}'},
275
+ 'formal': 'Dear Sir or Madam',
276
+ 'comma': True},
277
+ 'en-gb': {'source_norms': 'Standard British business correspondence',
278
+ 'honorifics': {'mr': {'group': 'm', 'display': 'Mr'},
279
+ 'mrs': {'group': 'f', 'display': 'Mrs'},
280
+ 'ms': {'group': 'f', 'display': 'Ms'},
281
+ 'miss': {'group': 'f', 'display': 'Miss'}},
282
+ 'titles': {'professor': 'Professor',
283
+ 'prof': 'Professor',
284
+ 'dr': 'Dr'},
285
+ 'sole_titles': ['Professor'],
286
+ 'filler': ['ba',
287
+ 'bsc',
288
+ 'dr',
289
+ 'esq',
290
+ 'ii',
291
+ 'iii',
292
+ 'iv',
293
+ 'jr',
294
+ 'ma',
295
+ 'miss',
296
+ 'mr',
297
+ 'mrs',
298
+ 'ms',
299
+ 'msc',
300
+ 'phd',
301
+ 'prof',
302
+ 'professor',
303
+ 'sr'],
304
+ 'named': {'m': 'Dear {honorific} {titles} {surname}',
305
+ 'f': 'Dear {honorific} {titles} {surname}'},
306
+ 'formal': 'Dear Sir or Madam',
307
+ 'comma': True},
308
+ 'en-us': {'source_norms': 'Standard US cover-letter convention for '
309
+ 'unknown recipients',
310
+ 'honorifics': {'mr': {'group': 'm', 'display': 'Mr'},
311
+ 'mrs': {'group': 'f', 'display': 'Mrs'},
312
+ 'ms': {'group': 'f', 'display': 'Ms'},
313
+ 'miss': {'group': 'f', 'display': 'Miss'}},
314
+ 'titles': {'professor': 'Professor',
315
+ 'prof': 'Professor',
316
+ 'dr': 'Dr'},
317
+ 'sole_titles': ['Professor'],
318
+ 'filler': ['ba',
319
+ 'bsc',
320
+ 'dr',
321
+ 'esq',
322
+ 'ii',
323
+ 'iii',
324
+ 'iv',
325
+ 'jr',
326
+ 'ma',
327
+ 'miss',
328
+ 'mr',
329
+ 'mrs',
330
+ 'ms',
331
+ 'msc',
332
+ 'phd',
333
+ 'prof',
334
+ 'professor',
335
+ 'sr'],
336
+ 'named': {'m': 'Dear {honorific} {titles} {surname}',
337
+ 'f': 'Dear {honorific} {titles} {surname}'},
338
+ 'formal': 'Dear Hiring Manager',
339
+ 'comma': True}}},
340
+ 'closing': {'locales': {'de': 'Mit freundlichen Grüßen',
341
+ 'de-ch': 'Freundliche Grüsse',
342
+ 'de-li': 'Freundliche Grüsse',
343
+ 'de-at': 'Mit freundlichen Grüßen',
344
+ 'en': 'Yours sincerely,',
345
+ 'en-gb': 'Yours sincerely,',
346
+ 'en-us': 'Sincerely,',
347
+ 'fr': 'Veuillez agréer, Madame, Monsieur, mes salutations distinguées.',
348
+ 'fr-ch': 'Meilleures salutations,',
349
+ 'it': 'Cordiali saluti,',
350
+ 'it-ch': 'Cordiali saluti,',
351
+ 'es': 'Atentamente,',
352
+ 'es-mx': 'Atentamente,',
353
+ 'pt': 'Atenciosamente,',
354
+ 'pt-br': 'Atenciosamente,',
355
+ 'nl': 'Met vriendelijke groet,',
356
+ 'nl-be': 'Met vriendelijke groeten,',
357
+ 'ar': 'مع أطيب التحيات،',
358
+ 'bg': 'С уважение,',
359
+ 'cs': 'S pozdravem,',
360
+ 'da': 'Med venlig hilsen,',
361
+ 'el': 'Με εκτίμηση,',
362
+ 'et': 'Lugupidamisega,',
363
+ 'fi': 'Ystävällisin terveisin,',
364
+ 'hu': 'Tisztelettel,',
365
+ 'id': 'Hormat saya,',
366
+ 'ja': '敬具',
367
+ 'ko': '감사합니다.',
368
+ 'lt': 'Pagarbiai,',
369
+ 'lv': 'Ar cieņu,',
370
+ 'nb': 'Med vennlig hilsen,',
371
+ 'pl': 'Z poważaniem,',
372
+ 'ro': 'Cu stimă,',
373
+ 'ru': 'С уважением,',
374
+ 'sk': 'S pozdravom,',
375
+ 'sl': 'S spoštovanjem,',
376
+ 'sv': 'Med vänliga hälsningar,',
377
+ 'tr': 'Saygılarımla,',
378
+ 'uk': 'З повагою,',
379
+ 'zh': '此致敬礼',
380
+ 'rm': 'Cordials salids'},
381
+ 'fallback': 'en'}}
cnice/farewell.py ADDED
@@ -0,0 +1,21 @@
1
+ """Locale-correct valedictions with explicit override support."""
2
+
3
+ from ._tables import TABLES
4
+
5
+ _TABLE = TABLES["closing"]
6
+ _KEYS = {key.lower(): key for key in _TABLE["locales"]}
7
+ __all__ = ["closing", "available_locales"]
8
+
9
+
10
+ def closing(locale: str, override_closing: str | None = None) -> str:
11
+ """Resolve exact locale, base language, then English; an override wins."""
12
+ if override_closing is not None:
13
+ return override_closing
14
+ lowered = locale.lower()
15
+ key = _KEYS.get(lowered, _KEYS.get(lowered.split("-", 1)[0], _TABLE["fallback"]))
16
+ return _TABLE["locales"][key]
17
+
18
+
19
+ def available_locales() -> list[str]:
20
+ """Sorted canonical BCP 47 codes in the valediction table."""
21
+ return sorted(_TABLE["locales"])
cnice/greet.py ADDED
@@ -0,0 +1,124 @@
1
+ """Locale-correct salutations through the uniform matcher.
2
+
3
+ One matcher for every language: each locale row in tables/salutation.json
4
+ carries its honorifics, academic titles, name templates, comma rule and
5
+ formal fallback; the code below only resolves the locale, matches
6
+ normalized tokens and fills templates.
7
+ """
8
+
9
+ import re
10
+
11
+ from ._tables import TABLES
12
+
13
+ _SALUTATION = TABLES["salutation"]
14
+ _LOCALES = _SALUTATION["locales"]
15
+ _FALLBACK = _SALUTATION["fallback"]
16
+ _WS = re.compile(r"[\u0009-\u000d\u0020\u0085\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000]+")
17
+ _ASCII_LOWER = str.maketrans("ABCDEFGHIJKLMNOPQRSTUVWXYZ", "abcdefghijklmnopqrstuvwxyz")
18
+
19
+ __all__ = ["salutation", "salutation_honorific", "salutation_titles",
20
+ "salutation_surname", "salutation_last_name",
21
+ "recipient_salutation_warning", "honorific_warning", "is_supported"]
22
+
23
+
24
+ def _tokens(name: str) -> list[str]:
25
+ return [token for token in _WS.split(name) if token]
26
+
27
+
28
+ def _norm(token: str) -> str:
29
+ return token.strip(".").translate(_ASCII_LOWER)
30
+
31
+
32
+ def _ascii_lower(value: str) -> str:
33
+ return value.translate(_ASCII_LOWER)
34
+
35
+
36
+ def _base_language(code: str) -> str:
37
+ return code.split("-", 1)[0]
38
+
39
+
40
+ def _resolve_key(locale: str) -> str:
41
+ lower = _ascii_lower(locale)
42
+ if lower in _LOCALES:
43
+ return lower
44
+ base = _base_language(lower)
45
+ if base in _LOCALES:
46
+ return base
47
+ return _FALLBACK
48
+
49
+
50
+ def is_supported(locale: str) -> bool:
51
+ """Whether a locale has a salutation row directly or via its base language."""
52
+ return locale in _LOCALES or _base_language(_ascii_lower(locale)) in _LOCALES
53
+
54
+
55
+ def salutation_last_name(name: str) -> str:
56
+ """Last whitespace-separated token, or an empty string."""
57
+ tokens = _tokens(name)
58
+ return tokens[-1] if tokens else ""
59
+
60
+
61
+ def salutation_honorific(locale: str, name: str) -> str:
62
+ """Canonical display honorific for the locale, or "" when unparsable."""
63
+ entry = _LOCALES[_resolve_key(locale)]
64
+ tokens = _tokens(name)
65
+ first = tokens[0] if tokens else ""
66
+ honorific = entry["honorifics"].get(_norm(first))
67
+ return honorific["display"] if honorific is not None else ""
68
+
69
+
70
+ def salutation_titles(locale: str, name: str) -> list[str]:
71
+ """Recognized academic titles as display forms, with sole-title suppression."""
72
+ entry = _LOCALES[_resolve_key(locale)]
73
+ kept: list[str] = []
74
+ for token in _tokens(name):
75
+ title = entry["titles"].get(_norm(token))
76
+ if title is not None and title not in kept:
77
+ kept.append(title)
78
+ for title in kept:
79
+ if title in entry["sole_titles"]:
80
+ return [title]
81
+ return kept
82
+
83
+
84
+ def salutation_surname(locale: str, name: str) -> str:
85
+ """Last significant token after dropping filler; raw form preserved."""
86
+ entry = _LOCALES[_resolve_key(locale)]
87
+ filler = set(entry["filler"])
88
+ for token in reversed(_tokens(name)):
89
+ if _norm(token) not in filler:
90
+ return token
91
+ return ""
92
+
93
+
94
+ def salutation(locale: str, name: str) -> str:
95
+ """Locale-correct salutation, falling back to the formal template when incomplete."""
96
+ entry = _LOCALES[_resolve_key(locale)]
97
+ punct = "," if entry["comma"] else ""
98
+ tokens = _tokens(name)
99
+ first = tokens[0] if tokens else ""
100
+ honorific = entry["honorifics"].get(_norm(first))
101
+ surname = salutation_surname(locale, name)
102
+ if honorific is None or not surname:
103
+ return entry["formal"] + punct
104
+ titles = " ".join(salutation_titles(locale, name))
105
+ template = entry["named"].get(honorific["group"], entry["formal"])
106
+ rendered = template.replace("{honorific}", honorific["display"]).replace("{titles}", titles).replace("{surname}", surname)
107
+ collapsed = " ".join(_tokens(rendered))
108
+ return collapsed + punct
109
+
110
+
111
+ def recipient_salutation_warning(location: str, name: str) -> str | None:
112
+ """Advisory for an empty recipient name."""
113
+ if salutation_last_name(name) == "":
114
+ return f"{location}: job.cl_recipient.name is empty; using formal salutation (provide a name for tailored opportunities)"
115
+ return None
116
+
117
+
118
+ def honorific_warning(location: str, locale: str, name: str) -> str | None:
119
+ """Advisory for a nonempty name without a parsable honorific for the locale."""
120
+ if salutation_last_name(name) == "":
121
+ return None
122
+ if not salutation_honorific(locale, name) or not salutation_surname(locale, name):
123
+ return f"{location}: job.cl_recipient.name has no parsable honorific for {locale}; using formal salutation (provide an explicit honorific for tailored opportunities)"
124
+ return None
cnice/py.typed ADDED
@@ -0,0 +1 @@
1
+