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.
- {devlaunch-0.0.16 → devlaunch-0.0.18}/.gitignore +0 -1
- {devlaunch-0.0.16 → devlaunch-0.0.18}/PKG-INFO +86 -1
- {devlaunch-0.0.16 → devlaunch-0.0.18}/README.md +85 -0
- {devlaunch-0.0.16 → devlaunch-0.0.18}/devlaunch/devpod_provider.py +29 -6
- {devlaunch-0.0.16 → devlaunch-0.0.18}/devlaunch/dl.py +105 -17
- {devlaunch-0.0.16 → devlaunch-0.0.18}/pyproject.toml +22 -3
- {devlaunch-0.0.16 → devlaunch-0.0.18}/LICENSE +0 -0
- {devlaunch-0.0.16 → devlaunch-0.0.18}/devlaunch/__init__.py +0 -0
- {devlaunch-0.0.16 → devlaunch-0.0.18}/devlaunch/aid.py +0 -0
- {devlaunch-0.0.16 → devlaunch-0.0.18}/devlaunch/completion.py +0 -0
- {devlaunch-0.0.16 → devlaunch-0.0.18}/devlaunch/completion_loader.py +0 -0
- {devlaunch-0.0.16 → devlaunch-0.0.18}/devlaunch/completions/__init__.py +0 -0
- {devlaunch-0.0.16 → devlaunch-0.0.18}/devlaunch/completions/dl.bash +0 -0
- {devlaunch-0.0.16 → devlaunch-0.0.18}/devlaunch/devpod_ssh.py +0 -0
- {devlaunch-0.0.16 → devlaunch-0.0.18}/devlaunch/gh_auth.py +0 -0
- {devlaunch-0.0.16 → devlaunch-0.0.18}/devlaunch/tools.py +0 -0
- {devlaunch-0.0.16 → devlaunch-0.0.18}/devlaunch/tty_session.py +0 -0
- {devlaunch-0.0.16 → devlaunch-0.0.18}/devlaunch/workspace_id.py +0 -0
- {devlaunch-0.0.16 → devlaunch-0.0.18}/devlaunch/worktree/__init__.py +0 -0
- {devlaunch-0.0.16 → devlaunch-0.0.18}/devlaunch/worktree/branch_manager.py +0 -0
- {devlaunch-0.0.16 → devlaunch-0.0.18}/devlaunch/worktree/config.py +0 -0
- {devlaunch-0.0.16 → devlaunch-0.0.18}/devlaunch/worktree/migration.py +0 -0
- {devlaunch-0.0.16 → devlaunch-0.0.18}/devlaunch/worktree/models.py +0 -0
- {devlaunch-0.0.16 → devlaunch-0.0.18}/devlaunch/worktree/repo_manager.py +0 -0
- {devlaunch-0.0.16 → devlaunch-0.0.18}/devlaunch/worktree/storage.py +0 -0
- {devlaunch-0.0.16 → devlaunch-0.0.18}/devlaunch/worktree/workspace_clone.py +0 -0
- {devlaunch-0.0.16 → devlaunch-0.0.18}/devlaunch/xdg.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: devlaunch
|
|
3
|
-
Version: 0.0.
|
|
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(
|
|
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}:
|
|
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(
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
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
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
workspaces =
|
|
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,
|
|
1376
|
-
|
|
1377
|
-
|
|
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.
|
|
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:
|
|
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 (
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|