replx-agent 2.0.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.

Potentially problematic release.


This version of replx-agent might be problematic. Click here for more details.

Files changed (46) hide show
  1. replx_agent/__init__.py +3 -0
  2. replx_agent/__main__.py +5 -0
  3. replx_agent/commands.py +131 -0
  4. replx_agent/helpers/__init__.py +3 -0
  5. replx_agent/helpers/scanner.py +308 -0
  6. replx_agent/pkg/__init__.py +11 -0
  7. replx_agent/pkg/compiler.py +153 -0
  8. replx_agent/pkg/registry.py +387 -0
  9. replx_agent/pkg/service.py +690 -0
  10. replx_agent/pkg/store.py +83 -0
  11. replx_agent/protocol/__init__.py +11 -0
  12. replx_agent/protocol/repl.py +1029 -0
  13. replx_agent/protocol/storage.py +1018 -0
  14. replx_agent/rpc/__init__.py +3 -0
  15. replx_agent/rpc/protocol.py +220 -0
  16. replx_agent/server/__init__.py +3 -0
  17. replx_agent/server/__main__.py +4 -0
  18. replx_agent/server/command_dispatcher.py +49 -0
  19. replx_agent/server/connection_manager.py +654 -0
  20. replx_agent/server/core.py +1221 -0
  21. replx_agent/server/handlers/__init__.py +24 -0
  22. replx_agent/server/handlers/exec.py +553 -0
  23. replx_agent/server/handlers/filesystem.py +383 -0
  24. replx_agent/server/handlers/firmware.py +118 -0
  25. replx_agent/server/handlers/i2c.py +23 -0
  26. replx_agent/server/handlers/package.py +182 -0
  27. replx_agent/server/handlers/repl.py +188 -0
  28. replx_agent/server/handlers/session.py +205 -0
  29. replx_agent/server/handlers/spi.py +23 -0
  30. replx_agent/server/handlers/transfer.py +600 -0
  31. replx_agent/server/handlers/uart.py +32 -0
  32. replx_agent/server/session_manager.py +391 -0
  33. replx_agent/server/tcp.py +207 -0
  34. replx_agent/terminal.py +566 -0
  35. replx_agent/transport/__init__.py +32 -0
  36. replx_agent/transport/serial.py +220 -0
  37. replx_agent/utils/__init__.py +95 -0
  38. replx_agent/utils/constants.py +78 -0
  39. replx_agent/utils/device_info.py +183 -0
  40. replx_agent/utils/exceptions.py +31 -0
  41. replx_agent-2.0.0.dist-info/METADATA +74 -0
  42. replx_agent-2.0.0.dist-info/RECORD +46 -0
  43. replx_agent-2.0.0.dist-info/WHEEL +5 -0
  44. replx_agent-2.0.0.dist-info/entry_points.txt +2 -0
  45. replx_agent-2.0.0.dist-info/licenses/LICENSE +21 -0
  46. replx_agent-2.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,690 @@
1
+ """Package store orchestration for the agent.
2
+
3
+ Pure logic (no UI, no board I/O): download planning/execution against the
4
+ GitHub registry, install batch preparation (compile to staging), store
5
+ cleanup, store status and typehints enumeration. Board writes are done by
6
+ the command handler using the prepared batch.
7
+ """
8
+
9
+ import os
10
+ import hashlib
11
+ import shutil
12
+ import urllib.error
13
+ from concurrent.futures import ThreadPoolExecutor, as_completed
14
+ from typing import Callable, Optional
15
+
16
+ from replx_agent.utils import device_name_to_path
17
+ from replx_agent.utils.exceptions import PkgError
18
+ from .store import StoreManager
19
+ from .registry import InstallHelper, SearchHelper, RegistryHelper
20
+ from .compiler import CompilerHelper
21
+
22
+ ProgressFn = Callable[[dict], None]
23
+
24
+
25
+ def find_local_pkg_version(local_meta: dict, *, scope: str, target: str,
26
+ source_path: str, pkg_name: str) -> tuple[float, bool]:
27
+ if not local_meta or not isinstance(local_meta, dict):
28
+ return 0.0, True
29
+
30
+ local_packages = local_meta.get("packages", {})
31
+ if not local_packages or not isinstance(local_packages, dict):
32
+ return 0.0, True
33
+
34
+ if not scope or not target or not source_path or not pkg_name:
35
+ return 0.0, True
36
+
37
+ base_key = f"{scope}:{target}:{pkg_name}"
38
+
39
+ matching_versions = []
40
+ for key, meta in local_packages.items():
41
+ if not isinstance(key, str) or not isinstance(meta, dict):
42
+ continue
43
+ if key == base_key or key.startswith(base_key + "@"):
44
+ meta_source = meta.get("source") or meta.get("typehint") or ""
45
+ if meta_source == source_path:
46
+ matching_versions.append(RegistryHelper.get_version(meta))
47
+ if matching_versions:
48
+ return max(matching_versions), False
49
+
50
+ if base_key in local_packages:
51
+ meta = local_packages[base_key]
52
+ if isinstance(meta, dict):
53
+ meta_source = meta.get("source") or meta.get("typehint") or ""
54
+ if not source_path or not meta_source or meta_source == source_path:
55
+ return RegistryHelper.get_version(meta), False
56
+
57
+ if scope == "device":
58
+ best = None
59
+ for key, meta in local_packages.items():
60
+ if not isinstance(key, str):
61
+ continue
62
+ if key.startswith(base_key + "@"):
63
+ v = RegistryHelper.get_version(meta)
64
+ best = v if best is None else max(best, v)
65
+ if best is not None:
66
+ return best, False
67
+
68
+ prefix_lower = f"{scope}:{target}:".lower()
69
+ for key, meta in local_packages.items():
70
+ if not isinstance(key, str) or not isinstance(meta, dict):
71
+ continue
72
+ if key.lower().startswith(prefix_lower) and meta.get("source") == source_path:
73
+ return RegistryHelper.get_version(meta), False
74
+
75
+ return 0.0, True
76
+
77
+
78
+ def compute_search_rows(core: str, device: str, owner: str, repo: str, ref: str,
79
+ query: Optional[str] = None) -> dict:
80
+ try:
81
+ remote = StoreManager.load_remote_meta(owner, repo, ref)
82
+ except Exception as e:
83
+ raise PkgError(f"Failed to load remote registry: {e}")
84
+
85
+ try:
86
+ local = StoreManager.load_local_meta()
87
+ except Exception:
88
+ local = {}
89
+
90
+ cores, devices = RegistryHelper.root_sections(remote)
91
+
92
+ def status_label(remote_ver: float, local_ver_: float, missing_local: bool) -> str:
93
+ if missing_local:
94
+ return "NEW"
95
+ if remote_ver > (local_ver_ or 0.0):
96
+ return "UPD"
97
+ return ""
98
+
99
+ def local_ver(source_path: str, target: str, scope: str) -> tuple[float, bool]:
100
+ if not source_path or not target:
101
+ return 0.0, True
102
+
103
+ filename = source_path.split("/")[-1] if "/" in source_path else source_path
104
+ name = filename.replace(".py", "").replace(".pyi", "").replace("__init__", "")
105
+ if not name and "/" in source_path:
106
+ parts = source_path.split("/")
107
+ name = parts[-2] if len(parts) >= 2 else ""
108
+
109
+ return find_local_pkg_version(local, scope=scope, target=target,
110
+ source_path=source_path, pkg_name=name)
111
+
112
+ def add_core_rows(core_name: str, rows: list):
113
+ for relpath, pkg_meta in RegistryHelper.walk_files_for_core(remote, core_name, "src"):
114
+ if not relpath.endswith(".py"):
115
+ continue
116
+ rver = RegistryHelper.get_version(pkg_meta)
117
+ source = pkg_meta.get("source", "")
118
+ lver, missing = local_ver(source, core_name, "core")
119
+ stat = status_label(rver, lver, missing)
120
+ pkg_name = pkg_meta.get("source", "").split("/")[-1].replace(".py", "")
121
+ display_path = f"src/{relpath}"
122
+ if relpath.endswith("/__init__.py"):
123
+ display_path = display_path.replace("/__init__.py", "/")
124
+ rows.append(("core", core_name, stat, f"{rver:.1f}", display_path, pkg_name))
125
+
126
+ def add_device_rows(dev_name: str, rows: list):
127
+ for relpath, pkg_meta in RegistryHelper.walk_files_for_device(remote, dev_name, "src", include_submodules=True):
128
+ if not (relpath.endswith(".py") or relpath.endswith(".bin")):
129
+ continue
130
+ rver = RegistryHelper.get_version(pkg_meta)
131
+ source = pkg_meta.get("_parent_source") or pkg_meta.get("source", "")
132
+ lver, missing = local_ver(source, dev_name, "device")
133
+ stat = status_label(rver, lver, missing)
134
+ pkg_name = pkg_meta.get("source", "").split("/")[-1].replace(".py", "")
135
+ display_path = f"src/{relpath}"
136
+ if relpath.endswith("/__init__.py"):
137
+ display_path = display_path.replace("/__init__.py", "/")
138
+ elif relpath == "__init__.py":
139
+ src_parts = source.split("/")
140
+ if len(src_parts) >= 2 and src_parts[0] == "device":
141
+ pkg_folder = src_parts[1].lstrip("_")
142
+ display_path = f"src/{pkg_folder}/"
143
+ rows.append(("device", dev_name, stat, f"{rver:.1f}", display_path, pkg_name))
144
+
145
+ cur_core = (core or "").strip()
146
+ cur_dev = (device or "").strip()
147
+ cur_dev_key = SearchHelper.key_ci(devices, device_name_to_path(cur_dev)) if cur_dev else None
148
+ cur_core_key = SearchHelper.key_ci(cores, cur_core) if cur_core else None
149
+ if not cur_core_key:
150
+ cur_dev_key = None
151
+
152
+ rows: list[tuple[str, str, str, str, str, str]] = []
153
+
154
+ if query:
155
+ dkey = SearchHelper.key_ci(devices, device_name_to_path(query))
156
+ ckey = SearchHelper.key_ci(cores, query)
157
+
158
+ if dkey and cur_dev_key and dkey == cur_dev_key:
159
+ add_device_rows(dkey, rows)
160
+ elif ckey and cur_core_key and ckey == cur_core_key:
161
+ add_core_rows(ckey, rows)
162
+ else:
163
+ q = query.lower()
164
+ temp_rows = []
165
+
166
+ if cur_core_key:
167
+ add_core_rows(cur_core_key, temp_rows)
168
+ if cur_dev_key and cur_dev_key != cur_core_key:
169
+ add_device_rows(cur_dev_key, temp_rows)
170
+
171
+ for scope, target, stat, ver_str, shown_path, pkg_name in temp_rows:
172
+ if (q in pkg_name.lower()) or (q in shown_path.lower()):
173
+ rows.append((scope, target, stat, ver_str, shown_path, pkg_name))
174
+ else:
175
+ if cur_core_key:
176
+ add_core_rows(cur_core_key, rows)
177
+ if cur_dev_key and cur_dev_key != cur_core_key:
178
+ add_device_rows(cur_dev_key, rows)
179
+
180
+ def row_key(r):
181
+ scope_order = 0 if r[0] == "core" else 1
182
+ return (scope_order, r[1].lower(), r[4].lower())
183
+
184
+ rows.sort(key=row_key)
185
+
186
+ return {"rows": [list(r) for r in rows]}
187
+
188
+
189
+ def download(core: str, device: str, owner: str, repo: str, ref: str,
190
+ scope_filter: Optional[str] = None,
191
+ file_filter: Optional[str] = None,
192
+ progress: Optional[ProgressFn] = None) -> dict:
193
+ StoreManager.ensure_home_store()
194
+
195
+ dev = (device or "").strip()
196
+ core = (core or "").strip()
197
+
198
+ if not core:
199
+ raise PkgError("Failed to get device info from connected board.")
200
+
201
+ try:
202
+ remote = StoreManager.load_remote_meta(owner, repo, ref)
203
+ except Exception as e:
204
+ raise PkgError(f"Failed to load remote meta: {e}")
205
+
206
+ cores, devices = RegistryHelper.root_sections(remote)
207
+
208
+ if scope_filter not in (None, "core", "device"):
209
+ raise PkgError(f"Invalid scope_filter: {scope_filter}")
210
+
211
+ include_core = scope_filter in (None, "core")
212
+ include_device = scope_filter in (None, "device")
213
+
214
+ core_exists_remote = core in cores if core else False
215
+ device_exists_remote = (device_name_to_path(dev) in devices if dev else False) and (dev != core)
216
+
217
+ if not core_exists_remote:
218
+ return {
219
+ "core": core, "device": dev,
220
+ "core_in_registry": False,
221
+ "total": 0, "new": 0, "upd": 0,
222
+ "errors": [], "target": "",
223
+ }
224
+
225
+ with StoreManager.LOCK:
226
+ try:
227
+ local = StoreManager.load_local_meta()
228
+ if not isinstance(local, dict):
229
+ local = {}
230
+ except Exception:
231
+ local = {}
232
+
233
+ local_packages = local.setdefault("packages", {})
234
+
235
+ if "platform_cores" in remote and core:
236
+ remote_platform_cores = remote.get("platform_cores", {})
237
+ if core in remote_platform_cores:
238
+ if "platform_cores" not in local:
239
+ local["platform_cores"] = {}
240
+ local["platform_cores"][core] = remote_platform_cores[core]
241
+
242
+ if "device_configs" in remote and dev and dev != core:
243
+ remote_device_configs = remote.get("device_configs", {})
244
+ device_key = device_name_to_path(dev)
245
+ if device_key in remote_device_configs:
246
+ if "device_configs" not in local:
247
+ local["device_configs"] = {}
248
+ local["device_configs"][device_key] = remote_device_configs[device_key]
249
+
250
+ if "schema_version" in remote:
251
+ local["schema_version"] = remote.get("schema_version")
252
+
253
+ def _local_touch_package(pkg_name: str, pkg_meta: dict) -> None:
254
+ local_packages[pkg_name] = pkg_meta.copy()
255
+
256
+ exts = (".py", ".pyi", ".json")
257
+
258
+ def _plan_for(scope: str, target_name: str, part: str) -> list:
259
+ todo = []
260
+ remote_packages = remote.get("packages", {})
261
+
262
+ if scope == "core":
263
+ walker = RegistryHelper.walk_files_for_core(remote, target_name, part)
264
+ else:
265
+ walker = RegistryHelper.walk_files_for_device(remote, target_name, part)
266
+
267
+ for relpath, pkg_meta in walker:
268
+ if not relpath.endswith(exts):
269
+ continue
270
+
271
+ source = pkg_meta.get("source", "")
272
+ rver = RegistryHelper.get_version(pkg_meta)
273
+
274
+ orig_pkg_name = None
275
+ orig_pkg_meta = None
276
+
277
+ for name, meta in remote_packages.items():
278
+ if meta.get("source") == source:
279
+ orig_pkg_name = name
280
+ orig_pkg_meta = meta
281
+ break
282
+ variants = meta.get("variants", {})
283
+ for var_name, var_meta in variants.items():
284
+ if var_meta.get("source") == source:
285
+ orig_pkg_name = name
286
+ orig_pkg_meta = meta.copy()
287
+ orig_pkg_meta["source"] = source
288
+ break
289
+ if orig_pkg_name:
290
+ break
291
+
292
+ if not orig_pkg_name or not orig_pkg_meta:
293
+ pkg_name = source.split("/")[-1].replace(".py", "").replace(".pyi", "").replace("__init__", "")
294
+ if not pkg_name or pkg_name == "":
295
+ pkg_name = source.split("/")[-2] if len(source.split("/")) >= 2 else "unknown"
296
+ orig_pkg_name = pkg_name
297
+ orig_pkg_meta = pkg_meta
298
+
299
+ source_for_match = (
300
+ source
301
+ or orig_pkg_meta.get("source", "")
302
+ or orig_pkg_meta.get("typehint", "")
303
+ or ""
304
+ )
305
+ lver, missing = find_local_pkg_version(
306
+ local,
307
+ scope=scope,
308
+ target=target_name,
309
+ source_path=source_for_match,
310
+ pkg_name=orig_pkg_name,
311
+ )
312
+
313
+ change_type = "NEW" if missing else ("UPD" if float(lver) < float(rver) else "")
314
+
315
+ if float(lver) < float(rver):
316
+ display_name = source.split("/")[-1].replace(".py", "").replace(".pyi", "")
317
+ todo.append((relpath, pkg_meta, display_name, orig_pkg_name, orig_pkg_meta, change_type))
318
+
319
+ return todo
320
+
321
+ plan = []
322
+ new_count = 0
323
+ upd_count = 0
324
+ download_targets = []
325
+
326
+ def _append_plan(scope: str, target_name: str, part: str, entries: list):
327
+ nonlocal new_count, upd_count
328
+ for relpath, pkg_meta, display_name, orig_pkg_name, orig_pkg_meta, change_type in entries:
329
+ plan.append((scope, target_name, part, relpath, pkg_meta, display_name,
330
+ orig_pkg_name, orig_pkg_meta, change_type))
331
+ if change_type == "NEW":
332
+ new_count += 1
333
+ elif change_type == "UPD":
334
+ upd_count += 1
335
+
336
+ if core_exists_remote and include_core:
337
+ core_src_files = _plan_for("core", core, "src")
338
+ core_hints_files = _plan_for("core", core, "typehints")
339
+
340
+ if file_filter:
341
+ core_src_files = [
342
+ item for item in core_src_files
343
+ if os.path.basename(item[0]) == file_filter
344
+ ]
345
+ core_hints_files = []
346
+
347
+ _append_plan("core", core, "src", core_src_files)
348
+ _append_plan("core", core, "typehints", core_hints_files)
349
+
350
+ if core_src_files or core_hints_files:
351
+ download_targets.append(f"core/{core}")
352
+
353
+ if device_exists_remote and include_device:
354
+ device_src_files = _plan_for("device", device_name_to_path(dev), "src")
355
+ device_hints_files = _plan_for("device", device_name_to_path(dev), "typehints")
356
+
357
+ if file_filter:
358
+ device_src_files = [
359
+ item for item in device_src_files
360
+ if os.path.basename(item[0]) == file_filter
361
+ ]
362
+ device_hints_files = []
363
+
364
+ _append_plan("device", device_name_to_path(dev), "src", device_src_files)
365
+ _append_plan("device", device_name_to_path(dev), "typehints", device_hints_files)
366
+
367
+ if device_src_files or device_hints_files:
368
+ download_targets.append(f"device/{dev}")
369
+
370
+ download_target = " + ".join(download_targets) if download_targets else ""
371
+
372
+ total = len(plan)
373
+ errors: list[str] = []
374
+
375
+ if total > 0:
376
+ def download_file(task):
377
+ scope, target, part, relpath, pkg_meta, display_name, orig_pkg_name, orig_pkg_meta, _change_type = task
378
+
379
+ if part == "typehints":
380
+ source = pkg_meta.get("typehint", "")
381
+ if not source:
382
+ return (False, relpath, "No typehint path in metadata")
383
+ else:
384
+ source = pkg_meta.get("source", "")
385
+
386
+ if scope == "device" and part == "typehints":
387
+ out_path = os.path.join(StoreManager.pkg_root(), scope, target, part, target, relpath.replace("/", os.sep))
388
+ else:
389
+ out_path = os.path.join(StoreManager.pkg_root(), scope, target, part, relpath.replace("/", os.sep))
390
+
391
+ try:
392
+ os.makedirs(os.path.dirname(out_path), exist_ok=True)
393
+ InstallHelper.download_raw_file(owner, repo, ref, source, out_path)
394
+
395
+ if part == "typehints":
396
+ submodules = pkg_meta.get("submodules_typehints", [])
397
+ else:
398
+ submodules = pkg_meta.get("submodules", [])
399
+
400
+ for submodule_path in submodules:
401
+ relpath_dir = os.path.dirname(relpath) if "/" in relpath or "\\" in relpath else ""
402
+ submodule_filename = os.path.basename(submodule_path)
403
+ sub_relpath = os.path.join(relpath_dir, submodule_filename) if relpath_dir else submodule_filename
404
+
405
+ if scope == "device" and part == "typehints":
406
+ sub_out_path = os.path.join(StoreManager.pkg_root(), scope, target, part, target, sub_relpath.replace("/", os.sep))
407
+ else:
408
+ sub_out_path = os.path.join(StoreManager.pkg_root(), scope, target, part, sub_relpath.replace("/", os.sep))
409
+
410
+ os.makedirs(os.path.dirname(sub_out_path), exist_ok=True)
411
+ InstallHelper.download_raw_file(owner, repo, ref, submodule_path, sub_out_path)
412
+
413
+ unique_pkg_name = f"{scope}:{target}:{orig_pkg_name}"
414
+ source_key = source.replace("\\", "/") if source else ""
415
+ if source_key:
416
+ _local_touch_package(f"{unique_pkg_name}@{source_key}", orig_pkg_meta)
417
+ else:
418
+ _local_touch_package(unique_pkg_name, orig_pkg_meta)
419
+ return (True, relpath, None)
420
+ except urllib.error.HTTPError:
421
+ url = f"https://raw.githubusercontent.com/{owner}/{repo}/{ref}/{source}"
422
+ return (False, relpath, f"404 Not Found - URL: {url}")
423
+ except OSError as e:
424
+ return (False, relpath, f"Local: {e}")
425
+ except Exception as e:
426
+ return (False, relpath, str(e))
427
+
428
+ default_workers = 4
429
+ max_workers = min(
430
+ int(os.environ.get("REPLX_DOWNLOAD_THREADS", str(default_workers))),
431
+ total,
432
+ 8
433
+ )
434
+
435
+ done = 0
436
+ with ThreadPoolExecutor(max_workers=max_workers) as executor:
437
+ future_to_task = {executor.submit(download_file, task): task for task in plan}
438
+
439
+ for future in as_completed(future_to_task):
440
+ success, relpath, error = future.result()
441
+ done += 1
442
+ if not success:
443
+ errors.append(f"{relpath}: {error}")
444
+ if progress:
445
+ progress({
446
+ "phase": "download",
447
+ "current": done,
448
+ "total": total,
449
+ "file": relpath,
450
+ "target": download_target,
451
+ })
452
+
453
+ if device_exists_remote and dev:
454
+ device_typehints_pkg_dir = os.path.join(
455
+ StoreManager.pkg_root(), "device", device_name_to_path(dev),
456
+ "typehints", device_name_to_path(dev))
457
+ init_pyi_path = os.path.join(device_typehints_pkg_dir, "__init__.pyi")
458
+
459
+ if os.path.isdir(device_typehints_pkg_dir) and not os.path.exists(init_pyi_path):
460
+ try:
461
+ with open(init_pyi_path, "w", encoding="utf-8") as f:
462
+ f.write("# Type hints for device package\n")
463
+ except Exception:
464
+ pass
465
+
466
+ StoreManager.save_local_meta(local)
467
+
468
+ return {
469
+ "core": core,
470
+ "device": dev,
471
+ "core_in_registry": True,
472
+ "device_in_registry": device_exists_remote,
473
+ "total": total,
474
+ "new": new_count,
475
+ "upd": upd_count,
476
+ "errors": errors,
477
+ "target": download_target,
478
+ }
479
+
480
+
481
+ def build_install_batch(spec: str, core: str, device: str, version: str,
482
+ progress: Optional[ProgressFn] = None) -> dict:
483
+ if not (spec.startswith("core.") or spec.startswith("device.")):
484
+ raise PkgError(f"Invalid spec format: {spec}")
485
+
486
+ scope, rest = InstallHelper.resolve_spec(spec)
487
+ base, local_list = InstallHelper.list_local_py_targets(scope, rest, core, device)
488
+ if not local_list:
489
+ raise PkgError("No local files to install. Run 'replx pkg download' first.")
490
+
491
+ total = len(local_list)
492
+ arch_tag = CompilerHelper.mpy_arch_tag(core)
493
+
494
+ batch_specs: list[tuple[str, str]] = []
495
+ unique_dirs: set[str] = set()
496
+
497
+ for idx, (abs_file, rel) in enumerate(local_list):
498
+ if progress:
499
+ progress({
500
+ "phase": "compile",
501
+ "current": idx,
502
+ "total": total,
503
+ "file": os.path.basename(rel),
504
+ })
505
+
506
+ rel_dir = os.path.dirname(rel)
507
+ is_python = abs_file.endswith(".py")
508
+
509
+ if rel.startswith("ext/") and "/" in rel[4:]:
510
+ parts = rel.split("/")
511
+ if len(parts) >= 3:
512
+ rel_dir = "ext"
513
+ remote_dir = InstallHelper.remote_dir_for(scope, rel_dir, device)
514
+
515
+ if is_python:
516
+ CompilerHelper.compile_to_staging(abs_file, base, core, version)
517
+ out_file = CompilerHelper.staging_out_for(abs_file, base, arch_tag)
518
+ remote_path = ("/" + remote_dir + os.path.splitext(parts[-1])[0] + ".mpy").replace("//", "/")
519
+ else:
520
+ out_file = abs_file
521
+ remote_path = ("/" + remote_dir + parts[-1]).replace("//", "/")
522
+
523
+ batch_specs.append((out_file, remote_path))
524
+ unique_dirs.add(remote_dir)
525
+ continue
526
+
527
+ remote_dir = InstallHelper.remote_dir_for(scope, rel_dir, device)
528
+
529
+ if is_python:
530
+ CompilerHelper.compile_to_staging(abs_file, base, core, version)
531
+ out_file = CompilerHelper.staging_out_for(abs_file, base, arch_tag)
532
+ remote_path = ("/" + remote_dir + os.path.splitext(os.path.basename(rel))[0] + ".mpy").replace("//", "/")
533
+ else:
534
+ out_file = abs_file
535
+ remote_path = ("/" + remote_dir + os.path.basename(rel)).replace("//", "/")
536
+
537
+ batch_specs.append((out_file, remote_path))
538
+
539
+ if remote_dir:
540
+ unique_dirs.add(remote_dir)
541
+
542
+ all_paths = set()
543
+ for remote_dir in sorted(unique_dirs):
544
+ parts = [p for p in remote_dir.replace("\\", "/").strip("/").split("/") if p]
545
+ path = ""
546
+ for p in parts:
547
+ path = path + "/" + p
548
+ all_paths.add(path)
549
+
550
+ file_sizes = [os.path.getsize(lp) if os.path.exists(lp) else 0 for lp, _ in batch_specs]
551
+
552
+ return {
553
+ "specs": batch_specs,
554
+ "sizes": file_sizes,
555
+ "dirs": sorted(all_paths),
556
+ "files": total,
557
+ "total_bytes": sum(file_sizes),
558
+ }
559
+
560
+
561
+ def clean(core: str, device: str) -> dict:
562
+ pkg_root = StoreManager.pkg_root()
563
+ device_key = device_name_to_path(device)
564
+ core_path = os.path.join(pkg_root, "core", core)
565
+ device_path = os.path.join(pkg_root, "device", device_key)
566
+ meta_path = StoreManager.local_meta_path()
567
+
568
+ core_exists = os.path.isdir(core_path)
569
+ device_exists = os.path.isdir(device_path)
570
+ meta_exists = os.path.isfile(meta_path)
571
+
572
+ if not core_exists and not device_exists:
573
+ return {"removed": [], "total_size": 0, "found": False}
574
+
575
+ def get_dir_size(path: str) -> int:
576
+ total = 0
577
+ for dirpath, _dirnames, filenames in os.walk(path):
578
+ for f in filenames:
579
+ fp = os.path.join(dirpath, f)
580
+ try:
581
+ total += os.path.getsize(fp)
582
+ except OSError:
583
+ pass
584
+ return total
585
+
586
+ removed_items = []
587
+ total_size = 0
588
+
589
+ with StoreManager.LOCK:
590
+ if core_exists:
591
+ size = get_dir_size(core_path)
592
+ shutil.rmtree(core_path)
593
+ removed_items.append({"item": f"core/{core}/", "size": size})
594
+ total_size += size
595
+
596
+ if device_exists:
597
+ size = get_dir_size(device_path)
598
+ shutil.rmtree(device_path)
599
+ removed_items.append({"item": f"device/{device}/", "size": size})
600
+ total_size += size
601
+
602
+ if meta_exists:
603
+ local_meta = StoreManager.load_local_meta()
604
+ local_packages = local_meta.get("packages", {})
605
+
606
+ core_prefix = f"core:{core}:"
607
+ device_prefix = f"device:{device_key}:"
608
+
609
+ keys_to_remove = [k for k in local_packages.keys()
610
+ if k.startswith(core_prefix) or k.startswith(device_prefix)]
611
+
612
+ entries_removed = 0
613
+ if keys_to_remove:
614
+ for key in keys_to_remove:
615
+ del local_packages[key]
616
+ entries_removed += len(keys_to_remove)
617
+
618
+ if core_exists and "platform_cores" in local_meta:
619
+ if core in local_meta["platform_cores"]:
620
+ del local_meta["platform_cores"][core]
621
+ entries_removed += 1
622
+
623
+ if device_exists and "device_configs" in local_meta:
624
+ if device_key in local_meta["device_configs"]:
625
+ del local_meta["device_configs"][device_key]
626
+ entries_removed += 1
627
+
628
+ if entries_removed > 0:
629
+ StoreManager.save_local_meta(local_meta)
630
+ removed_items.append({"item": "registry.json", "entries": entries_removed})
631
+
632
+ return {"removed": removed_items, "total_size": total_size, "found": True}
633
+
634
+
635
+ def store_status(core: str, device: str) -> dict:
636
+ StoreManager.ensure_home_store()
637
+ ready, reason = InstallHelper.local_store_ready_for_full_install(core, device)
638
+ return {
639
+ "ready": ready,
640
+ "reason": reason,
641
+ "meta": os.path.isfile(StoreManager.local_meta_path()),
642
+ "core_src": os.path.isdir(os.path.join(StoreManager.pkg_root(), "core", core, "src")),
643
+ "device_src": os.path.isdir(os.path.join(
644
+ StoreManager.pkg_root(), "device", device_name_to_path(device), "src")),
645
+ }
646
+
647
+
648
+ def _typehints_dir(scope: str, core: str, device: str) -> str:
649
+ if scope == "core":
650
+ return os.path.join(StoreManager.pkg_root(), "core", core, "typehints")
651
+ return os.path.join(StoreManager.pkg_root(), "device", device_name_to_path(device), "typehints")
652
+
653
+
654
+ def typehints_list(core: str, device: str) -> dict:
655
+ files = []
656
+ for scope in ("core", "device"):
657
+ base = _typehints_dir(scope, core, device)
658
+ if not os.path.isdir(base):
659
+ continue
660
+ for dp, _dirs, fns in os.walk(base):
661
+ for fn in fns:
662
+ ap = os.path.join(dp, fn)
663
+ rel = os.path.relpath(ap, base).replace("\\", "/")
664
+ digest = hashlib.sha256()
665
+ try:
666
+ with open(ap, "rb") as f:
667
+ for chunk in iter(lambda: f.read(65536), b""):
668
+ digest.update(chunk)
669
+ files.append({
670
+ "scope": scope,
671
+ "relpath": rel,
672
+ "size": os.path.getsize(ap),
673
+ "sha256": digest.hexdigest(),
674
+ })
675
+ except OSError:
676
+ continue
677
+ return {"core": core, "device": device, "files": files}
678
+
679
+
680
+ def typehints_read(scope: str, relpath: str, core: str, device: str) -> bytes:
681
+ if scope not in ("core", "device"):
682
+ raise PkgError(f"Invalid typehints scope: {scope}")
683
+ base = os.path.realpath(_typehints_dir(scope, core, device))
684
+ target = os.path.realpath(os.path.join(base, relpath.replace("/", os.sep)))
685
+ if not (target == base or target.startswith(base + os.sep)):
686
+ raise PkgError(f"Invalid typehints path: {relpath}")
687
+ if not os.path.isfile(target):
688
+ raise PkgError(f"Typehint file not found: {relpath}")
689
+ with open(target, "rb") as f:
690
+ return f.read()