devlaunch 0.0.16__tar.gz → 0.0.18__tar.gz

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 (27) hide show
  1. {devlaunch-0.0.16 → devlaunch-0.0.18}/.gitignore +0 -1
  2. {devlaunch-0.0.16 → devlaunch-0.0.18}/PKG-INFO +86 -1
  3. {devlaunch-0.0.16 → devlaunch-0.0.18}/README.md +85 -0
  4. {devlaunch-0.0.16 → devlaunch-0.0.18}/devlaunch/devpod_provider.py +29 -6
  5. {devlaunch-0.0.16 → devlaunch-0.0.18}/devlaunch/dl.py +105 -17
  6. {devlaunch-0.0.16 → devlaunch-0.0.18}/pyproject.toml +22 -3
  7. {devlaunch-0.0.16 → devlaunch-0.0.18}/LICENSE +0 -0
  8. {devlaunch-0.0.16 → devlaunch-0.0.18}/devlaunch/__init__.py +0 -0
  9. {devlaunch-0.0.16 → devlaunch-0.0.18}/devlaunch/aid.py +0 -0
  10. {devlaunch-0.0.16 → devlaunch-0.0.18}/devlaunch/completion.py +0 -0
  11. {devlaunch-0.0.16 → devlaunch-0.0.18}/devlaunch/completion_loader.py +0 -0
  12. {devlaunch-0.0.16 → devlaunch-0.0.18}/devlaunch/completions/__init__.py +0 -0
  13. {devlaunch-0.0.16 → devlaunch-0.0.18}/devlaunch/completions/dl.bash +0 -0
  14. {devlaunch-0.0.16 → devlaunch-0.0.18}/devlaunch/devpod_ssh.py +0 -0
  15. {devlaunch-0.0.16 → devlaunch-0.0.18}/devlaunch/gh_auth.py +0 -0
  16. {devlaunch-0.0.16 → devlaunch-0.0.18}/devlaunch/tools.py +0 -0
  17. {devlaunch-0.0.16 → devlaunch-0.0.18}/devlaunch/tty_session.py +0 -0
  18. {devlaunch-0.0.16 → devlaunch-0.0.18}/devlaunch/workspace_id.py +0 -0
  19. {devlaunch-0.0.16 → devlaunch-0.0.18}/devlaunch/worktree/__init__.py +0 -0
  20. {devlaunch-0.0.16 → devlaunch-0.0.18}/devlaunch/worktree/branch_manager.py +0 -0
  21. {devlaunch-0.0.16 → devlaunch-0.0.18}/devlaunch/worktree/config.py +0 -0
  22. {devlaunch-0.0.16 → devlaunch-0.0.18}/devlaunch/worktree/migration.py +0 -0
  23. {devlaunch-0.0.16 → devlaunch-0.0.18}/devlaunch/worktree/models.py +0 -0
  24. {devlaunch-0.0.16 → devlaunch-0.0.18}/devlaunch/worktree/repo_manager.py +0 -0
  25. {devlaunch-0.0.16 → devlaunch-0.0.18}/devlaunch/worktree/storage.py +0 -0
  26. {devlaunch-0.0.16 → devlaunch-0.0.18}/devlaunch/worktree/workspace_clone.py +0 -0
  27. {devlaunch-0.0.16 → devlaunch-0.0.18}/devlaunch/xdg.py +0 -0
@@ -50,7 +50,6 @@ coverage.xml
50
50
  .hypothesis/
51
51
  .pytest_cache/
52
52
  cover/
53
- test/docker/results/
54
53
 
55
54
  # Translations
56
55
  *.mo
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devlaunch
3
- Version: 0.0.16
3
+ Version: 0.0.18
4
4
  Summary: DevLaunch - A streamlined CLI for devpod workspaces
5
5
  Project-URL: Source, https://github.com/blooop/devlaunch
6
6
  Project-URL: Home, https://github.com/blooop/devlaunch
@@ -57,6 +57,13 @@ Note: When using pip, you must install [devpod](https://devpod.sh/docs/getting-s
57
57
  If `devpod` is not on `PATH`, every command that needs it prints a single install hint on stderr and exits `127`
58
58
  (the shell's "command not found" code). `dl --help` and `dl --version` keep working without it.
59
59
 
60
+ A `devpod` that is installed but cannot answer is a different failure and gets a different exit code. If
61
+ `devpod list` exits non-zero, or prints something that is not a `--output json` workspace listing, `dl` quotes
62
+ what devpod said on stderr and exits `1` rather than reporting that you have no workspaces — so `dl --purge`
63
+ stops instead of deleting caches it never checked. Shell completion is the deliberate exception: `dl --install`,
64
+ `dl --refresh` and `dl --completion-data` log the failure and carry on with the repos and branches they can
65
+ still discover on local disk, so an unreachable devpod costs you workspace-name completion and nothing more.
66
+
60
67
  ### Shell Completions
61
68
 
62
69
  After installation, set up shell completions for `dl` and `aid`:
@@ -432,9 +439,87 @@ This project uses [pixi](https://pixi.sh) for environment management.
432
439
  # Run tests
433
440
  pixi run test
434
441
 
442
+ # Run the e2e suite: real devpod, real containers
443
+ pixi run test-e2e
444
+
435
445
  # Run full CI suite
436
446
  pixi run ci
437
447
 
438
448
  # Format and lint
439
449
  pixi run style
440
450
  ```
451
+
452
+ `pixi run test` skips the e2e tests, which need devpod and a Docker daemon and
453
+ build real containers. CI runs `pixi run test-e2e` in a job of its own, outside
454
+ the Python matrix, on a throwaway runner — on every push to `main` and on every
455
+ pull request that targets `main`. A pull request onto any other base runs no CI
456
+ at all, e2e included, because the whole workflow is triggered by
457
+ `pull_request: branches: [main]`; that gap covers every job here rather than
458
+ this one, and stacked PRs are what walk into it.
459
+
460
+ Running it yourself is a different proposition. This repo's devcontainer carries
461
+ a Docker daemon of its own, through the `docker-in-docker` feature, and pins the
462
+ same devpod a host installs, so `pixi run test-e2e` from inside it builds its
463
+ containers in there rather than on your Docker. You can also run it on a machine
464
+ you do not mind it writing to — an ephemeral CI runner, say. It is skipped by
465
+ default rather than gated on a container, because what it needs is a daemon, not
466
+ nesting. Either way the suite exercises `dl --purge`, so it gives itself a
467
+ private devpod namespace before collection begins — but the containers it builds
468
+ are real ones, and it wants several minutes and a 1.25 GB image pull the first
469
+ time.
470
+
471
+ Its skips mean one thing only. A test that opts out does so through
472
+ `fixtures.e2e_guard.opt_out`, and any other skip is reported as a failure,
473
+ because a run that could not reach a registry used to be indistinguishable from
474
+ a healthy one. Every run also prints what it actually built:
475
+
476
+ ```
477
+ --------------------------------- e2e session ---------------------------------
478
+ 22 e2e tests attempted, 4 workspaces created: e2e-test-create, e2e-test-lifecycle, e2e-test-git, e2e-test-purge
479
+ ```
480
+
481
+ A run whose workspace-building tests built nothing does not pass: the shortfall
482
+ is counted into the last line of the run, so `4 passed, 18 skipped` becomes
483
+ `1 failed, 4 passed, 18 skipped`. A run with no workspace-building tests in it —
484
+ `pytest -m e2e test/e2e/test_interactive_session.py`, say — has nothing to
485
+ answer for and says so instead.
486
+
487
+ `DEVLAUNCH_E2E_WORKSPACE=<id>` opts in to the interactive-session tests, which
488
+ attach to a workspace you already have running rather than building one.
489
+
490
+ The nested daemon is also why the devcontainer does not join the host's network
491
+ namespace: a nested daemon needs a namespace of its own, or it co-manages the
492
+ host's `docker0` bridge and writes its NAT rules into the host's netfilter
493
+ tables.
494
+
495
+ ### Disk cost of the dev container
496
+
497
+ Opening a devcontainer for a branch costs about **2 GB on the host before you do
498
+ anything in it**: ~600 MB of image layers unique to this image, a ~680 MB container
499
+ writable layer, and a ~520 MB `<workspace>-pixi` volume.
500
+
501
+ The container carries its own Docker daemon, and that daemon's `/var/lib/docker`
502
+ lives on a second named volume. One `pixi run test-e2e` plus a couple of nested
503
+ workspaces puts **~2.3 GB** in there, and nothing garbage-collects it — the inner
504
+ daemon reports ~45% of its images reclaimable with no reclaimer. Nested daemons
505
+ share no layers with the host or with each other, so this is paid once per branch.
506
+
507
+ **Budget ~4 GB per branch you are actively developing and e2e-testing — about 12 GB
508
+ for three concurrent branches.**
509
+
510
+ The time cost is cold pulls in a fresh nested daemon: the first `devpod up` inside a
511
+ new container takes ~25s, ~16s of which is pulling a base image the host already has.
512
+ Workspaces after that reuse it and take ~8s.
513
+
514
+ **These volumes are not reclaimed automatically.** `devpod delete` removes the
515
+ container with `docker rm` and never touches volumes, and Docker never
516
+ garbage-collects a *named* volume — so `<workspace>-pixi` and
517
+ `dind-var-lib-docker-*` outlive the workspace that created them. To see what has
518
+ piled up:
519
+
520
+ ```bash
521
+ docker system df -v # under Local Volumes, LINKS 0 means no container uses it
522
+ ```
523
+
524
+ Cross-check a name against `devpod list` before removing it with `docker volume rm`:
525
+ a volume belonging to a live workspace shows `LINKS 1`.
@@ -35,6 +35,13 @@ Note: When using pip, you must install [devpod](https://devpod.sh/docs/getting-s
35
35
  If `devpod` is not on `PATH`, every command that needs it prints a single install hint on stderr and exits `127`
36
36
  (the shell's "command not found" code). `dl --help` and `dl --version` keep working without it.
37
37
 
38
+ A `devpod` that is installed but cannot answer is a different failure and gets a different exit code. If
39
+ `devpod list` exits non-zero, or prints something that is not a `--output json` workspace listing, `dl` quotes
40
+ what devpod said on stderr and exits `1` rather than reporting that you have no workspaces — so `dl --purge`
41
+ stops instead of deleting caches it never checked. Shell completion is the deliberate exception: `dl --install`,
42
+ `dl --refresh` and `dl --completion-data` log the failure and carry on with the repos and branches they can
43
+ still discover on local disk, so an unreachable devpod costs you workspace-name completion and nothing more.
44
+
38
45
  ### Shell Completions
39
46
 
40
47
  After installation, set up shell completions for `dl` and `aid`:
@@ -410,9 +417,87 @@ This project uses [pixi](https://pixi.sh) for environment management.
410
417
  # Run tests
411
418
  pixi run test
412
419
 
420
+ # Run the e2e suite: real devpod, real containers
421
+ pixi run test-e2e
422
+
413
423
  # Run full CI suite
414
424
  pixi run ci
415
425
 
416
426
  # Format and lint
417
427
  pixi run style
418
428
  ```
429
+
430
+ `pixi run test` skips the e2e tests, which need devpod and a Docker daemon and
431
+ build real containers. CI runs `pixi run test-e2e` in a job of its own, outside
432
+ the Python matrix, on a throwaway runner — on every push to `main` and on every
433
+ pull request that targets `main`. A pull request onto any other base runs no CI
434
+ at all, e2e included, because the whole workflow is triggered by
435
+ `pull_request: branches: [main]`; that gap covers every job here rather than
436
+ this one, and stacked PRs are what walk into it.
437
+
438
+ Running it yourself is a different proposition. This repo's devcontainer carries
439
+ a Docker daemon of its own, through the `docker-in-docker` feature, and pins the
440
+ same devpod a host installs, so `pixi run test-e2e` from inside it builds its
441
+ containers in there rather than on your Docker. You can also run it on a machine
442
+ you do not mind it writing to — an ephemeral CI runner, say. It is skipped by
443
+ default rather than gated on a container, because what it needs is a daemon, not
444
+ nesting. Either way the suite exercises `dl --purge`, so it gives itself a
445
+ private devpod namespace before collection begins — but the containers it builds
446
+ are real ones, and it wants several minutes and a 1.25 GB image pull the first
447
+ time.
448
+
449
+ Its skips mean one thing only. A test that opts out does so through
450
+ `fixtures.e2e_guard.opt_out`, and any other skip is reported as a failure,
451
+ because a run that could not reach a registry used to be indistinguishable from
452
+ a healthy one. Every run also prints what it actually built:
453
+
454
+ ```
455
+ --------------------------------- e2e session ---------------------------------
456
+ 22 e2e tests attempted, 4 workspaces created: e2e-test-create, e2e-test-lifecycle, e2e-test-git, e2e-test-purge
457
+ ```
458
+
459
+ A run whose workspace-building tests built nothing does not pass: the shortfall
460
+ is counted into the last line of the run, so `4 passed, 18 skipped` becomes
461
+ `1 failed, 4 passed, 18 skipped`. A run with no workspace-building tests in it —
462
+ `pytest -m e2e test/e2e/test_interactive_session.py`, say — has nothing to
463
+ answer for and says so instead.
464
+
465
+ `DEVLAUNCH_E2E_WORKSPACE=<id>` opts in to the interactive-session tests, which
466
+ attach to a workspace you already have running rather than building one.
467
+
468
+ The nested daemon is also why the devcontainer does not join the host's network
469
+ namespace: a nested daemon needs a namespace of its own, or it co-manages the
470
+ host's `docker0` bridge and writes its NAT rules into the host's netfilter
471
+ tables.
472
+
473
+ ### Disk cost of the dev container
474
+
475
+ Opening a devcontainer for a branch costs about **2 GB on the host before you do
476
+ anything in it**: ~600 MB of image layers unique to this image, a ~680 MB container
477
+ writable layer, and a ~520 MB `<workspace>-pixi` volume.
478
+
479
+ The container carries its own Docker daemon, and that daemon's `/var/lib/docker`
480
+ lives on a second named volume. One `pixi run test-e2e` plus a couple of nested
481
+ workspaces puts **~2.3 GB** in there, and nothing garbage-collects it — the inner
482
+ daemon reports ~45% of its images reclaimable with no reclaimer. Nested daemons
483
+ share no layers with the host or with each other, so this is paid once per branch.
484
+
485
+ **Budget ~4 GB per branch you are actively developing and e2e-testing — about 12 GB
486
+ for three concurrent branches.**
487
+
488
+ The time cost is cold pulls in a fresh nested daemon: the first `devpod up` inside a
489
+ new container takes ~25s, ~16s of which is pulling a base image the host already has.
490
+ Workspaces after that reuse it and take ~8s.
491
+
492
+ **These volumes are not reclaimed automatically.** `devpod delete` removes the
493
+ container with `docker rm` and never touches volumes, and Docker never
494
+ garbage-collects a *named* volume — so `<workspace>-pixi` and
495
+ `dind-var-lib-docker-*` outlive the workspace that created them. To see what has
496
+ piled up:
497
+
498
+ ```bash
499
+ docker system df -v # under Local Volumes, LINKS 0 means no container uses it
500
+ ```
501
+
502
+ Cross-check a name against `devpod list` before removing it with `docker volume rm`:
503
+ a volume belonging to a live workspace shows `LINKS 1`.
@@ -27,11 +27,25 @@ import subprocess
27
27
  from typing import Callable, Optional, Sequence, Set
28
28
 
29
29
 
30
- class UnreadableProviderList(RuntimeError):
30
+ class UnreadableProviderList(Exception):
31
31
  """devpod's provider listing could not be read.
32
32
 
33
33
  Distinct from "no providers are registered", which is a listing that reads
34
34
  fine and is empty.
35
+
36
+ Not a RuntimeError. It used to be, and the CLI below then had to name
37
+ RuntimeError as well to catch the add failure -- which made the handler
38
+ broad enough to report any RuntimeError in the process as a devpod problem.
39
+ The two failures devpod can hand this module now have a type each, and the
40
+ handler names both and nothing else.
41
+ """
42
+
43
+
44
+ class ProviderAddFailed(Exception):
45
+ """`devpod provider add` ran and failed.
46
+
47
+ Distinct from a listing that could not be read: devpod answered the question
48
+ it was asked, and then refused to do the thing.
35
49
  """
36
50
 
37
51
 
@@ -62,7 +76,8 @@ def list_provider_names(
62
76
  )
63
77
  if result.returncode != 0:
64
78
  raise UnreadableProviderList(
65
- f"`devpod provider list` exited {result.returncode}: {(result.stderr or '').strip()[:200]}"
79
+ f"`devpod provider list` exited {result.returncode}: "
80
+ f"{(result.stderr or '').strip()[:200]!r}"
66
81
  )
67
82
  return parse_provider_names(result.stdout or "")
68
83
 
@@ -74,13 +89,21 @@ def ensure_provider(
74
89
 
75
90
  Returns True if it had to be added, False if it was already there. Raises
76
91
  `UnreadableProviderList` rather than guessing when devpod's answer cannot
77
- be read.
92
+ be read, and `ProviderAddFailed` when the add itself fails.
78
93
  """
79
94
  if name in list_provider_names(run=run):
80
95
  return False
81
- result = run(["devpod", "provider", "add", name], check=False)
96
+ result = run(
97
+ ["devpod", "provider", "add", name],
98
+ capture_output=True,
99
+ text=True,
100
+ check=False,
101
+ )
82
102
  if result.returncode != 0:
83
- raise RuntimeError(f"`devpod provider add {name}` exited {result.returncode}")
103
+ raise ProviderAddFailed(
104
+ f"`devpod provider add {name}` exited {result.returncode}: "
105
+ f"{(result.stderr or '').strip()[:200]!r}"
106
+ )
84
107
  return True
85
108
 
86
109
 
@@ -105,7 +128,7 @@ def main(
105
128
 
106
129
  try:
107
130
  added = ensure_provider(args.name, run=run)
108
- except (UnreadableProviderList, RuntimeError) as exc:
131
+ except (UnreadableProviderList, ProviderAddFailed) as exc:
109
132
  print(f"error: {exc}")
110
133
  return 1
111
134
  print(f"devpod provider {args.name}: {'added' if added else 'already registered'}")
@@ -65,6 +65,26 @@ class SshNotInstalled(MissingBinary):
65
65
  """
66
66
 
67
67
 
68
+ class UnreadableWorkspaceList(Exception):
69
+ """devpod's workspace listing could not be read.
70
+
71
+ Distinct from "this machine has no workspaces", which is a listing that
72
+ reads fine and is empty. The two used to share one representation -- an
73
+ empty list -- so `dl --purge` could report that there was nothing to purge
74
+ when the truth was that it never found out, and a workspace spec could be
75
+ called unknown when nothing had been asked.
76
+
77
+ Deliberately not a RuntimeError, for the same reason MissingBinary is not:
78
+ dl catches RuntimeError broadly wherever a flaky command should degrade
79
+ rather than abort, and an answer dl could not read is precisely the thing
80
+ that must not degrade. It travels as a type nothing in between catches, and
81
+ main() is the only place that handles it.
82
+
83
+ The sibling shape in devpod_provider deliberately reads the same way. Two
84
+ listings devpod can fail to produce, two exceptions that say so, one rule.
85
+ """
86
+
87
+
68
88
  # One line, so a completion helper that trips over it cannot spew into the
69
89
  # user's shell. It names both install routes because devpod ships with the
70
90
  # pixi/conda package and does not ship with the pip one (see README).
@@ -86,6 +106,10 @@ SSH_MISSING_MESSAGE = (
86
106
  # cannot be confused with a devpod command that ran and failed.
87
107
  DEVPOD_MISSING_EXIT_CODE = 127
88
108
 
109
+ # devpod is there and dl asked it, but what came back could not be read. A plain
110
+ # failure rather than 127: nothing is missing and nothing needs installing.
111
+ UNREADABLE_WORKSPACE_LIST_EXIT_CODE = 1
112
+
89
113
 
90
114
  def _install_provenance() -> Optional[str]:
91
115
  """Describe the install this dl was launched from, or None if unremarkable.
@@ -268,8 +292,31 @@ def get_local_branches(owner_repo: str) -> List[str]:
268
292
 
269
293
 
270
294
  def update_completion_cache() -> Dict[str, Any]:
271
- """Update the completion cache with current data."""
272
- workspaces = list_workspaces()
295
+ """Update the completion cache with current data.
296
+
297
+ The one reader of the workspace list that has something to do with a listing
298
+ it cannot read. Everywhere else, an unreadable listing means the question
299
+ being asked cannot be answered at all, and UnreadableWorkspaceList travels
300
+ up to main() to say so. Here the workspace names are one of four things
301
+ being collected, and the other three -- repos, owners, branches -- come off
302
+ the local disk without asking devpod anything. So this catches, logs, and
303
+ builds completions out of what it can still see, deliberately: refusing
304
+ would mean an unreachable devpod stops `dl --install` from installing
305
+ completions at all, when what it costs is the workspace names.
306
+
307
+ The failure is logged rather than swallowed, because `dl --refresh` prints
308
+ the workspace count it got and zero-because-we-could-not-ask must not read
309
+ as zero-because-there-are-none. The cache this then writes offers no
310
+ workspace names until a later refresh succeeds -- which is what it did
311
+ before the listing learned to refuse -- and list_workspaces() still declines
312
+ to remember a list it never got, so the next command asks devpod again
313
+ instead of being served this one.
314
+ """
315
+ try:
316
+ workspaces = list_workspaces()
317
+ except UnreadableWorkspaceList as exc:
318
+ logging.warning(f"Completing without workspace names: {exc}")
319
+ workspaces = []
273
320
  workspace_ids = [ws.id for ws in workspaces]
274
321
  repos = discover_repos_from_workspaces(workspaces)
275
322
 
@@ -903,6 +950,38 @@ def invalidate_workspace_list_cache() -> None:
903
950
  _workspace_list_cache.pop(_WORKSPACE_LIST_KEY, None)
904
951
 
905
952
 
953
+ def parse_workspaces(listing: str) -> List[Workspace]:
954
+ """The workspaces in a `devpod list --output json` listing.
955
+
956
+ Anything that is not such a listing raises rather than parsing to nothing.
957
+ That includes the empty string: devpod prints `[]` for a machine with no
958
+ workspaces, so silence is devpod failing to answer, not devpod answering
959
+ that there is nothing to list. Silence gets a branch of its own rather than
960
+ falling into the JSON parser, whose report of it -- `not JSON: ''` -- reads
961
+ like a bug in dl rather than a devpod that never spoke.
962
+ """
963
+ if not listing.strip():
964
+ raise UnreadableWorkspaceList(
965
+ "devpod said nothing when asked to list workspaces; it prints `[]` when there are none"
966
+ )
967
+ try:
968
+ parsed = json.loads(listing)
969
+ except json.JSONDecodeError as exc:
970
+ raise UnreadableWorkspaceList(
971
+ f"devpod's workspace listing is not JSON: {listing[:120]!r}"
972
+ ) from exc
973
+ if not isinstance(parsed, list):
974
+ raise UnreadableWorkspaceList(
975
+ f"expected devpod to list workspaces, got {type(parsed).__name__}"
976
+ )
977
+ for entry in parsed:
978
+ if not isinstance(entry, dict):
979
+ raise UnreadableWorkspaceList(
980
+ f"expected each listed workspace to be an object, got {type(entry).__name__}"
981
+ )
982
+ return [Workspace.from_json(ws) for ws in parsed]
983
+
984
+
906
985
  def list_workspaces(refresh: bool = False) -> List[Workspace]:
907
986
  """List all devpod workspaces, reading devpod at most once per command.
908
987
 
@@ -916,10 +995,15 @@ def list_workspaces(refresh: bool = False) -> List[Workspace]:
916
995
  refresh=True bypasses the snapshot for a caller that must have the
917
996
  post-mutation truth even if nothing announced the mutation.
918
997
 
919
- Only an answer devpod actually gave is remembered. A failed or unparsable
920
- read returns an empty list without caching it, so a transient failure and
921
- a missing devpod, which raises out of here can never be served again as
922
- "this machine has no workspaces".
998
+ Only an answer devpod actually gave is remembered, and only an answer devpod
999
+ actually gave is returned: a read that failed or could not be parsed raises
1000
+ UnreadableWorkspaceList rather than answering with an empty list, so neither
1001
+ a transient failure nor a missing devpod can be served to a caller as "this
1002
+ machine has no workspaces".
1003
+
1004
+ Which of the workspaces devpod lists belong to dl is a separate question,
1005
+ and not one this function answers. It answers only whether the list can be
1006
+ believed at all.
923
1007
  """
924
1008
  if not refresh:
925
1009
  cached = _workspace_list_cache.get(_WORKSPACE_LIST_KEY)
@@ -928,14 +1012,14 @@ def list_workspaces(refresh: bool = False) -> List[Workspace]:
928
1012
  # be rewriting what the next caller sees.
929
1013
  return list(cached)
930
1014
  result = run_devpod(["list", "--output", "json"], capture=True)
931
- if result.returncode != 0 or not result.stdout.strip():
932
- return []
933
- try:
934
- data = json.loads(result.stdout)
935
- except json.JSONDecodeError:
936
- logging.error("Failed to parse devpod output")
937
- return []
938
- workspaces = [Workspace.from_json(ws) for ws in data]
1015
+ if result.returncode != 0:
1016
+ # !r for the same reason the parse path uses it: devpod's stderr is
1017
+ # routinely several lines, and DEVPOD_MISSING_MESSAGE's comment sets the
1018
+ # rule that one of dl's failure messages is one line.
1019
+ raise UnreadableWorkspaceList(
1020
+ f"`devpod list` exited {result.returncode}: {(result.stderr or '').strip()[:200]!r}"
1021
+ )
1022
+ workspaces = parse_workspaces(result.stdout or "")
939
1023
  _workspace_list_cache[_WORKSPACE_LIST_KEY] = workspaces
940
1024
  return list(workspaces)
941
1025
 
@@ -1372,9 +1456,10 @@ def wants_startup_cache_refresh(args: List[str]) -> bool:
1372
1456
  def main(argv: Optional[List[str]] = None) -> int:
1373
1457
  """Main entry point for dl CLI.
1374
1458
 
1375
- Thin wrapper so there is exactly one handler for a missing devpod, however
1376
- deep in the command it was noticed. The message goes to stderr because
1377
- stdout is parsed by the completion machinery (--repos, --completion-data).
1459
+ Thin wrapper so there is exactly one handler for a missing devpod, and one
1460
+ for a devpod that answered with something dl could not read, however deep in
1461
+ the command either was noticed. Both messages go to stderr because stdout is
1462
+ parsed by the completion machinery (--repos, --completion-data).
1378
1463
 
1379
1464
  argv is the argument list without the program name, defaulting to the real
1380
1465
  one. It is a parameter so that a sibling entry point can hand dl a command
@@ -1390,6 +1475,9 @@ def main(argv: Optional[List[str]] = None) -> int:
1390
1475
  except MissingBinary as e:
1391
1476
  print(e, file=sys.stderr)
1392
1477
  return DEVPOD_MISSING_EXIT_CODE
1478
+ except UnreadableWorkspaceList as e:
1479
+ print(f"error: {e}", file=sys.stderr)
1480
+ return UNREADABLE_WORKSPACE_LIST_EXIT_CODE
1393
1481
 
1394
1482
 
1395
1483
  def _run_cli(argv: Optional[List[str]] = None) -> int:
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "devlaunch"
3
- version = "0.0.16"
3
+ version = "0.0.18"
4
4
  authors = [{ name = "Austin Gregg-Smith", email = "blooop@gmail.com" }]
5
5
  description = "DevLaunch - A streamlined CLI for devpod workspaces"
6
6
  readme = "README.md"
@@ -90,6 +90,22 @@ lint = { depends-on = ["ruff-lint", "ty", "pylint"] }
90
90
  style = { depends-on = ["format", "lint"] }
91
91
  commit-format = "git commit -a -m'autoformat code' || true"
92
92
  test = "pytest"
93
+ # Needs a real Docker daemon and a real devpod. Inside this repo's devcontainer
94
+ # that daemon is the container's own, via the docker-in-docker feature; on an
95
+ # ephemeral CI runner or a machine you do not mind writing to, it is the host's.
96
+ #
97
+ # The default run excludes e2e (see addopts); this is the way back in. A
98
+ # trailing `-m e2e` beats the one in addopts, and `-ra` puts every skip's reason
99
+ # in the summary, which is the whole difference between a run that opted out of
100
+ # something and a run that could not do it.
101
+ #
102
+ # Deliberately no `depends-on = ["dev-add-docker"]`. The suite repoints
103
+ # DEVPOD_HOME at a fresh directory in pytest_configure, before collection, so a
104
+ # provider registered in the ambient ~/.devpod is one this run never reads:
105
+ # test/e2e/conftest.py installs it into the run's own home instead. An edge here
106
+ # would buy the suite nothing and write to the developer's real devpod home as a
107
+ # side effect of running tests.
108
+ test-e2e = "pytest -m e2e -ra test/e2e/"
93
109
  coverage = "coverage run -m pytest && coverage xml -o coverage.xml"
94
110
  coverage-report = "coverage report -m"
95
111
  update-lock = "pixi update && git commit -a -m'update pixi.lock' || true"
@@ -123,10 +139,13 @@ enable = "no-else-return,consider-using-in"
123
139
  markers = [
124
140
  "unit: Pure logic tests with no external commands. Fast, runs everywhere.",
125
141
  "integration: Real git commands, mocked DevPod. Catches git errors and path issues.",
126
- "e2e: Full E2E with Docker-in-Docker. Real DevPod creating real containers.",
142
+ "e2e: Real DevPod creating real containers. Needs a Docker daemon.",
143
+ "creates_workspace: This e2e test builds a real workspace. The session floor in test/e2e/conftest.py holds a run that attempted one of these and built nothing to be a failure, so the promise is declared by the test rather than inferred afterwards.",
127
144
  ]
128
145
  testpaths = ["test"]
129
- # Default: run unit and integration tests, skip e2e (requires Docker-in-Docker)
146
+ # Default: run unit and integration tests, skip e2e (which needs a daemon, and
147
+ # creates and deletes real containers -- in a devpod home the suite makes for
148
+ # the run, never the developer's)
130
149
  addopts = "-m 'not e2e'"
131
150
 
132
151
  [tool.coverage.run]
File without changes
File without changes
File without changes