ruyi 0.44.0a20251118__py3-none-any.whl → 0.45.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.
Files changed (55) hide show
  1. ruyi/__main__.py +16 -4
  2. ruyi/cli/cmd.py +6 -5
  3. ruyi/cli/config_cli.py +14 -11
  4. ruyi/cli/main.py +34 -17
  5. ruyi/cli/oobe.py +10 -10
  6. ruyi/cli/self_cli.py +49 -36
  7. ruyi/cli/user_input.py +42 -12
  8. ruyi/cli/version_cli.py +11 -5
  9. ruyi/config/__init__.py +30 -10
  10. ruyi/config/errors.py +19 -7
  11. ruyi/device/provision.py +116 -55
  12. ruyi/device/provision_cli.py +6 -3
  13. ruyi/i18n/__init__.py +129 -0
  14. ruyi/log/__init__.py +6 -5
  15. ruyi/mux/runtime.py +19 -6
  16. ruyi/mux/venv/maker.py +93 -35
  17. ruyi/mux/venv/venv_cli.py +13 -10
  18. ruyi/pluginhost/plugin_cli.py +4 -3
  19. ruyi/resource_bundle/__init__.py +22 -8
  20. ruyi/resource_bundle/__main__.py +6 -5
  21. ruyi/resource_bundle/data.py +13 -9
  22. ruyi/ruyipkg/admin_checksum.py +4 -1
  23. ruyi/ruyipkg/admin_cli.py +9 -6
  24. ruyi/ruyipkg/augmented_pkg.py +15 -14
  25. ruyi/ruyipkg/checksum.py +8 -2
  26. ruyi/ruyipkg/distfile.py +33 -9
  27. ruyi/ruyipkg/entity.py +12 -2
  28. ruyi/ruyipkg/entity_cli.py +20 -12
  29. ruyi/ruyipkg/entity_provider.py +11 -2
  30. ruyi/ruyipkg/fetcher.py +38 -9
  31. ruyi/ruyipkg/install.py +163 -64
  32. ruyi/ruyipkg/install_cli.py +18 -15
  33. ruyi/ruyipkg/list.py +27 -20
  34. ruyi/ruyipkg/list_cli.py +12 -7
  35. ruyi/ruyipkg/news.py +23 -11
  36. ruyi/ruyipkg/news_cli.py +10 -7
  37. ruyi/ruyipkg/profile_cli.py +8 -2
  38. ruyi/ruyipkg/repo.py +22 -8
  39. ruyi/ruyipkg/unpack.py +42 -8
  40. ruyi/ruyipkg/unpack_method.py +5 -1
  41. ruyi/ruyipkg/update_cli.py +8 -3
  42. ruyi/telemetry/aggregate.py +5 -0
  43. ruyi/telemetry/provider.py +292 -105
  44. ruyi/telemetry/store.py +68 -15
  45. ruyi/telemetry/telemetry_cli.py +32 -13
  46. ruyi/utils/git.py +18 -11
  47. ruyi/utils/prereqs.py +10 -5
  48. ruyi/utils/ssl_patch.py +2 -1
  49. ruyi/version.py +9 -3
  50. {ruyi-0.44.0a20251118.dist-info → ruyi-0.45.0.dist-info}/METADATA +4 -2
  51. ruyi-0.45.0.dist-info/RECORD +103 -0
  52. {ruyi-0.44.0a20251118.dist-info → ruyi-0.45.0.dist-info}/WHEEL +1 -1
  53. ruyi-0.44.0a20251118.dist-info/RECORD +0 -102
  54. {ruyi-0.44.0a20251118.dist-info → ruyi-0.45.0.dist-info}/entry_points.txt +0 -0
  55. {ruyi-0.44.0a20251118.dist-info → ruyi-0.45.0.dist-info}/licenses/LICENSE-Apache.txt +0 -0
ruyi/device/provision.py CHANGED
@@ -4,6 +4,7 @@ from typing import TYPE_CHECKING, TypedDict, TypeGuard, cast
4
4
 
5
5
  from ..cli import user_input
6
6
  from ..config import GlobalConfig
7
+ from ..i18n import _
7
8
  from ..log import RuyiLogger
8
9
  from ..ruyipkg.atom import Atom, ExprAtom, SlugAtom
9
10
  from ..ruyipkg.entity_provider import BaseEntity
@@ -36,7 +37,8 @@ def do_provision_interactive(config: GlobalConfig) -> int:
36
37
  mr.ensure_git_repo()
37
38
 
38
39
  log.stdout(
39
- """
40
+ _(
41
+ """
40
42
  [bold green]RuyiSDK Device Provisioning Wizard[/]
41
43
 
42
44
  This is a wizard intended to help you install a system on your device for your
@@ -52,11 +54,12 @@ required to flash images, you should arrange to allow your user account [yellow]
52
54
  access to necessary commands such as [yellow]dd[/]. Flashing will fail if the [yellow]sudo[/]
53
55
  configuration does not allow so.
54
56
  """
57
+ )
55
58
  )
56
59
 
57
- if not user_input.ask_for_yesno_confirmation(log, "Continue?"):
60
+ if not user_input.ask_for_yesno_confirmation(log, _("Continue?")):
58
61
  log.stdout(
59
- "\nExiting. You can restart the wizard whenever prepared.",
62
+ _("\nExiting. You can restart the wizard whenever prepared."),
60
63
  end="\n\n",
61
64
  )
62
65
  return 1
@@ -68,7 +71,9 @@ configuration does not allow so.
68
71
  dev_choices = {k: v.display_name or "" for k, v in devices_by_id.items()}
69
72
  dev_id = user_input.ask_for_kv_choice(
70
73
  log,
71
- "\nThe following devices are currently supported by the wizard. Please pick your device:",
74
+ _(
75
+ "\nThe following devices are currently supported by the wizard. Please pick your device:"
76
+ ),
72
77
  dev_choices,
73
78
  )
74
79
  dev = devices_by_id[dev_id]
@@ -84,7 +89,9 @@ configuration does not allow so.
84
89
  variant_choices = [get_variant_display_name(dev, i) for i in variants]
85
90
  variant_idx = user_input.ask_for_choice(
86
91
  log,
87
- "\nThe device has the following variants. Please choose the one corresponding to your hardware at hand:",
92
+ _(
93
+ "\nThe device has the following variants. Please choose the one corresponding to your hardware at hand:"
94
+ ),
88
95
  variant_choices,
89
96
  )
90
97
  variant = variants[variant_idx]
@@ -101,7 +108,9 @@ configuration does not allow so.
101
108
  combo_choices = [combo.display_name or "" for combo in supported_combos]
102
109
  combo_idx = user_input.ask_for_choice(
103
110
  log,
104
- "\nThe following system configurations are supported by the device variant you have chosen. Please pick the one you want to put on the device:",
111
+ _(
112
+ "\nThe following system configurations are supported by the device variant you have chosen. Please pick the one you want to put on the device:"
113
+ ),
105
114
  combo_choices,
106
115
  )
107
116
  combo = supported_combos[combo_idx]
@@ -132,7 +141,8 @@ def do_provision_combo_interactive(
132
141
  combo: BaseEntity,
133
142
  ) -> int:
134
143
  logger = config.logger
135
- logger.D(f"provisioning device variant '{dev_decl.id}@{variant_decl.id}'")
144
+ devid = f"{dev_decl.id}@{variant_decl.id}"
145
+ logger.D(f"provisioning device variant '{devid}'")
136
146
 
137
147
  # download packages
138
148
  pkg_atoms = combo.data.get("package_atoms", [])
@@ -141,28 +151,36 @@ def do_provision_combo_interactive(
141
151
  return 0
142
152
 
143
153
  logger.F(
144
- f"malformed config: device variant '{dev_decl.id}@{variant_decl.id}' asks for no packages but provides no messages either"
154
+ _(
155
+ "malformed config: device variant '{devid}' asks for no packages but provides no messages either"
156
+ ).format(
157
+ devid=devid,
158
+ )
145
159
  )
146
160
  return 1
147
161
 
148
162
  new_pkg_atoms = customize_package_versions(config, mr, pkg_atoms)
149
163
  if new_pkg_atoms is None:
150
- logger.stdout("\nExiting. You may restart the wizard at any time.", end="\n\n")
164
+ logger.stdout(
165
+ _("\nExiting. You may restart the wizard at any time."), end="\n\n"
166
+ )
151
167
  return 1
152
168
  else:
153
169
  pkg_atoms = new_pkg_atoms
154
170
 
155
171
  pkg_names_for_display = "\n".join(f" * [green]{i}[/]" for i in pkg_atoms)
156
172
  logger.stdout(
157
- f"""
158
- We are about to download and install the following packages for your device:
159
-
160
- {pkg_names_for_display}
161
- """
173
+ _(
174
+ "\nWe are about to download and install the following packages for your device:"
175
+ ),
176
+ end="\n\n",
162
177
  )
178
+ logger.stdout(pkg_names_for_display, end="\n\n")
163
179
 
164
- if not user_input.ask_for_yesno_confirmation(logger, "Proceed?"):
165
- logger.stdout("\nExiting. You may restart the wizard at any time.", end="\n\n")
180
+ if not user_input.ask_for_yesno_confirmation(logger, _("Proceed?")):
181
+ logger.stdout(
182
+ _("\nExiting. You may restart the wizard at any time."), end="\n\n"
183
+ )
166
184
  return 1
167
185
 
168
186
  ret = do_install_atoms(
@@ -174,8 +192,8 @@ We are about to download and install the following packages for your device:
174
192
  reinstall=False,
175
193
  )
176
194
  if ret != 0:
177
- logger.F("failed to download and install packages")
178
- logger.I("your device was not touched")
195
+ logger.F(_("failed to download and install packages"))
196
+ logger.I(_("your device was not touched"))
179
197
  return 2
180
198
 
181
199
  strat_provider = ProvisionStrategyProvider(mr)
@@ -198,7 +216,8 @@ We are about to download and install the following packages for your device:
198
216
  host_blkdev_map: PartitionMapDecl = {}
199
217
  if requested_host_blkdevs:
200
218
  logger.stdout(
201
- """
219
+ _(
220
+ """
202
221
  For initializing this target device, you should plug into this host system the
203
222
  device's storage (e.g. SD card or NVMe SSD), or a removable disk to be
204
223
  reformatted as a live medium, and note down the corresponding device file
@@ -206,6 +225,7 @@ path(s), e.g. /dev/sdX, /dev/nvmeXnY for whole disks; /dev/sdXY, /dev/nvmeXnYpZ
206
225
  for partitions. You may consult e.g. [yellow]sudo blkid[/] output for the
207
226
  information you will need later.
208
227
  """
228
+ )
209
229
  )
210
230
  for part in requested_host_blkdevs:
211
231
  part_desc = get_part_desc(part)
@@ -213,7 +233,9 @@ information you will need later.
213
233
  while True:
214
234
  path = user_input.ask_for_file(
215
235
  logger,
216
- f"Please give the path for the {part_desc}:",
236
+ _("Please give the path for the {part_desc}:").format(
237
+ part_desc=part_desc,
238
+ ),
217
239
  )
218
240
 
219
241
  # Retrieve the latest mount info in case the user un-mounts
@@ -224,10 +246,17 @@ information you will need later.
224
246
  for m in blkdev_mounts:
225
247
  if m.source_path.samefile(path):
226
248
  logger.W(
227
- f"path [cyan]'{path}'[/] is currently mounted at [yellow]'{m.target}'[/]"
249
+ _(
250
+ "path [cyan]'{path}'[/] is currently mounted at [yellow]'{target}'[/]"
251
+ ).format(
252
+ path=path,
253
+ target=m.target,
254
+ )
228
255
  )
229
256
  logger.I(
230
- "rejecting the path for safety; please double-check and retry"
257
+ _(
258
+ "rejecting the path for safety; please double-check and retry"
259
+ )
231
260
  )
232
261
  path_valid = False
233
262
  break
@@ -238,12 +267,14 @@ information you will need later.
238
267
 
239
268
  # final confirmation
240
269
  logger.stdout(
241
- """
270
+ _(
271
+ """
242
272
  We have collected enough information for the actual flashing. Now is the last
243
273
  chance to re-check and confirm everything is fine.
244
274
 
245
275
  We are about to:
246
276
  """
277
+ )
247
278
  )
248
279
 
249
280
  pretend_steps = "\n".join(
@@ -257,9 +288,11 @@ We are about to:
257
288
  )
258
289
  logger.stdout(pretend_steps, end="\n\n")
259
290
 
260
- if not user_input.ask_for_yesno_confirmation(logger, "Proceed with flashing?"):
291
+ if not user_input.ask_for_yesno_confirmation(logger, _("Proceed with flashing?")):
261
292
  logger.stdout(
262
- "\nExiting. The device is not touched and you may re-start the wizard at will.",
293
+ _(
294
+ "\nExiting. The device is not touched and you may re-start the wizard at will."
295
+ ),
263
296
  end="\n\n",
264
297
  )
265
298
  return 1
@@ -273,18 +306,20 @@ We are about to:
273
306
  # ask the user to ensure the device shows up
274
307
  # TODO: automate doing so
275
308
  logger.stdout(
276
- """
309
+ _("""
277
310
  Some flashing steps require the use of fastboot, in which case you should
278
311
  ensure the target device is showing up in [yellow]fastboot devices[/] output.
279
312
  Please [bold red]confirm it yourself before continuing[/].
280
- """
313
+ """)
281
314
  )
282
315
  if not user_input.ask_for_yesno_confirmation(
283
316
  logger,
284
- "Is the device identified by fastboot now?",
317
+ _("Is the device identified by fastboot now?"),
285
318
  ):
286
319
  logger.stdout(
287
- "\nAborting. The device is not touched. You may re-start the wizard after [yellow]fastboot[/] is fixed for the device.",
320
+ _(
321
+ "\nAborting. The device is not touched. You may re-start the wizard after [yellow]fastboot[/] is fixed for the device."
322
+ ),
288
323
  end="\n\n",
289
324
  )
290
325
  return 1
@@ -294,16 +329,16 @@ Please [bold red]confirm it yourself before continuing[/].
294
329
  logger.D(f"flashing {pkg} with strategy {strat}")
295
330
  ret = strat.flash(pkg_part_maps[pkg], host_blkdev_map)
296
331
  if ret != 0:
297
- logger.F("flashing failed, check your device right now")
332
+ logger.F(_("flashing failed, check your device right now"))
298
333
  return ret
299
334
 
300
335
  # parting words
301
336
  logger.stdout(
302
- """
337
+ _("""
303
338
  It seems the flashing has finished without errors.
304
339
 
305
340
  [bold green]Happy hacking![/]
306
- """
341
+ """)
307
342
  )
308
343
 
309
344
  maybe_render_postinst_msg(logger, mr, combo, config.lang_code)
@@ -314,11 +349,11 @@ It seems the flashing has finished without errors.
314
349
  def get_part_desc(part: PartitionKind) -> str:
315
350
  match part:
316
351
  case "disk":
317
- return "target's whole disk"
352
+ return _("target's whole disk")
318
353
  case "live":
319
- return "removable disk to use as live medium"
354
+ return _("removable disk to use as live medium")
320
355
  case _:
321
- return f"target's '{part}' partition"
356
+ return _("target's '{part}' partition").format(part=part)
322
357
 
323
358
 
324
359
  class PackageProvisionStrategyDecl(TypedDict):
@@ -489,17 +524,19 @@ def customize_package_versions(
489
524
 
490
525
  # Ask if the user wants to customize package versions
491
526
  logger.stdout(
492
- "By default, we'll install the latest version of each package, but in this case, other choices are possible."
527
+ _(
528
+ "By default, we'll install the latest version of each package, but in this case, other choices are possible."
529
+ )
493
530
  )
494
531
  if not user_input.ask_for_yesno_confirmation(
495
532
  logger,
496
- "Would you like to customize package versions?",
533
+ _("Would you like to customize package versions?"),
497
534
  ):
498
535
  return pkg_atoms
499
536
 
500
537
  while True: # Loop to allow restarting the selection process
501
538
  result: list[str] = []
502
- logger.stdout("\n[bold]Package Version Selection[/]")
539
+ logger.stdout(_("\n[bold]Package Version Selection[/]"))
503
540
 
504
541
  for atom_str in pkg_atoms:
505
542
  # Parse the atom to get package name
@@ -507,18 +544,26 @@ def customize_package_versions(
507
544
  if isinstance(a, ExprAtom):
508
545
  # If it's already an expression with version constraints, show the constraints
509
546
  logger.stdout(
510
- f"\nPackage [green]{atom_str}[/] already has version constraints."
547
+ _(
548
+ "\nPackage [green]{atom}[/] already has version constraints."
549
+ ).format(
550
+ atom=atom_str,
551
+ )
511
552
  )
512
553
  if not user_input.ask_for_yesno_confirmation(
513
554
  logger,
514
- "Would you like to change them?",
555
+ _("Would you like to change them?"),
515
556
  ):
516
557
  result.append(atom_str)
517
558
  continue
518
559
  elif isinstance(a, SlugAtom):
519
560
  # Slugs already fix the version, so we can't change them
520
561
  logger.W(
521
- f"version cannot be overridden for slug atom [green]{atom_str}[/]"
562
+ _(
563
+ "version cannot be overridden for slug atom [green]{atom}[/]"
564
+ ).format(
565
+ atom=atom_str,
566
+ )
522
567
  )
523
568
  result.append(atom_str)
524
569
  continue
@@ -526,19 +571,24 @@ def customize_package_versions(
526
571
  # Get all available versions for this package
527
572
  package_name = a.name
528
573
  category = a.category
574
+ pkg_fullname = f"{category}/{package_name}" if category else package_name
529
575
 
530
576
  available_versions: "list[BoundPackageManifest]" = []
531
577
  try:
532
578
  available_versions = list(mr.iter_pkg_vers(package_name, category))
533
579
  except KeyError:
534
580
  logger.W(
535
- f"could not find package [yellow]{category}/{package_name}[/] in repository"
581
+ _("could not find package [yellow]{pkg}[/] in repository").format(
582
+ pkg=pkg_fullname
583
+ )
536
584
  )
537
585
  result.append(atom_str)
538
586
 
539
587
  if not available_versions:
540
588
  logger.W(
541
- f"no versions found for package [yellow]{category}/{package_name}[/]"
589
+ _("no versions found for package [yellow]{pkg}[/]").format(
590
+ pkg=pkg_fullname
591
+ )
542
592
  )
543
593
  result.append(atom_str)
544
594
  continue
@@ -547,7 +597,12 @@ def customize_package_versions(
547
597
  # If there's only one version available, use it
548
598
  selected_version = available_versions[0]
549
599
  logger.stdout(
550
- f"Only one version available for [green]{category}/{package_name}[/]: [blue]{selected_version.ver}[/], using it."
600
+ _(
601
+ "Only one version available for [green]{pkg}[/]: [blue]{ver}[/], using it."
602
+ ).format(
603
+ pkg=pkg_fullname,
604
+ ver=selected_version.ver,
605
+ )
551
606
  )
552
607
  result.append(atom_str)
553
608
  continue
@@ -562,11 +617,15 @@ def customize_package_versions(
562
617
  remarks = []
563
618
 
564
619
  if pm.is_prerelease:
565
- remarks.append("prerelease")
620
+ remarks.append(_("prerelease"))
566
621
  if pm.service_level.has_known_issues:
567
- remarks.append("has known issues")
622
+ remarks.append(_("has known issues"))
568
623
  if pm.upstream_version:
569
- remarks.append(f"upstream: {pm.upstream_version}")
624
+ remarks.append(
625
+ _("upstream: {upstream_ver}").format(
626
+ upstream_ver=pm.upstream_version
627
+ )
628
+ )
570
629
 
571
630
  remark_str = f" ({', '.join(remarks)})" if remarks else ""
572
631
  version_choices.append(f"{version_str}{remark_str}")
@@ -574,7 +633,9 @@ def customize_package_versions(
574
633
  # Ask the user to select a version
575
634
  version_idx = user_input.ask_for_choice(
576
635
  logger,
577
- f"\nSelect a version for package [green]{category or ''}{('/' + package_name) if category else package_name}[/]:",
636
+ _("\nSelect a version for package [green]{pkg}[/]:").format(
637
+ pkg=pkg_fullname,
638
+ ),
578
639
  version_choices,
579
640
  )
580
641
 
@@ -586,27 +647,27 @@ def customize_package_versions(
586
647
  else:
587
648
  new_atom = f"{package_name}(=={selected_version.ver})"
588
649
 
589
- logger.stdout(f"Selected: [blue]{new_atom}[/]")
650
+ logger.stdout(_("Selected: [blue]{new_atom}[/]").format(new_atom=new_atom))
590
651
  result.append(new_atom)
591
652
 
592
- logger.stdout("\nPackage versions to be installed:")
653
+ logger.stdout(_("\nPackage versions to be installed:"))
593
654
  for atom in result:
594
655
  logger.stdout(f" * [green]{atom}[/]")
595
656
 
596
657
  confirmation = user_input.ask_for_choice(
597
658
  logger,
598
- "\nHow would you like to proceed?",
659
+ _("\nHow would you like to proceed?"),
599
660
  [
600
- "Continue with these versions",
601
- "Restart version selection",
602
- "Abort device provisioning",
661
+ _("Continue with these versions"),
662
+ _("Restart version selection"),
663
+ _("Abort device provisioning"),
603
664
  ],
604
665
  )
605
666
 
606
667
  if confirmation == 0: # Continue with these versions
607
668
  return result
608
669
  elif confirmation == 1: # Restart version selection
609
- logger.stdout("\nRestarting package version selection...")
670
+ logger.stdout(_("\nRestarting package version selection..."))
610
671
  continue
611
672
  else: # Abort installation
612
673
  return None
@@ -2,6 +2,7 @@ import argparse
2
2
  from typing import TYPE_CHECKING
3
3
 
4
4
  from ..cli.cmd import RootCommand
5
+ from ..i18n import _
5
6
 
6
7
  if TYPE_CHECKING:
7
8
  from ..cli.completion import ArgumentParser
@@ -12,7 +13,7 @@ class DeviceCommand(
12
13
  RootCommand,
13
14
  cmd="device",
14
15
  has_subcommands=True,
15
- help="Manage devices",
16
+ help=_("Manage devices"),
16
17
  ):
17
18
  @classmethod
18
19
  def configure_args(cls, gc: "GlobalConfig", p: "ArgumentParser") -> None:
@@ -23,7 +24,7 @@ class DeviceProvisionCommand(
23
24
  DeviceCommand,
24
25
  cmd="provision",
25
26
  aliases=["flash"],
26
- help="Interactively initialize a device for development",
27
+ help=_("Interactively initialize a device for development"),
27
28
  ):
28
29
  @classmethod
29
30
  def configure_args(cls, gc: "GlobalConfig", p: "ArgumentParser") -> None:
@@ -36,5 +37,7 @@ class DeviceProvisionCommand(
36
37
  try:
37
38
  return do_provision_interactive(cfg)
38
39
  except KeyboardInterrupt:
39
- cfg.logger.stdout("\n\nKeyboard interrupt received, exiting.", end="\n\n")
40
+ cfg.logger.stdout(
41
+ _("\n\nKeyboard interrupt received, exiting."), end="\n\n"
42
+ )
40
43
  return 1
ruyi/i18n/__init__.py ADDED
@@ -0,0 +1,129 @@
1
+ from io import BytesIO
2
+ import gettext
3
+ import os
4
+ import sys
5
+ from typing import Final, Mapping, NewType
6
+
7
+ if sys.version_info >= (3, 11):
8
+ from typing import LiteralString
9
+ else:
10
+ # It may happen that Python and typing_extensions are both too old, which
11
+ # is unfortunately the case with Ubuntu 22.04 LTS system packages, meaning
12
+ # typing_extensions cannot guarantee us LiteralString either.
13
+ #
14
+ # We don't expect development work within such an environment, so just
15
+ # alias to str to avoid importing typing_extensions altogether. This also
16
+ # helps CLI startup performance.
17
+ #
18
+ # Unfortunately, simply assigning str to LiteralString would not work either,
19
+ # due to mypy/pyright not wanting us to re-assign types; we have to
20
+ # resort to providing different function signatures for Python 3.10, which
21
+ # is done below.
22
+ #
23
+ # LiteralString = str # type: ignore[misc]
24
+ pass
25
+
26
+ from ..resource_bundle import get_resource_blob
27
+
28
+
29
+ def _probe_lang(environ: Mapping[str, str]) -> list[str]:
30
+ """Probe the environment variables the gettext way, to determine the list
31
+ of preferred languages."""
32
+ languages: list[str] = []
33
+ # check the variables in this order
34
+ for envar in ("LANGUAGE", "LC_ALL", "LC_MESSAGES", "LANG"):
35
+ if val := environ.get(envar):
36
+ languages = val.split(":")
37
+ break
38
+ if "C" not in languages:
39
+ languages.append("C")
40
+
41
+ for i, lang in enumerate(languages):
42
+ # normalize things like en_US.UTF-8 to en_US
43
+ if "." in lang:
44
+ languages[i] = lang.split(".", 1)[0]
45
+
46
+ return languages
47
+
48
+
49
+ _DOMAINS = (
50
+ "argparse",
51
+ "ruyi",
52
+ )
53
+ """gettext domains we supply and use ourselves"""
54
+
55
+
56
+ class I18nAdapter:
57
+ """Adapter for gettext translation functions."""
58
+
59
+ def __init__(self) -> None:
60
+ self._t = gettext.NullTranslations()
61
+
62
+ def hook(self) -> None:
63
+ # monkey-patch the global gettext functions
64
+ # the type ignore comments are necessary because mypy doesn't see
65
+ # the bounded methods as compatible with the unbound functions
66
+ # (it doesn't remove self from the unbound method signature)
67
+ gettext.gettext = self.gettext # type: ignore[assignment]
68
+ gettext.ngettext = self.ngettext # type: ignore[assignment]
69
+
70
+ def init_from_env(self, environ: Mapping[str, str] | None = None) -> None:
71
+ if environ is None:
72
+ environ = os.environ
73
+
74
+ langs = _probe_lang(environ)
75
+ for domain in _DOMAINS:
76
+ for lang in langs:
77
+ if self.set_locale(domain, lang):
78
+ break
79
+
80
+ def _get_mo(self, domain: str, locale: str) -> BytesIO | None:
81
+ # this is always forward-slash-separated, because this is not a concrete
82
+ # filesystem path, rather a resource bundle key
83
+ path = f"locale/{locale}/LC_MESSAGES/{domain}.mo"
84
+ blob = get_resource_blob(path)
85
+ if blob:
86
+ return BytesIO(blob)
87
+ return None
88
+
89
+ def set_locale(self, domain: str, locale: str | None = None) -> bool:
90
+ if locale is not None:
91
+ if mo_file := self._get_mo(domain, locale):
92
+ self._t.add_fallback(gettext.GNUTranslations(mo_file))
93
+ return True
94
+ return False
95
+
96
+ def gettext(self, x: str) -> str:
97
+ return self._t.gettext(x)
98
+
99
+ def ngettext(self, singular: str, plural: str, n: int) -> str:
100
+ return self._t.ngettext(singular, plural, n)
101
+
102
+
103
+ ADAPTER: Final = I18nAdapter()
104
+
105
+
106
+ DeferredI18nString = NewType("DeferredI18nString", str)
107
+
108
+
109
+ if sys.version_info >= (3, 11):
110
+
111
+ def _(x: LiteralString | DeferredI18nString) -> str:
112
+ """``gettext`` alias that ensures its input is string literal via type
113
+ signature."""
114
+ return ADAPTER.gettext(x)
115
+
116
+ def d_(x: LiteralString) -> DeferredI18nString:
117
+ """Mark a string literal for deferred translation: call ``_`` at use sites."""
118
+ return DeferredI18nString(x)
119
+
120
+ else:
121
+
122
+ def _(x: str | DeferredI18nString) -> str:
123
+ """``gettext`` alias that ensures its input is string literal via type
124
+ signature."""
125
+ return ADAPTER.gettext(x)
126
+
127
+ def d_(x: str) -> DeferredI18nString:
128
+ """Mark a string literal for deferred translation: call ``_`` at use sites."""
129
+ return DeferredI18nString(x)
ruyi/log/__init__.py CHANGED
@@ -11,6 +11,7 @@ if TYPE_CHECKING:
11
11
  from rich.console import Console, RenderableType
12
12
  from rich.text import Text
13
13
 
14
+ from ..i18n import _
14
15
  from ..utils.global_mode import ProvidesGlobalMode
15
16
  from ..utils.porcelain import PorcelainEntity, PorcelainEntityType, PorcelainOutput
16
17
 
@@ -217,7 +218,7 @@ class RuyiConsoleLogger(RuyiLogger):
217
218
  return self._emit_porcelain_log("F", message, sep, *objects)
218
219
 
219
220
  return self.log_console.print(
220
- f"[bold red]fatal error:[/] {message}",
221
+ _("[bold red]fatal error:[/] {message}").format(message=message),
221
222
  *objects,
222
223
  sep=sep,
223
224
  end=end,
@@ -234,7 +235,7 @@ class RuyiConsoleLogger(RuyiLogger):
234
235
  return self._emit_porcelain_log("I", message, sep, *objects)
235
236
 
236
237
  return self.log_console.print(
237
- f"[bold green]info:[/] {message}",
238
+ _("[bold green]info:[/] {message}").format(message=message),
238
239
  *objects,
239
240
  sep=sep,
240
241
  end=end,
@@ -251,7 +252,7 @@ class RuyiConsoleLogger(RuyiLogger):
251
252
  return self._emit_porcelain_log("W", message, sep, *objects)
252
253
 
253
254
  return self.log_console.print(
254
- f"[bold yellow]warn:[/] {message}",
255
+ _("[bold yellow]warn:[/] {message}").format(message=message),
255
256
  *objects,
256
257
  sep=sep,
257
258
  end=end,
@@ -263,10 +264,10 @@ def humanize_list(
263
264
  *,
264
265
  sep: str = ", ",
265
266
  item_color: str | None = None,
266
- empty_prompt: str = "(none)",
267
+ empty_prompt: str | None = None,
267
268
  ) -> str:
268
269
  if not obj:
269
- return empty_prompt
270
+ return empty_prompt if empty_prompt is not None else _("(none)")
270
271
  if item_color is None:
271
272
  return sep.join(obj)
272
273
  return sep.join(f"[{item_color}]{x}[/]" for x in obj)