paglets 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.
Files changed (84) hide show
  1. paglets/__init__.py +3 -0
  2. paglets/config/__init__.py +3 -0
  3. paglets/config/defaults/__init__.py +3 -0
  4. paglets/config/defaults/launch.toml +23 -0
  5. paglets/config/startup.py +431 -0
  6. paglets/core/__init__.py +3 -0
  7. paglets/core/agent.py +614 -0
  8. paglets/core/context_events.py +148 -0
  9. paglets/core/errors.py +59 -0
  10. paglets/core/events.py +46 -0
  11. paglets/core/itinerary.py +223 -0
  12. paglets/core/messages.py +229 -0
  13. paglets/core/runtime_values.py +55 -0
  14. paglets/core/wire.py +9 -0
  15. paglets/examples/__init__.py +3 -0
  16. paglets/examples/compute/__init__.py +42 -0
  17. paglets/examples/compute/agent.py +1279 -0
  18. paglets/examples/compute/chudnovsky.py +221 -0
  19. paglets/examples/compute/cli.py +261 -0
  20. paglets/examples/compute/models.py +111 -0
  21. paglets/examples/mesh_benchmark/__init__.py +59 -0
  22. paglets/examples/mesh_benchmark/agent.py +479 -0
  23. paglets/examples/mesh_benchmark/analysis.py +304 -0
  24. paglets/examples/mesh_benchmark/cli.py +327 -0
  25. paglets/examples/mesh_benchmark/models.py +123 -0
  26. paglets/examples/mesh_info/__init__.py +43 -0
  27. paglets/examples/mesh_info/agent.py +466 -0
  28. paglets/examples/mesh_info/cli.py +197 -0
  29. paglets/examples/performance/__init__.py +36 -0
  30. paglets/examples/performance/agent.py +196 -0
  31. paglets/examples/performance/cli.py +290 -0
  32. paglets/examples/performance/kernels.py +549 -0
  33. paglets/examples/performance/models.py +98 -0
  34. paglets/examples/search/__init__.py +25 -0
  35. paglets/examples/search/agent.py +287 -0
  36. paglets/examples/search/cli.py +369 -0
  37. paglets/examples/search/local_search.py +555 -0
  38. paglets/examples/search/models.py +103 -0
  39. paglets/examples/system_info/__init__.py +47 -0
  40. paglets/examples/system_info/agent.py +503 -0
  41. paglets/examples/system_info/cli.py +215 -0
  42. paglets/persistence/__init__.py +3 -0
  43. paglets/persistence/persistency.py +131 -0
  44. paglets/persistence/storage.py +92 -0
  45. paglets/remote/__init__.py +3 -0
  46. paglets/remote/admin.py +457 -0
  47. paglets/remote/client.py +126 -0
  48. paglets/remote/mesh.py +625 -0
  49. paglets/remote/proxy.py +230 -0
  50. paglets/remote/references.py +36 -0
  51. paglets/remote/transfer.py +59 -0
  52. paglets/remote/transport.py +394 -0
  53. paglets/runtime/__init__.py +3 -0
  54. paglets/runtime/binding.py +61 -0
  55. paglets/runtime/child_bootstrap.py +227 -0
  56. paglets/runtime/child_calls.py +258 -0
  57. paglets/runtime/child_endpoint.py +121 -0
  58. paglets/runtime/child_facade.py +424 -0
  59. paglets/runtime/envelope.py +59 -0
  60. paglets/runtime/host.py +1142 -0
  61. paglets/runtime/http_api.py +298 -0
  62. paglets/runtime/inactive_records.py +180 -0
  63. paglets/runtime/lifecycle.py +552 -0
  64. paglets/runtime/mailbox.py +147 -0
  65. paglets/runtime/process_controller.py +343 -0
  66. paglets/runtime/process_protocol.py +163 -0
  67. paglets/runtime/process_runtime.py +12 -0
  68. paglets/runtime/relay.py +611 -0
  69. paglets/runtime/resident_services.py +420 -0
  70. paglets/runtime/resources.py +69 -0
  71. paglets/serialization/__init__.py +3 -0
  72. paglets/serialization/codec.py +191 -0
  73. paglets/services/__init__.py +3 -0
  74. paglets/services/contracts.py +390 -0
  75. paglets/services/resident.py +69 -0
  76. paglets/tooling/__init__.py +3 -0
  77. paglets/tooling/cli.py +332 -0
  78. paglets/tooling/discovery.py +168 -0
  79. paglets/tooling/git_update.py +493 -0
  80. paglets-0.1.0.dist-info/METADATA +163 -0
  81. paglets-0.1.0.dist-info/RECORD +84 -0
  82. paglets-0.1.0.dist-info/WHEEL +4 -0
  83. paglets-0.1.0.dist-info/entry_points.txt +8 -0
  84. paglets-0.1.0.dist-info/licenses/LICENSE +21 -0
paglets/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ # Copyright (c) 2026 by C. Klukas.
2
+ # Licensed under the MIT License. See LICENSE for details.
3
+ """Paglets mobile object runtime package."""
@@ -0,0 +1,3 @@
1
+ # Copyright (c) 2026 by C. Klukas.
2
+ # Licensed under the MIT License. See LICENSE for details.
3
+ """Startup and launch configuration support."""
@@ -0,0 +1,3 @@
1
+ # Copyright (c) 2026 by C. Klukas.
2
+ # Licensed under the MIT License. See LICENSE for details.
3
+ """Bundled default configuration files for paglets."""
@@ -0,0 +1,23 @@
1
+ [launch]
2
+ demo_config_id = "paglets-default-launch"
3
+ demo_config_version = "4"
4
+
5
+ [[resident_services]]
6
+ class = "paglets.examples.system_info.agent:ServerInfoAgent"
7
+ enabled = true
8
+ agent_id = "service.server-info"
9
+ singleton = true
10
+ lifecycle = "lazy"
11
+ scope = "mesh"
12
+ idle_timeout = 30.0
13
+ state = { service_scope = "mesh" }
14
+
15
+ [[resident_services]]
16
+ class = "paglets.examples.mesh_info.agent:MeshInfoAgent"
17
+ enabled = true
18
+ agent_id = "service.mesh-info"
19
+ singleton = true
20
+ lifecycle = "eager"
21
+ scope = "mesh"
22
+ idle_timeout = 0.0
23
+ state = { service_scope = "mesh" }
@@ -0,0 +1,431 @@
1
+ # Copyright (c) 2026 by C. Klukas.
2
+ # Licensed under the MIT License. See LICENSE for details.
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ import tomllib
7
+ from collections.abc import Callable
8
+ from dataclasses import dataclass, field
9
+ from datetime import datetime
10
+ from importlib import resources
11
+ from pathlib import Path
12
+ from typing import Any, TextIO
13
+
14
+ from paglets.core.errors import HostError, SerializationError
15
+ from paglets.core.runtime_values import (
16
+ LaunchConfigSyncAction,
17
+ ResidentLifecycle,
18
+ ServiceScope,
19
+ enum_from_wire,
20
+ require_enum,
21
+ )
22
+ from paglets.serialization.codec import dataclass_from_wire, resolve_qualified_name
23
+ from paglets.services.resident import ResidentServiceSpec
24
+
25
+ DEFAULT_LAUNCH_CONFIG_PATH = Path.home() / ".paglets" / "launch.toml"
26
+ DEFAULT_DEMO_CONFIG_ID = "paglets-default-launch"
27
+
28
+
29
+ @dataclass(frozen=True, slots=True)
30
+ class AutoStartSpec:
31
+ """Class-level marker for agents that can be started from launch config."""
32
+
33
+ alias: str
34
+ agent_id: str | None = None
35
+ singleton: bool = True
36
+ state: dict[str, Any] = field(default_factory=dict)
37
+
38
+
39
+ @dataclass(frozen=True, slots=True)
40
+ class StartupAgentConfig:
41
+ """One launch-config entry describing an agent to start."""
42
+
43
+ use: str | None = None
44
+ class_name: str | None = None
45
+ enabled: bool = True
46
+ agent_id: str | None = None
47
+ singleton: bool = True
48
+ state: dict[str, Any] = field(default_factory=dict)
49
+ init: Any = None
50
+
51
+
52
+ @dataclass(frozen=True, slots=True)
53
+ class ResidentServiceConfig:
54
+ """One launch-config entry describing a managed resident service."""
55
+
56
+ use: str | None = None
57
+ class_name: str | None = None
58
+ service_name: str | None = None
59
+ enabled: bool = True
60
+ agent_id: str | None = None
61
+ singleton: bool = True
62
+ lifecycle: ResidentLifecycle | None = None
63
+ scope: ServiceScope | None = None
64
+ idle_timeout: float | None = None
65
+ state: dict[str, Any] = field(default_factory=dict)
66
+ init: Any = None
67
+
68
+ def __post_init__(self) -> None:
69
+ if self.lifecycle is not None:
70
+ require_enum(self.lifecycle, ResidentLifecycle, "lifecycle")
71
+ if self.scope is not None:
72
+ require_enum(self.scope, ServiceScope, "scope")
73
+
74
+
75
+ @dataclass(frozen=True, slots=True)
76
+ class LaunchConfig:
77
+ """Parsed paglets launch config."""
78
+
79
+ path: Path | None = None
80
+ demo_config_id: str | None = None
81
+ demo_config_version: str | None = None
82
+ sync_demo_config: bool = True
83
+ startup_agents: tuple[StartupAgentConfig, ...] = ()
84
+ resident_services: tuple[ResidentServiceConfig, ...] = ()
85
+
86
+
87
+ @dataclass(frozen=True, slots=True)
88
+ class LaunchConfigSyncResult:
89
+ """Result of syncing the bundled demo launch config to the user path."""
90
+
91
+ action: LaunchConfigSyncAction
92
+ path: Path
93
+ message: str
94
+ backup_path: Path | None = None
95
+
96
+ def __post_init__(self) -> None:
97
+ require_enum(self.action, LaunchConfigSyncAction, "action")
98
+
99
+
100
+ @dataclass(frozen=True, slots=True)
101
+ class ResolvedStartupAgent:
102
+ agent_cls: type[Any]
103
+ state: Any
104
+ agent_id: str | None
105
+ singleton: bool
106
+ init: Any
107
+
108
+
109
+ @dataclass(frozen=True, slots=True)
110
+ class ResolvedResidentService:
111
+ agent_cls: type[Any]
112
+ state: Any
113
+ agent_id: str
114
+ singleton: bool
115
+ init: Any
116
+ spec: ResidentServiceSpec
117
+ lifecycle: ResidentLifecycle
118
+ scope: ServiceScope
119
+ idle_timeout: float
120
+
121
+
122
+ def bundled_launch_config_text() -> str:
123
+ return resources.files("paglets.config.defaults").joinpath("launch.toml").read_text(encoding="utf-8")
124
+
125
+
126
+ def load_launch_config(path: Path | str = DEFAULT_LAUNCH_CONFIG_PATH) -> LaunchConfig:
127
+ config_path = Path(path).expanduser()
128
+ if not config_path.exists():
129
+ return LaunchConfig(path=config_path)
130
+ payload = _load_toml(config_path.read_text(encoding="utf-8"), config_path)
131
+ return _launch_config_from_payload(payload, config_path)
132
+
133
+
134
+ def sync_launch_config(
135
+ path: Path | str = DEFAULT_LAUNCH_CONFIG_PATH,
136
+ *,
137
+ enabled: bool = True,
138
+ yes: bool = False,
139
+ interactive: bool | None = None,
140
+ input_func: Callable[[str], str] = input,
141
+ output: TextIO | None = None,
142
+ ) -> LaunchConfigSyncResult:
143
+ config_path = Path(path).expanduser()
144
+ out = output or sys.stderr
145
+ bundled_text = bundled_launch_config_text()
146
+ bundled_payload = _load_toml(bundled_text, None)
147
+ bundled_launch = _launch_table(bundled_payload)
148
+ bundled_id = str(bundled_launch.get("demo_config_id") or DEFAULT_DEMO_CONFIG_ID)
149
+ bundled_version = str(bundled_launch.get("demo_config_version") or "")
150
+
151
+ if not enabled:
152
+ return LaunchConfigSyncResult(LaunchConfigSyncAction.SKIPPED, config_path, "launch config sync disabled")
153
+
154
+ if not config_path.exists():
155
+ _write_launch_config(config_path, bundled_text)
156
+ return LaunchConfigSyncResult(
157
+ LaunchConfigSyncAction.COPIED,
158
+ config_path,
159
+ f"copied bundled launch config to {config_path}",
160
+ )
161
+
162
+ current_text = config_path.read_text(encoding="utf-8")
163
+ current_payload = _load_toml(current_text, config_path)
164
+ current_launch = _launch_table(current_payload)
165
+ if not bool(current_launch.get("sync_demo_config", True)):
166
+ return LaunchConfigSyncResult(
167
+ LaunchConfigSyncAction.SKIPPED,
168
+ config_path,
169
+ "launch config disables bundled demo sync",
170
+ )
171
+
172
+ current_id = str(current_launch.get("demo_config_id") or "")
173
+ current_version = str(current_launch.get("demo_config_version") or "")
174
+ if current_id == bundled_id and current_version == bundled_version:
175
+ return LaunchConfigSyncResult(LaunchConfigSyncAction.UNCHANGED, config_path, "launch config is up to date")
176
+
177
+ if yes:
178
+ backup_path = _replace_launch_config(config_path, bundled_text)
179
+ return LaunchConfigSyncResult(
180
+ LaunchConfigSyncAction.UPDATED,
181
+ config_path,
182
+ f"updated launch config from bundled demo at {config_path}",
183
+ backup_path,
184
+ )
185
+
186
+ if interactive is None:
187
+ interactive = sys.stdin.isatty()
188
+ if not interactive:
189
+ update_hint = (
190
+ f"keeping existing {config_path}. Run paglets-host with --yes to update "
191
+ "or --no-sync-launch-config to suppress."
192
+ )
193
+ print(
194
+ f"paglets host warning: bundled launch config {bundled_id} version {bundled_version} is available; "
195
+ f"{update_hint}",
196
+ file=out,
197
+ flush=True,
198
+ )
199
+ return LaunchConfigSyncResult(
200
+ LaunchConfigSyncAction.UPDATE_AVAILABLE,
201
+ config_path,
202
+ "bundled launch config update available",
203
+ )
204
+
205
+ answer = (
206
+ input_func(
207
+ f"Bundled paglets launch config {bundled_id} version {bundled_version} is available. "
208
+ f"Replace {config_path} and move the old file aside? [y/N] "
209
+ )
210
+ .strip()
211
+ .lower()
212
+ )
213
+ if answer not in {"y", "yes"}:
214
+ return LaunchConfigSyncResult(LaunchConfigSyncAction.SKIPPED, config_path, "launch config update declined")
215
+
216
+ backup_path = _replace_launch_config(config_path, bundled_text)
217
+ return LaunchConfigSyncResult(
218
+ LaunchConfigSyncAction.UPDATED,
219
+ config_path,
220
+ f"updated launch config from bundled demo at {config_path}",
221
+ backup_path,
222
+ )
223
+
224
+
225
+ def resolve_startup_agent(config: StartupAgentConfig) -> ResolvedStartupAgent:
226
+ from paglets.core.agent import Paglet
227
+
228
+ agent_cls: type[Paglet[Any]]
229
+ spec: AutoStartSpec | None = None
230
+ if config.use:
231
+ raise HostError(f"Unknown startup agent alias {config.use!r}; use 'class' for importable paglet classes")
232
+ elif config.class_name:
233
+ resolved = resolve_qualified_name(config.class_name)
234
+ if not isinstance(resolved, type) or not issubclass(resolved, Paglet):
235
+ raise HostError(f"{config.class_name!r} is not a Paglet class")
236
+ agent_cls = resolved
237
+ maybe_spec = getattr(agent_cls, "AUTO_START", None)
238
+ spec = maybe_spec if isinstance(maybe_spec, AutoStartSpec) else None
239
+ else:
240
+ raise HostError("startup agent entry must set 'use' or 'class'")
241
+
242
+ state_payload: dict[str, Any] = {}
243
+ if spec is not None:
244
+ state_payload.update(spec.state)
245
+ state_payload.update(config.state)
246
+ state_cls = agent_cls.state_class()
247
+ try:
248
+ state = dataclass_from_wire(state_cls, state_payload)
249
+ except SerializationError as exc:
250
+ raise HostError(f"Could not build state for startup agent {agent_cls.__name__}") from exc
251
+ agent_id = config.agent_id or (spec.agent_id if spec is not None else None)
252
+ return ResolvedStartupAgent(
253
+ agent_cls=agent_cls,
254
+ state=state,
255
+ agent_id=agent_id,
256
+ singleton=config.singleton,
257
+ init=config.init,
258
+ )
259
+
260
+
261
+ def resolve_resident_service(config: ResidentServiceConfig) -> ResolvedResidentService:
262
+ from paglets.core.agent import Paglet
263
+
264
+ if config.use:
265
+ raise HostError(f"Unknown resident service alias {config.use!r}; use 'class' for importable paglet classes")
266
+ if not config.class_name:
267
+ raise HostError("resident service entry must set 'class'")
268
+ resolved = resolve_qualified_name(config.class_name)
269
+ if not isinstance(resolved, type) or not issubclass(resolved, Paglet):
270
+ raise HostError(f"{config.class_name!r} is not a Paglet class")
271
+ agent_cls: type[Paglet[Any]] = resolved
272
+ spec = _select_resident_service_spec(agent_cls, config)
273
+
274
+ state_payload = dict(spec.state)
275
+ state_payload.update(config.state)
276
+ state_cls = agent_cls.state_class()
277
+ try:
278
+ state = dataclass_from_wire(state_cls, state_payload)
279
+ except SerializationError as exc:
280
+ raise HostError(f"Could not build state for resident service {agent_cls.__name__}") from exc
281
+
282
+ lifecycle = config.lifecycle or spec.lifecycle
283
+ require_enum(lifecycle, ResidentLifecycle, "lifecycle")
284
+ scope = config.scope or spec.scope
285
+ require_enum(scope, ServiceScope, "scope")
286
+ idle_timeout = spec.idle_timeout if config.idle_timeout is None else config.idle_timeout
287
+ if idle_timeout < 0:
288
+ raise HostError("resident service idle_timeout must be non-negative")
289
+
290
+ agent_id = config.agent_id or spec.agent_id or f"service.{spec.contract.name}"
291
+ if not agent_id:
292
+ raise HostError("resident service requires an agent_id")
293
+ return ResolvedResidentService(
294
+ agent_cls=agent_cls,
295
+ state=state,
296
+ agent_id=agent_id,
297
+ singleton=config.singleton and spec.singleton,
298
+ init=config.init,
299
+ spec=spec,
300
+ lifecycle=lifecycle,
301
+ scope=scope,
302
+ idle_timeout=float(idle_timeout),
303
+ )
304
+
305
+
306
+ def _launch_config_from_payload(payload: dict[str, Any], path: Path) -> LaunchConfig:
307
+ launch = _launch_table(payload)
308
+ raw_agents = payload.get("startup_agents", [])
309
+ if not isinstance(raw_agents, list):
310
+ raise HostError(f"{path} startup_agents must be a list")
311
+ raw_resident_services = payload.get("resident_services", [])
312
+ if not isinstance(raw_resident_services, list):
313
+ raise HostError(f"{path} resident_services must be a list")
314
+ return LaunchConfig(
315
+ path=path,
316
+ demo_config_id=str(launch["demo_config_id"]) if launch.get("demo_config_id") is not None else None,
317
+ demo_config_version=str(launch["demo_config_version"])
318
+ if launch.get("demo_config_version") is not None
319
+ else None,
320
+ sync_demo_config=bool(launch.get("sync_demo_config", True)),
321
+ startup_agents=tuple(_startup_agent_from_payload(item, path) for item in raw_agents),
322
+ resident_services=tuple(_resident_service_from_payload(item, path) for item in raw_resident_services),
323
+ )
324
+
325
+
326
+ def _startup_agent_from_payload(payload: Any, path: Path) -> StartupAgentConfig:
327
+ if not isinstance(payload, dict):
328
+ raise HostError(f"{path} startup_agents entries must be tables")
329
+ state = payload.get("state", {})
330
+ if not isinstance(state, dict):
331
+ raise HostError(f"{path} startup agent state must be an inline table or table")
332
+ class_name = payload.get("class")
333
+ return StartupAgentConfig(
334
+ use=str(payload["use"]) if payload.get("use") is not None else None,
335
+ class_name=str(class_name) if class_name is not None else None,
336
+ enabled=bool(payload.get("enabled", True)),
337
+ agent_id=str(payload["agent_id"]) if payload.get("agent_id") is not None else None,
338
+ singleton=bool(payload.get("singleton", True)),
339
+ state=dict(state),
340
+ init=payload.get("init"),
341
+ )
342
+
343
+
344
+ def _resident_service_from_payload(payload: Any, path: Path) -> ResidentServiceConfig:
345
+ if not isinstance(payload, dict):
346
+ raise HostError(f"{path} resident_services entries must be tables")
347
+ state = payload.get("state", {})
348
+ if not isinstance(state, dict):
349
+ raise HostError(f"{path} resident service state must be an inline table or table")
350
+ lifecycle = payload.get("lifecycle")
351
+ scope = payload.get("scope")
352
+ idle_timeout = payload.get("idle_timeout")
353
+ class_name = payload.get("class")
354
+ service_name = payload.get("service")
355
+ return ResidentServiceConfig(
356
+ use=str(payload["use"]) if payload.get("use") is not None else None,
357
+ class_name=str(class_name) if class_name is not None else None,
358
+ service_name=str(service_name) if service_name is not None else None,
359
+ enabled=bool(payload.get("enabled", True)),
360
+ agent_id=str(payload["agent_id"]) if payload.get("agent_id") is not None else None,
361
+ singleton=bool(payload.get("singleton", True)),
362
+ lifecycle=(
363
+ _enum_from_config(lifecycle, ResidentLifecycle, "lifecycle", path) if lifecycle is not None else None
364
+ ),
365
+ scope=_enum_from_config(scope, ServiceScope, "scope", path) if scope is not None else None,
366
+ idle_timeout=float(idle_timeout) if idle_timeout is not None else None,
367
+ state=dict(state),
368
+ init=payload.get("init"),
369
+ )
370
+
371
+
372
+ def _select_resident_service_spec(agent_cls: type[Any], config: ResidentServiceConfig) -> ResidentServiceSpec:
373
+ raw_specs = getattr(agent_cls, "RESIDENT_SERVICES", ())
374
+ specs = tuple(spec for spec in raw_specs if isinstance(spec, ResidentServiceSpec))
375
+ if not specs:
376
+ raise HostError(f"{agent_cls.__name__} must declare RESIDENT_SERVICES for resident service launch config")
377
+ if config.service_name is not None:
378
+ for spec in specs:
379
+ if spec.contract.name == config.service_name:
380
+ return spec
381
+ raise HostError(f"{agent_cls.__name__} does not declare resident service {config.service_name!r}")
382
+ if len(specs) != 1:
383
+ raise HostError(f"{agent_cls.__name__} declares multiple resident services; set service in launch config")
384
+ return specs[0]
385
+
386
+
387
+ def _enum_from_config(value: Any, enum_cls: type[Any], field_name: str, path: Path) -> Any:
388
+ try:
389
+ return enum_from_wire(value, enum_cls, field_name)
390
+ except (TypeError, ValueError) as exc:
391
+ raise HostError(f"{path} resident service {field_name}: {exc}") from exc
392
+
393
+
394
+ def _load_toml(text: str, path: Path | None) -> dict[str, Any]:
395
+ try:
396
+ payload = tomllib.loads(text)
397
+ except tomllib.TOMLDecodeError as exc:
398
+ location = f"{path}: " if path is not None else ""
399
+ raise HostError(f"{location}invalid launch config TOML: {exc}") from exc
400
+ if not isinstance(payload, dict):
401
+ raise HostError(f"{path or 'bundled launch config'} must be a TOML table")
402
+ return payload
403
+
404
+
405
+ def _launch_table(payload: dict[str, Any]) -> dict[str, Any]:
406
+ launch = payload.get("launch", {})
407
+ if launch is None:
408
+ return {}
409
+ if not isinstance(launch, dict):
410
+ raise HostError("launch config [launch] must be a table")
411
+ return launch
412
+
413
+
414
+ def _write_launch_config(path: Path, text: str) -> None:
415
+ path.parent.mkdir(parents=True, exist_ok=True)
416
+ path.write_text(text, encoding="utf-8")
417
+
418
+
419
+ def _replace_launch_config(path: Path, text: str) -> Path:
420
+ backup_path = _backup_path(path)
421
+ path.replace(backup_path)
422
+ _write_launch_config(path, text)
423
+ return backup_path
424
+
425
+
426
+ def _backup_path(path: Path) -> Path:
427
+ candidate = path.with_name(f"{path.name}.old")
428
+ if not candidate.exists():
429
+ return candidate
430
+ stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
431
+ return path.with_name(f"{path.name}.old-{stamp}")
@@ -0,0 +1,3 @@
1
+ # Copyright (c) 2026 by C. Klukas.
2
+ # Licensed under the MIT License. See LICENSE for details.
3
+ """Core paglet authoring primitives."""