ruyi 0.39.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 (101) hide show
  1. ruyi/__init__.py +21 -0
  2. ruyi/__main__.py +98 -0
  3. ruyi/cli/__init__.py +5 -0
  4. ruyi/cli/builtin_commands.py +14 -0
  5. ruyi/cli/cmd.py +224 -0
  6. ruyi/cli/completer.py +50 -0
  7. ruyi/cli/completion.py +26 -0
  8. ruyi/cli/config_cli.py +153 -0
  9. ruyi/cli/main.py +111 -0
  10. ruyi/cli/self_cli.py +295 -0
  11. ruyi/cli/user_input.py +127 -0
  12. ruyi/cli/version_cli.py +45 -0
  13. ruyi/config/__init__.py +401 -0
  14. ruyi/config/editor.py +92 -0
  15. ruyi/config/errors.py +76 -0
  16. ruyi/config/news.py +39 -0
  17. ruyi/config/schema.py +197 -0
  18. ruyi/device/__init__.py +0 -0
  19. ruyi/device/provision.py +591 -0
  20. ruyi/device/provision_cli.py +40 -0
  21. ruyi/log/__init__.py +272 -0
  22. ruyi/mux/.gitignore +1 -0
  23. ruyi/mux/__init__.py +0 -0
  24. ruyi/mux/runtime.py +213 -0
  25. ruyi/mux/venv/__init__.py +12 -0
  26. ruyi/mux/venv/emulator_cfg.py +41 -0
  27. ruyi/mux/venv/maker.py +782 -0
  28. ruyi/mux/venv/venv_cli.py +92 -0
  29. ruyi/mux/venv_cfg.py +214 -0
  30. ruyi/pluginhost/__init__.py +0 -0
  31. ruyi/pluginhost/api.py +206 -0
  32. ruyi/pluginhost/ctx.py +222 -0
  33. ruyi/pluginhost/paths.py +135 -0
  34. ruyi/pluginhost/plugin_cli.py +37 -0
  35. ruyi/pluginhost/unsandboxed.py +246 -0
  36. ruyi/py.typed +0 -0
  37. ruyi/resource_bundle/__init__.py +20 -0
  38. ruyi/resource_bundle/__main__.py +55 -0
  39. ruyi/resource_bundle/data.py +26 -0
  40. ruyi/ruyipkg/__init__.py +0 -0
  41. ruyi/ruyipkg/admin_checksum.py +88 -0
  42. ruyi/ruyipkg/admin_cli.py +83 -0
  43. ruyi/ruyipkg/atom.py +184 -0
  44. ruyi/ruyipkg/augmented_pkg.py +212 -0
  45. ruyi/ruyipkg/canonical_dump.py +320 -0
  46. ruyi/ruyipkg/checksum.py +39 -0
  47. ruyi/ruyipkg/cli_completion.py +42 -0
  48. ruyi/ruyipkg/distfile.py +208 -0
  49. ruyi/ruyipkg/entity.py +387 -0
  50. ruyi/ruyipkg/entity_cli.py +123 -0
  51. ruyi/ruyipkg/entity_provider.py +273 -0
  52. ruyi/ruyipkg/fetch.py +271 -0
  53. ruyi/ruyipkg/host.py +55 -0
  54. ruyi/ruyipkg/install.py +554 -0
  55. ruyi/ruyipkg/install_cli.py +150 -0
  56. ruyi/ruyipkg/list.py +126 -0
  57. ruyi/ruyipkg/list_cli.py +79 -0
  58. ruyi/ruyipkg/list_filter.py +173 -0
  59. ruyi/ruyipkg/msg.py +99 -0
  60. ruyi/ruyipkg/news.py +123 -0
  61. ruyi/ruyipkg/news_cli.py +78 -0
  62. ruyi/ruyipkg/news_store.py +183 -0
  63. ruyi/ruyipkg/pkg_manifest.py +657 -0
  64. ruyi/ruyipkg/profile.py +208 -0
  65. ruyi/ruyipkg/profile_cli.py +33 -0
  66. ruyi/ruyipkg/protocols.py +55 -0
  67. ruyi/ruyipkg/repo.py +763 -0
  68. ruyi/ruyipkg/state.py +345 -0
  69. ruyi/ruyipkg/unpack.py +369 -0
  70. ruyi/ruyipkg/unpack_method.py +91 -0
  71. ruyi/ruyipkg/update_cli.py +54 -0
  72. ruyi/telemetry/__init__.py +0 -0
  73. ruyi/telemetry/aggregate.py +72 -0
  74. ruyi/telemetry/event.py +41 -0
  75. ruyi/telemetry/node_info.py +192 -0
  76. ruyi/telemetry/provider.py +411 -0
  77. ruyi/telemetry/scope.py +43 -0
  78. ruyi/telemetry/store.py +238 -0
  79. ruyi/telemetry/telemetry_cli.py +127 -0
  80. ruyi/utils/__init__.py +0 -0
  81. ruyi/utils/ar.py +74 -0
  82. ruyi/utils/ci.py +63 -0
  83. ruyi/utils/frontmatter.py +38 -0
  84. ruyi/utils/git.py +169 -0
  85. ruyi/utils/global_mode.py +204 -0
  86. ruyi/utils/l10n.py +83 -0
  87. ruyi/utils/markdown.py +73 -0
  88. ruyi/utils/nuitka.py +33 -0
  89. ruyi/utils/porcelain.py +51 -0
  90. ruyi/utils/prereqs.py +77 -0
  91. ruyi/utils/ssl_patch.py +170 -0
  92. ruyi/utils/templating.py +34 -0
  93. ruyi/utils/toml.py +115 -0
  94. ruyi/utils/url.py +7 -0
  95. ruyi/utils/xdg_basedir.py +80 -0
  96. ruyi/version.py +67 -0
  97. ruyi-0.39.0.dist-info/LICENSE-Apache.txt +201 -0
  98. ruyi-0.39.0.dist-info/METADATA +403 -0
  99. ruyi-0.39.0.dist-info/RECORD +101 -0
  100. ruyi-0.39.0.dist-info/WHEEL +4 -0
  101. ruyi-0.39.0.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,591 @@
1
+ import itertools
2
+ import os.path
3
+ from typing import TYPE_CHECKING, TypedDict, TypeGuard, cast
4
+
5
+ from ..cli import user_input
6
+ from ..config import GlobalConfig
7
+ from ..log import RuyiLogger
8
+ from ..ruyipkg.atom import Atom, ExprAtom, SlugAtom
9
+ from ..ruyipkg.entity_provider import BaseEntity
10
+ from ..ruyipkg.host import get_native_host
11
+ from ..ruyipkg.install import do_install_atoms
12
+ from ..ruyipkg.pkg_manifest import (
13
+ KNOWN_PARTITION_KINDS,
14
+ PartitionKind,
15
+ PartitionMapDecl,
16
+ )
17
+ from ..ruyipkg.repo import MetadataRepo
18
+ from ..utils import prereqs
19
+
20
+ if TYPE_CHECKING:
21
+ from ..ruyipkg.pkg_manifest import BoundPackageManifest
22
+
23
+
24
+ def get_variant_display_name(dev: BaseEntity, variant: BaseEntity) -> str:
25
+ """Get the display name of a device variant."""
26
+ if n := variant.display_name:
27
+ return n
28
+ return f"{dev.display_name} ({variant.data['variant_name']})"
29
+
30
+
31
+ def do_provision_interactive(config: GlobalConfig) -> int:
32
+ log = config.logger
33
+
34
+ # ensure ruyi repo is present, for good out-of-the-box experience
35
+ mr = config.repo
36
+ mr.ensure_git_repo()
37
+
38
+ log.stdout(
39
+ """
40
+ [bold green]RuyiSDK Device Provisioning Wizard[/]
41
+
42
+ This is a wizard intended to help you install a system on your device for your
43
+ development pleasure, all with ease.
44
+
45
+ You will be asked some questions that help RuyiSDK understand your device and
46
+ your intended configuration, then packages will be downloaded and flashed onto
47
+ the device's storage, that you should somehow make available on this host
48
+ system beforehand.
49
+
50
+ Note that, as Ruyi does not run as [yellow]root[/], but raw disk access is most likely
51
+ required to flash images, you should arrange to allow your user account [yellow]sudo[/]
52
+ access to necessary commands such as [yellow]dd[/]. Flashing will fail if the [yellow]sudo[/]
53
+ configuration does not allow so.
54
+ """
55
+ )
56
+
57
+ if not user_input.ask_for_yesno_confirmation(log, "Continue?"):
58
+ log.stdout(
59
+ "\nExiting. You can restart the wizard whenever prepared.",
60
+ end="\n\n",
61
+ )
62
+ return 1
63
+
64
+ device_entities = list(mr.entity_store.iter_entities("device"))
65
+ device_entities.sort(key=lambda x: x.display_name or "")
66
+ devices_by_id = {x.id: x for x in device_entities}
67
+
68
+ dev_choices = {k: v.display_name or "" for k, v in devices_by_id.items()}
69
+ dev_id = user_input.ask_for_kv_choice(
70
+ log,
71
+ "\nThe following devices are currently supported by the wizard. Please pick your device:",
72
+ dev_choices,
73
+ )
74
+ dev = devices_by_id[dev_id]
75
+
76
+ variants = list(
77
+ mr.entity_store.traverse_related_entities(
78
+ dev,
79
+ entity_types=["device-variant"],
80
+ )
81
+ )
82
+ variants.sort(key=lambda x: x.data.get("variant_name", ""))
83
+
84
+ variant_choices = [get_variant_display_name(dev, i) for i in variants]
85
+ variant_idx = user_input.ask_for_choice(
86
+ log,
87
+ "\nThe device has the following variants. Please choose the one corresponding to your hardware at hand:",
88
+ variant_choices,
89
+ )
90
+ variant = variants[variant_idx]
91
+
92
+ supported_combos = list(
93
+ mr.entity_store.traverse_related_entities(
94
+ variant,
95
+ forward_refs=False,
96
+ reverse_refs=True,
97
+ entity_types=["image-combo"],
98
+ )
99
+ )
100
+ supported_combos.sort(key=lambda x: x.display_name or "")
101
+ combo_choices = [combo.display_name or "" for combo in supported_combos]
102
+ combo_idx = user_input.ask_for_choice(
103
+ 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:",
105
+ combo_choices,
106
+ )
107
+ combo = supported_combos[combo_idx]
108
+
109
+ return do_provision_combo_interactive(config, mr, dev, variant, combo)
110
+
111
+
112
+ def maybe_render_postinst_msg(
113
+ logger: RuyiLogger,
114
+ mr: MetadataRepo,
115
+ combo: BaseEntity,
116
+ lang_code: str,
117
+ ) -> bool:
118
+ if postinst_msgid := combo.data.get("postinst_msgid"):
119
+ # This field is named just "msgid" so no variables to render for
120
+ # the retrieved text
121
+ if msg := mr.messages.get_message_template(postinst_msgid, lang_code):
122
+ logger.stdout(f"\n{msg}")
123
+ return True
124
+ return False
125
+
126
+
127
+ def do_provision_combo_interactive(
128
+ config: GlobalConfig,
129
+ mr: MetadataRepo,
130
+ dev_decl: BaseEntity,
131
+ variant_decl: BaseEntity,
132
+ combo: BaseEntity,
133
+ ) -> int:
134
+ logger = config.logger
135
+ logger.D(f"provisioning device variant '{dev_decl.id}@{variant_decl.id}'")
136
+
137
+ # download packages
138
+ pkg_atoms = combo.data.get("package_atoms", [])
139
+ if not pkg_atoms:
140
+ if maybe_render_postinst_msg(logger, mr, combo, config.lang_code):
141
+ return 0
142
+
143
+ logger.F(
144
+ f"malformed config: device variant '{dev_decl.id}@{variant_decl.id}' asks for no packages but provides no messages either"
145
+ )
146
+ return 1
147
+
148
+ new_pkg_atoms = customize_package_versions(config, mr, pkg_atoms)
149
+ if new_pkg_atoms is None:
150
+ logger.stdout("\nExiting. You may restart the wizard at any time.", end="\n\n")
151
+ return 1
152
+ else:
153
+ pkg_atoms = new_pkg_atoms
154
+
155
+ pkg_names_for_display = "\n".join(f" * [green]{i}[/]" for i in pkg_atoms)
156
+ 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
+ """
162
+ )
163
+
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")
166
+ return 1
167
+
168
+ ret = do_install_atoms(
169
+ config,
170
+ mr,
171
+ set(pkg_atoms),
172
+ canonicalized_host=get_native_host(),
173
+ fetch_only=False,
174
+ reinstall=False,
175
+ )
176
+ if ret != 0:
177
+ logger.F("failed to download and install packages")
178
+ logger.I("your device was not touched")
179
+ return 2
180
+
181
+ strat_provider = ProvisionStrategyProvider(mr)
182
+ strategies = [
183
+ (pkg, get_pkg_provision_strategy(strat_provider, mr, pkg)) for pkg in pkg_atoms
184
+ ]
185
+ strategies.sort(key=lambda x: x[1].priority, reverse=True)
186
+
187
+ # compose a partition map for each image pkg installed
188
+ pkg_part_maps = {pkg: make_pkg_part_map(config, mr, pkg) for pkg in pkg_atoms}
189
+ all_parts: list[PartitionKind] = []
190
+ for pkg_part_map in pkg_part_maps.values():
191
+ all_parts.extend(pkg_part_map.keys())
192
+
193
+ # prompt user to give paths to target block device(s)
194
+ requested_host_blkdevs: list[PartitionKind] = []
195
+ for pkg, strat in strategies:
196
+ requested_host_blkdevs.extend(strat.need_host_blkdevs(all_parts))
197
+
198
+ host_blkdev_map: PartitionMapDecl = {}
199
+ if requested_host_blkdevs:
200
+ logger.stdout(
201
+ """
202
+ For initializing this target device, you should plug into this host system the
203
+ device's storage (e.g. SD card or NVMe SSD), or a removable disk to be
204
+ reformatted as a live medium, and note down the corresponding device file
205
+ path(s), e.g. /dev/sdX, /dev/nvmeXnY for whole disks; /dev/sdXY, /dev/nvmeXnYpZ
206
+ for partitions. You may consult e.g. [yellow]sudo blkid[/] output for the
207
+ information you will need later.
208
+ """
209
+ )
210
+ for part in requested_host_blkdevs:
211
+ part_desc = get_part_desc(part)
212
+ path = user_input.ask_for_file(
213
+ logger,
214
+ f"Please give the path for the {part_desc}:",
215
+ )
216
+ host_blkdev_map[part] = path
217
+
218
+ # final confirmation
219
+ logger.stdout(
220
+ """
221
+ We have collected enough information for the actual flashing. Now is the last
222
+ chance to re-check and confirm everything is fine.
223
+
224
+ We are about to:
225
+ """
226
+ )
227
+
228
+ pretend_steps = "\n".join(
229
+ f" * {step_str}"
230
+ for step_str in itertools.chain(
231
+ *(
232
+ strat[1].pretend(pkg_part_maps[strat[0]], host_blkdev_map)
233
+ for strat in strategies
234
+ )
235
+ )
236
+ )
237
+ logger.stdout(pretend_steps, end="\n\n")
238
+
239
+ if not user_input.ask_for_yesno_confirmation(logger, "Proceed with flashing?"):
240
+ logger.stdout(
241
+ "\nExiting. The device is not touched and you may re-start the wizard at will.",
242
+ end="\n\n",
243
+ )
244
+ return 1
245
+
246
+ # ensure commands
247
+ all_needed_cmds = set(itertools.chain(*(strat.need_cmd for _, strat in strategies)))
248
+ if all_needed_cmds:
249
+ prereqs.ensure_cmds(logger, all_needed_cmds, interactive_retry=True)
250
+
251
+ if "fastboot" in all_needed_cmds:
252
+ # ask the user to ensure the device shows up
253
+ # TODO: automate doing so
254
+ logger.stdout(
255
+ """
256
+ Some flashing steps require the use of fastboot, in which case you should
257
+ ensure the target device is showing up in [yellow]fastboot devices[/] output.
258
+ Please confirm it yourself before the flashing begins.
259
+ """
260
+ )
261
+ if not user_input.ask_for_yesno_confirmation(
262
+ logger,
263
+ "Is the device identified by fastboot now?",
264
+ ):
265
+ logger.stdout(
266
+ "\nAborting. The device is not touched. You may re-start the wizard after [yellow]fastboot[/] is fixed for the device.",
267
+ end="\n\n",
268
+ )
269
+ return 1
270
+
271
+ # flash
272
+ for pkg, strat in strategies:
273
+ logger.D(f"flashing {pkg} with strategy {strat}")
274
+ ret = strat.flash(pkg_part_maps[pkg], host_blkdev_map)
275
+ if ret != 0:
276
+ logger.F("flashing failed, check your device right now")
277
+ return ret
278
+
279
+ # parting words
280
+ logger.stdout(
281
+ """
282
+ It seems the flashing has finished without errors.
283
+
284
+ [bold green]Happy hacking![/]
285
+ """
286
+ )
287
+
288
+ maybe_render_postinst_msg(logger, mr, combo, config.lang_code)
289
+
290
+ return 0
291
+
292
+
293
+ def get_part_desc(part: PartitionKind) -> str:
294
+ match part:
295
+ case "disk":
296
+ return "target's whole disk"
297
+ case "live":
298
+ return "removable disk to use as live medium"
299
+ case _:
300
+ return f"target's '{part}' partition"
301
+
302
+
303
+ class PackageProvisionStrategyDecl(TypedDict):
304
+ priority: int # higher number means earlier
305
+ need_host_blkdevs_fn: object # Callable[[list[PartitionKind]], list[PartitionKind]]
306
+ need_cmd: list[str]
307
+ pretend_fn: object # Callable[[PartitionMapDecl, PartitionMapDecl], list[str]]
308
+ flash_fn: object # Callable[[PartitionMapDecl, PartitionMapDecl], int]
309
+
310
+
311
+ def validate_list_str(x: object) -> TypeGuard[list[str]]:
312
+ if not isinstance(x, list):
313
+ return False
314
+ x = cast(list[object], x)
315
+ return all(isinstance(y, str) for y in x)
316
+
317
+
318
+ def validate_list_partition_kinds(x: object) -> TypeGuard[list[PartitionKind]]:
319
+ if not isinstance(x, list):
320
+ return False
321
+ x = cast(list[object], x)
322
+ for item in x:
323
+ if not isinstance(item, str) or item not in KNOWN_PARTITION_KINDS:
324
+ return False
325
+ return True
326
+
327
+
328
+ class PackageProvisionStrategy:
329
+ def __init__(
330
+ self,
331
+ decl: PackageProvisionStrategyDecl,
332
+ mr: MetadataRepo,
333
+ ) -> None:
334
+ self._d = decl
335
+ self._mr = mr
336
+
337
+ @property
338
+ def priority(self) -> int:
339
+ return self._d["priority"]
340
+
341
+ @property
342
+ def need_cmd(self) -> list[str]:
343
+ return self._d["need_cmd"]
344
+
345
+ def need_host_blkdevs(self, x: list[PartitionKind]) -> list[PartitionKind]:
346
+ result = self._mr.eval_plugin_fn(self._d["need_host_blkdevs_fn"], x)
347
+ if not validate_list_partition_kinds(result):
348
+ raise TypeError("need_host_blkdevs_fn must return list[PartitionKind]")
349
+ return result
350
+
351
+ def pretend(
352
+ self,
353
+ img_paths: PartitionMapDecl,
354
+ blkdev_paths: PartitionMapDecl,
355
+ ) -> list[str]:
356
+ result = self._mr.eval_plugin_fn(self._d["pretend_fn"], img_paths, blkdev_paths)
357
+ if not validate_list_str(result):
358
+ raise TypeError("pretend_fn must return list[str]")
359
+ return result
360
+
361
+ def flash(
362
+ self,
363
+ img_paths: PartitionMapDecl,
364
+ blkdev_paths: PartitionMapDecl,
365
+ ) -> int:
366
+ result = self._mr.eval_plugin_fn(self._d["flash_fn"], img_paths, blkdev_paths)
367
+ if not isinstance(result, int):
368
+ raise TypeError("flash_fn must return int")
369
+ return result
370
+
371
+
372
+ class ProvisionStrategyProvider:
373
+ def __init__(self, mr: MetadataRepo) -> None:
374
+ self._mr = mr
375
+ self._strats: dict[str, PackageProvisionStrategy] = {}
376
+
377
+ # import the "standard library" of strategies
378
+ self._import_strategy_plugin("std")
379
+
380
+ def _import_strategy_plugin(self, plugin_pkg_name: str) -> None:
381
+ plugin_id = f"ruyi-device-provision-strategy-{plugin_pkg_name}"
382
+ provided_strats = self._mr.get_from_plugin(
383
+ plugin_id,
384
+ "PROVIDED_DEVICE_PROVISION_STRATEGIES_V1",
385
+ )
386
+ if not isinstance(provided_strats, dict):
387
+ raise RuntimeError(
388
+ f"malformed device provisioner strategy plugin '{plugin_id}'"
389
+ )
390
+ for name, decl in provided_strats.items():
391
+ self._strats[name] = PackageProvisionStrategy(decl, self._mr)
392
+
393
+ def __getitem__(self, name: str) -> PackageProvisionStrategy:
394
+ try:
395
+ return self._strats[name]
396
+ except KeyError:
397
+ # for now it's "ruyi-device-provision-strategy-STRATEGY-NAME"
398
+ # we may have to revise before Ruyi v1.0 though
399
+ self._import_strategy_plugin(name)
400
+ return self._strats[name]
401
+
402
+
403
+ def get_pkg_provision_strategy(
404
+ strat_provider: ProvisionStrategyProvider,
405
+ mr: MetadataRepo,
406
+ atom: str,
407
+ ) -> PackageProvisionStrategy:
408
+ a = Atom.parse(atom)
409
+ pm = a.match_in_repo(mr, True)
410
+ assert pm is not None
411
+
412
+ pmd = pm.provisionable_metadata
413
+ assert pmd is not None
414
+ return strat_provider[pmd.strategy]
415
+
416
+
417
+ def make_pkg_part_map(
418
+ config: GlobalConfig,
419
+ mr: MetadataRepo,
420
+ atom: str,
421
+ ) -> PartitionMapDecl:
422
+ a = Atom.parse(atom)
423
+ pm = a.match_in_repo(mr, True)
424
+ assert pm is not None
425
+ pkg_root = config.global_blob_install_root(pm.name_for_installation)
426
+
427
+ pmd = pm.provisionable_metadata
428
+ assert pmd is not None
429
+ return {p: os.path.join(pkg_root, f) for p, f in pmd.partition_map.items()}
430
+
431
+
432
+ def is_package_version_customization_possible(
433
+ gc: GlobalConfig,
434
+ mr: MetadataRepo,
435
+ pkg_atoms: list[str],
436
+ ) -> bool:
437
+ """
438
+ Check if package version customization is possible, which means there
439
+ are at least one package atom specified that matches more than one versions.
440
+ """
441
+
442
+ for atom_str in pkg_atoms:
443
+ # Get all available versions for this package
444
+ a = Atom.parse(atom_str)
445
+ try:
446
+ if len(list(a.iter_in_repo(mr, gc.include_prereleases))) > 1:
447
+ return True
448
+ except KeyError:
449
+ continue
450
+
451
+ return False
452
+
453
+
454
+ def customize_package_versions(
455
+ config: GlobalConfig,
456
+ mr: MetadataRepo,
457
+ pkg_atoms: list[str],
458
+ ) -> list[str] | None:
459
+ """
460
+ Allow the user to customize the versions of packages to be installed.
461
+ Returns a new list of package atoms with user-selected versions.
462
+ """
463
+
464
+ if not is_package_version_customization_possible(config, mr, pkg_atoms):
465
+ return pkg_atoms
466
+
467
+ logger = config.logger
468
+
469
+ # Ask if the user wants to customize package versions
470
+ logger.stdout(
471
+ "By default, we'll install the latest version of each package, but in this case, other choices are possible."
472
+ )
473
+ if not user_input.ask_for_yesno_confirmation(
474
+ logger,
475
+ "Would you like to customize package versions?",
476
+ ):
477
+ return pkg_atoms
478
+
479
+ while True: # Loop to allow restarting the selection process
480
+ result: list[str] = []
481
+ logger.stdout("\n[bold]Package Version Selection[/]")
482
+
483
+ for atom_str in pkg_atoms:
484
+ # Parse the atom to get package name
485
+ a = Atom.parse(atom_str)
486
+ if isinstance(a, ExprAtom):
487
+ # If it's already an expression with version constraints, show the constraints
488
+ logger.stdout(
489
+ f"\nPackage [green]{atom_str}[/] already has version constraints."
490
+ )
491
+ if not user_input.ask_for_yesno_confirmation(
492
+ logger,
493
+ "Would you like to change them?",
494
+ ):
495
+ result.append(atom_str)
496
+ continue
497
+ elif isinstance(a, SlugAtom):
498
+ # Slugs already fix the version, so we can't change them
499
+ logger.W(
500
+ f"version cannot be overridden for slug atom [green]{atom_str}[/]"
501
+ )
502
+ result.append(atom_str)
503
+ continue
504
+
505
+ # Get all available versions for this package
506
+ package_name = a.name
507
+ category = a.category
508
+
509
+ available_versions: "list[BoundPackageManifest]" = []
510
+ try:
511
+ available_versions = list(mr.iter_pkg_vers(package_name, category))
512
+ except KeyError:
513
+ logger.W(
514
+ f"could not find package [yellow]{category}/{package_name}[/] in repository"
515
+ )
516
+ result.append(atom_str)
517
+
518
+ if not available_versions:
519
+ logger.W(
520
+ f"no versions found for package [yellow]{category}/{package_name}[/]"
521
+ )
522
+ result.append(atom_str)
523
+ continue
524
+
525
+ if len(available_versions) == 1:
526
+ # If there's only one version available, use it
527
+ selected_version = available_versions[0]
528
+ logger.stdout(
529
+ f"Only one version available for [green]{category}/{package_name}[/]: [blue]{selected_version.ver}[/], using it."
530
+ )
531
+ result.append(atom_str)
532
+ continue
533
+
534
+ # Sort versions with newest first
535
+ available_versions.sort(key=lambda pm: pm.semver, reverse=True)
536
+
537
+ # Create a list of version choices for display
538
+ version_choices = []
539
+ for pm in available_versions:
540
+ version_str = str(pm.semver)
541
+ remarks = []
542
+
543
+ if pm.is_prerelease:
544
+ remarks.append("prerelease")
545
+ if pm.service_level.has_known_issues:
546
+ remarks.append("has known issues")
547
+ if pm.upstream_version:
548
+ remarks.append(f"upstream: {pm.upstream_version}")
549
+
550
+ remark_str = f" ({', '.join(remarks)})" if remarks else ""
551
+ version_choices.append(f"{version_str}{remark_str}")
552
+
553
+ # Ask the user to select a version
554
+ version_idx = user_input.ask_for_choice(
555
+ logger,
556
+ f"\nSelect a version for package [green]{category or ''}{('/' + package_name) if category else package_name}[/]:",
557
+ version_choices,
558
+ )
559
+
560
+ selected_version = available_versions[version_idx]
561
+
562
+ # Create the new atom string with the selected version
563
+ if category:
564
+ new_atom = f"{category}/{package_name}(=={selected_version.ver})"
565
+ else:
566
+ new_atom = f"{package_name}(=={selected_version.ver})"
567
+
568
+ logger.stdout(f"Selected: [blue]{new_atom}[/]")
569
+ result.append(new_atom)
570
+
571
+ logger.stdout("\nPackage versions to be installed:")
572
+ for atom in result:
573
+ logger.stdout(f" * [green]{atom}[/]")
574
+
575
+ confirmation = user_input.ask_for_choice(
576
+ logger,
577
+ "\nHow would you like to proceed?",
578
+ [
579
+ "Continue with these versions",
580
+ "Restart version selection",
581
+ "Abort device provisioning",
582
+ ],
583
+ )
584
+
585
+ if confirmation == 0: # Continue with these versions
586
+ return result
587
+ elif confirmation == 1: # Restart version selection
588
+ logger.stdout("\nRestarting package version selection...")
589
+ continue
590
+ else: # Abort installation
591
+ return None
@@ -0,0 +1,40 @@
1
+ import argparse
2
+ from typing import TYPE_CHECKING
3
+
4
+ from ..cli.cmd import RootCommand
5
+
6
+ if TYPE_CHECKING:
7
+ from ..cli.completion import ArgumentParser
8
+ from ..config import GlobalConfig
9
+
10
+
11
+ class DeviceCommand(
12
+ RootCommand,
13
+ cmd="device",
14
+ has_subcommands=True,
15
+ help="Manage devices",
16
+ ):
17
+ @classmethod
18
+ def configure_args(cls, gc: "GlobalConfig", p: "ArgumentParser") -> None:
19
+ pass
20
+
21
+
22
+ class DeviceProvisionCommand(
23
+ DeviceCommand,
24
+ cmd="provision",
25
+ aliases=["flash"],
26
+ help="Interactively initialize a device for development",
27
+ ):
28
+ @classmethod
29
+ def configure_args(cls, gc: "GlobalConfig", p: "ArgumentParser") -> None:
30
+ pass
31
+
32
+ @classmethod
33
+ def main(cls, cfg: "GlobalConfig", args: argparse.Namespace) -> int:
34
+ from .provision import do_provision_interactive
35
+
36
+ try:
37
+ return do_provision_interactive(cfg)
38
+ except KeyboardInterrupt:
39
+ cfg.logger.stdout("\n\nKeyboard interrupt received, exiting.", end="\n\n")
40
+ return 1