call-dibs 0.3.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.
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: call-dibs
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: Call dibs on shared stuff — a one-file lock ledger for humans and AI agents.
|
|
5
|
+
License: MIT
|
|
6
|
+
Requires-Python: >=3.8
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Dynamic: license-file
|
|
10
|
+
|
|
11
|
+
<div align="center">
|
|
12
|
+
|
|
13
|
+
# ✋ call-dibs
|
|
14
|
+
|
|
15
|
+
**Call dibs on shared stuff.**
|
|
16
|
+
|
|
17
|
+
A tiny lock ledger so humans and AI agents stop fighting over the same phone, GPU, or test bench.
|
|
18
|
+
|
|
19
|
+
[](https://github.com/NeoMarcoPolo/call-dibs/actions/workflows/ci.yml)
|
|
20
|
+
[](https://pypi.org/project/call-dibs/)
|
|
21
|
+
[](https://www.python.org/)
|
|
22
|
+
[](LICENSE)
|
|
23
|
+
|
|
24
|
+
</div>
|
|
25
|
+
|
|
26
|
+
```console
|
|
27
|
+
$ dibs claim phone-a --note "regression run"
|
|
28
|
+
claimed phone-a as alice
|
|
29
|
+
|
|
30
|
+
$ dibs claim phone-a # …from another session
|
|
31
|
+
BUSY phone-a: held by alice since 2026-09-02T21:14:03Z (3m ago) — "regression run"
|
|
32
|
+
|
|
33
|
+
$ dibs release phone-a
|
|
34
|
+
released phone-a (was alice)
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Why
|
|
38
|
+
|
|
39
|
+
You have one test phone, one GPU, one hardware bench — and several
|
|
40
|
+
terminals (or several coding agents) that can all reach it. `dibs` is the
|
|
41
|
+
smallest thing that makes them take turns.
|
|
42
|
+
|
|
43
|
+
- **One file, no dependencies.** A single Python script, stdlib only. No
|
|
44
|
+
daemon, no server, no database.
|
|
45
|
+
- **Cross-platform.** macOS, Linux, Windows.
|
|
46
|
+
- **Legible.** The ledger is one human-readable JSON file per resource, and
|
|
47
|
+
every "busy" says *who*, *since when*, and *why*.
|
|
48
|
+
- **Agent-friendly.** Stable exit codes, `--json`, a blocking `--wait`, and a
|
|
49
|
+
`run` wrapper that always releases — even when the command fails.
|
|
50
|
+
|
|
51
|
+
## Install
|
|
52
|
+
|
|
53
|
+
```sh
|
|
54
|
+
pipx install call-dibs # or: pip install call-dibs · uv tool install call-dibs
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Works the same on macOS, Linux, and Windows. Prefer no package manager? It's
|
|
58
|
+
one file — [`dibs.py`](dibs.py) — drop it on your `PATH`.
|
|
59
|
+
|
|
60
|
+
## Set up
|
|
61
|
+
|
|
62
|
+
Tell `dibs` what you share. Write `~/.dibs/resources.json` once, by hand:
|
|
63
|
+
|
|
64
|
+
```json
|
|
65
|
+
{
|
|
66
|
+
"phone-a": "Android test phone on the bench",
|
|
67
|
+
"gpu-0": "the shared training GPU",
|
|
68
|
+
"printer": "label printer, room 2"
|
|
69
|
+
}
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Only these names can be claimed, so a typo can't silently create a new lock.
|
|
73
|
+
`dibs` never edits this file.
|
|
74
|
+
|
|
75
|
+
## Use
|
|
76
|
+
|
|
77
|
+
| command | what it does |
|
|
78
|
+
|---|---|
|
|
79
|
+
| `dibs claim <r...> [--note ...] [--wait [--timeout N]]` | claim one or more resources. Exit `2` + holder info if busy |
|
|
80
|
+
| `dibs release <r...> [--force]` | release |
|
|
81
|
+
| `dibs status [r] [--json]` | who has what |
|
|
82
|
+
| `dibs wait <r...> [--timeout N]` | block until free, without claiming |
|
|
83
|
+
| `dibs run <r> -- <cmd...>` | claim → run → always release |
|
|
84
|
+
| `dibs watch` | live terminal view |
|
|
85
|
+
|
|
86
|
+
Exit codes: `0` ok · `2` busy · `3` not yours · `4` wait timeout · `1` error.
|
|
87
|
+
|
|
88
|
+
### Who's asking
|
|
89
|
+
|
|
90
|
+
Every claim records an owner, and only that owner can release it. Name
|
|
91
|
+
yourself once per session with `DIBS_OWNER`:
|
|
92
|
+
|
|
93
|
+
```sh
|
|
94
|
+
export DIBS_OWNER=alice # people
|
|
95
|
+
DIBS_OWNER=agent:task-123 dibs claim … # agents: inline, so every call agrees
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
If it's unset, `dibs` falls back to `user@host:pid` — fine for one-off use,
|
|
99
|
+
wrong for agents whose shell changes every command. `--force` releases
|
|
100
|
+
someone else's lock when they're gone.
|
|
101
|
+
|
|
102
|
+
### Groups
|
|
103
|
+
|
|
104
|
+
Claim several resources in one call and they form a group. The claim is
|
|
105
|
+
all-or-nothing — if any resource is busy, nothing is taken — and the group
|
|
106
|
+
gets a tag so you can drop the whole set at once:
|
|
107
|
+
|
|
108
|
+
```console
|
|
109
|
+
$ dibs claim phone-a gpu-0 --note "regression run"
|
|
110
|
+
claimed gpu-0 as alice
|
|
111
|
+
claimed phone-a as alice
|
|
112
|
+
group g-3fa2c1: gpu-0, phone-a (discard with: dibs release g-3fa2c1)
|
|
113
|
+
|
|
114
|
+
$ dibs release g-3fa2c1
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Name the group yourself with `--as NAME`.
|
|
118
|
+
|
|
119
|
+
## For AI agents
|
|
120
|
+
|
|
121
|
+
`skills/dibs/` is a drop-in skill for Claude Code (and reads fine as an
|
|
122
|
+
`AGENTS.md` snippet for anything else). It teaches the protocol: claim before
|
|
123
|
+
touching hardware, one call per set, release when done, never force-break
|
|
124
|
+
someone else's lock.
|
|
125
|
+
|
|
126
|
+
```sh
|
|
127
|
+
cp -r skills/dibs ~/.claude/skills/
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
## Menu bar (macOS)
|
|
131
|
+
|
|
132
|
+
See the ledger at a glance: `dibs ✋2` in the top bar when two things are
|
|
133
|
+
claimed, `dibs ✓` when everything is free, and who-has-what in the dropdown.
|
|
134
|
+
Install [SwiftBar](https://swiftbar.app), then add the plugin:
|
|
135
|
+
|
|
136
|
+
```sh
|
|
137
|
+
open "swiftbar://addplugin?src=https://raw.githubusercontent.com/NeoMarcoPolo/call-dibs/main/contrib/dibs.5s.sh"
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
## How it works
|
|
141
|
+
|
|
142
|
+
One JSON file per resource under `~/.dibs/` (override with `DIBS_DIR`). A
|
|
143
|
+
claim is an atomic `O_CREAT|O_EXCL` create, so racing claimers get exactly
|
|
144
|
+
one winner. Locks are advisory and are held until released — there is no
|
|
145
|
+
expiry. The ledger is per-machine by default; point `DIBS_DIR` at a shared
|
|
146
|
+
directory to span machines.
|
|
147
|
+
|
|
148
|
+
## Contributing
|
|
149
|
+
|
|
150
|
+
`python3 -m unittest discover tests`. Keep it small.
|
|
151
|
+
|
|
152
|
+
Releases: bump `version` in `pyproject.toml` and `dibs.py`, then push a
|
|
153
|
+
`v*` tag — CI publishes to PyPI via trusted publishing (maintainer approval
|
|
154
|
+
required).
|
|
155
|
+
|
|
156
|
+
## License
|
|
157
|
+
|
|
158
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
dibs.py,sha256=BOumbuEwtUQbyh7B7Tckm45uoDtyCKndAaeR02J-6Vs,14919
|
|
2
|
+
call_dibs-0.3.0.dist-info/licenses/LICENSE,sha256=NobDAGyTMv0DH21lcgdia5DoUdpSPucoyDs58xrcEVs,1067
|
|
3
|
+
call_dibs-0.3.0.dist-info/METADATA,sha256=ujey6kjx9ivGY-P7PLP199p5gu2hTWJutystIyX1nkQ,5091
|
|
4
|
+
call_dibs-0.3.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
5
|
+
call_dibs-0.3.0.dist-info/entry_points.txt,sha256=I3tSKzk5X3hCFWYKxAeVBJale3mGAqqpmJRLjFlSyK8,35
|
|
6
|
+
call_dibs-0.3.0.dist-info/top_level.txt,sha256=iddzCviTlLjrDlmJ7Ucz_jrPB4ESV3_hpKNJvuWiL0s,5
|
|
7
|
+
call_dibs-0.3.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Luke Zhang
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
dibs
|
dibs.py
ADDED
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""dibs — call dibs on shared stuff.
|
|
3
|
+
|
|
4
|
+
A tiny advisory lock ledger for physical resources shared by humans and AI
|
|
5
|
+
agents: test phones, GPUs, staging environments, the label printer. One JSON
|
|
6
|
+
file per resource. No daemon, no dependencies — Python 3.8+ stdlib only.
|
|
7
|
+
macOS / Linux / Windows.
|
|
8
|
+
|
|
9
|
+
dibs claim gpu-0 --note "training run"
|
|
10
|
+
dibs status
|
|
11
|
+
dibs release gpu-0
|
|
12
|
+
|
|
13
|
+
A lock is held until it is released. If a holder vanished, break the lock
|
|
14
|
+
with `dibs release <r> --force` (loud on purpose).
|
|
15
|
+
|
|
16
|
+
Ledger dir: $DIBS_DIR (default ~/.dibs)
|
|
17
|
+
Owner id: $DIBS_OWNER (default user@host:pid<ppid> — set a stable one!)
|
|
18
|
+
Resources: $DIBS_DIR/resources.json — you write it by hand, once:
|
|
19
|
+
{"gpu-0": "the shared GPU", "phone-a": "test phone"}
|
|
20
|
+
Only defined names can be claimed. dibs never edits this file.
|
|
21
|
+
|
|
22
|
+
Claiming several resources in one call takes them all-or-nothing and forms
|
|
23
|
+
a group (auto-named, or `--as NAME`); release the group to drop them all:
|
|
24
|
+
|
|
25
|
+
dibs claim phone-a gpu-0 -> group g-3fa2c1: gpu-0, phone-a
|
|
26
|
+
dibs release g-3fa2c1
|
|
27
|
+
|
|
28
|
+
Exit codes: 0 ok · 2 busy · 3 not held by you · 4 wait timeout · 1 error
|
|
29
|
+
"""
|
|
30
|
+
import argparse
|
|
31
|
+
import getpass
|
|
32
|
+
import json
|
|
33
|
+
import os
|
|
34
|
+
import re
|
|
35
|
+
import socket
|
|
36
|
+
import sys
|
|
37
|
+
import time
|
|
38
|
+
from datetime import datetime, timezone
|
|
39
|
+
from pathlib import Path
|
|
40
|
+
|
|
41
|
+
__version__ = "0.3.0"
|
|
42
|
+
|
|
43
|
+
LEDGER = Path(os.environ.get("DIBS_DIR", Path.home() / ".dibs"))
|
|
44
|
+
NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def now_iso():
|
|
48
|
+
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def age_str(ts):
|
|
52
|
+
since = datetime.strptime(ts, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc)
|
|
53
|
+
secs = int((datetime.now(timezone.utc) - since).total_seconds())
|
|
54
|
+
if secs < 90:
|
|
55
|
+
return f"{secs}s"
|
|
56
|
+
if secs < 5400:
|
|
57
|
+
return f"{secs // 60}m"
|
|
58
|
+
if secs < 129600:
|
|
59
|
+
return f"{secs // 3600}h{(secs % 3600) // 60:02d}m"
|
|
60
|
+
return f"{secs // 86400}d"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def default_owner():
|
|
64
|
+
o = os.environ.get("DIBS_OWNER")
|
|
65
|
+
if o:
|
|
66
|
+
return o
|
|
67
|
+
return f"{getpass.getuser()}@{socket.gethostname().split('.')[0]}:pid{os.getppid()}"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def lock_path(resource):
|
|
71
|
+
if not NAME_RE.match(resource):
|
|
72
|
+
raise SystemExit(f"dibs: bad resource name {resource!r} "
|
|
73
|
+
"(letters/digits/._- only)")
|
|
74
|
+
return LEDGER / f"{resource}.lock.json"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def read_lock(path):
|
|
78
|
+
try:
|
|
79
|
+
return json.loads(path.read_text())
|
|
80
|
+
except (FileNotFoundError, json.JSONDecodeError):
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def registry():
|
|
85
|
+
"""Hand-written {"name": "description"} map, or None if absent."""
|
|
86
|
+
try:
|
|
87
|
+
reg = json.loads((LEDGER / "resources.json").read_text())
|
|
88
|
+
if isinstance(reg, dict):
|
|
89
|
+
return reg
|
|
90
|
+
except FileNotFoundError:
|
|
91
|
+
return None
|
|
92
|
+
except json.JSONDecodeError as e:
|
|
93
|
+
raise SystemExit(f"dibs: {LEDGER / 'resources.json'} is not valid JSON: {e}")
|
|
94
|
+
raise SystemExit(f"dibs: {LEDGER / 'resources.json'} must be a JSON object "
|
|
95
|
+
'{"name": "description"}')
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
NO_REGISTRY = (f"dibs: no resources defined yet. Create {LEDGER / 'resources.json'} "
|
|
99
|
+
'by hand, e.g. {"gpu-0": "the shared GPU", "phone-a": "test phone"}')
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def check_names(names):
|
|
103
|
+
"""Validate claim targets against the registry; return sorted, deduped."""
|
|
104
|
+
reg = registry()
|
|
105
|
+
if not reg:
|
|
106
|
+
raise SystemExit(NO_REGISTRY)
|
|
107
|
+
for name in names:
|
|
108
|
+
if name not in reg:
|
|
109
|
+
raise SystemExit(f"dibs: {name!r} is not defined in resources.json "
|
|
110
|
+
f"(known: {', '.join(sorted(reg))})")
|
|
111
|
+
return sorted(set(names))
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def holder_line(resource, rec):
|
|
115
|
+
note = f' — "{rec["note"]}"' if rec.get("note") else ""
|
|
116
|
+
grp = f" [group {rec['group']}]" if rec.get("group") else ""
|
|
117
|
+
return (f"{resource}: held by {rec['owner']} "
|
|
118
|
+
f"since {rec['since']} ({age_str(rec['since'])} ago){note}{grp}")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def ledger_rows():
|
|
122
|
+
"""All resources: registry entries plus any live locks."""
|
|
123
|
+
LEDGER.mkdir(parents=True, exist_ok=True)
|
|
124
|
+
reg = registry() or {}
|
|
125
|
+
rows = {name: {"resource": name, "description": desc}
|
|
126
|
+
for name, desc in sorted(reg.items())
|
|
127
|
+
if isinstance(desc, str)} # list values are groups, not resources
|
|
128
|
+
for p in sorted(LEDGER.glob("*.lock.json")):
|
|
129
|
+
rec = read_lock(p)
|
|
130
|
+
if rec is None:
|
|
131
|
+
continue
|
|
132
|
+
name = rec.get("resource", p.name[:-len(".lock.json")])
|
|
133
|
+
desc = reg.get(name, "")
|
|
134
|
+
rec.setdefault("description", desc if isinstance(desc, str) else "")
|
|
135
|
+
rows[name] = {**rows.get(name, {}), **rec}
|
|
136
|
+
return list(rows.values())
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def try_claim_one(resource, owner, note, group=None):
|
|
140
|
+
"""Returns 'claimed' | 'yours' | the blocking record."""
|
|
141
|
+
path = lock_path(resource)
|
|
142
|
+
rec = {"resource": resource, "owner": owner,
|
|
143
|
+
"host": socket.gethostname().split(".")[0],
|
|
144
|
+
"since": now_iso(), "note": note or None, "group": group}
|
|
145
|
+
while True:
|
|
146
|
+
LEDGER.mkdir(parents=True, exist_ok=True)
|
|
147
|
+
try:
|
|
148
|
+
fd = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
|
149
|
+
with os.fdopen(fd, "w") as f:
|
|
150
|
+
json.dump(rec, f, indent=2)
|
|
151
|
+
return "claimed"
|
|
152
|
+
except FileExistsError:
|
|
153
|
+
cur = read_lock(path)
|
|
154
|
+
if cur is None:
|
|
155
|
+
continue # holder released between our create and read; retry
|
|
156
|
+
if cur.get("owner") == owner:
|
|
157
|
+
if note and note != cur.get("note"):
|
|
158
|
+
tmp = path.with_name(path.name + f".tmp-{os.getpid()}")
|
|
159
|
+
tmp.write_text(json.dumps(dict(cur, note=note), indent=2))
|
|
160
|
+
os.replace(tmp, path)
|
|
161
|
+
return "yours"
|
|
162
|
+
return cur
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def cmd_claim(a):
|
|
166
|
+
"""All-or-nothing: on any BUSY, locks newly taken by this call are rolled
|
|
167
|
+
back (sorted claim order keeps overlapping sets deadlock-free). Claiming
|
|
168
|
+
several resources at once forms a group — a tag stamped on each lock —
|
|
169
|
+
so the whole set can later be discarded with `dibs release <group>`."""
|
|
170
|
+
resources = check_names(a.resources)
|
|
171
|
+
owner = a.owner or default_owner()
|
|
172
|
+
group = getattr(a, "as_group", None)
|
|
173
|
+
if group is None and len(resources) > 1:
|
|
174
|
+
group = "g-" + os.urandom(3).hex()
|
|
175
|
+
if group and not NAME_RE.match(group):
|
|
176
|
+
raise SystemExit(f"dibs: bad group name {group!r}")
|
|
177
|
+
deadline = time.time() + a.timeout if a.timeout else None
|
|
178
|
+
while True:
|
|
179
|
+
got, blocker = [], None
|
|
180
|
+
for r in resources:
|
|
181
|
+
res = try_claim_one(r, owner, a.note, group)
|
|
182
|
+
if res == "claimed":
|
|
183
|
+
got.append(r)
|
|
184
|
+
elif res != "yours":
|
|
185
|
+
blocker = (r, res)
|
|
186
|
+
break
|
|
187
|
+
if blocker is None:
|
|
188
|
+
for r in resources:
|
|
189
|
+
print(f"claimed {r} as {owner}")
|
|
190
|
+
if group:
|
|
191
|
+
print(f"group {group}: {', '.join(resources)} "
|
|
192
|
+
f"(discard with: dibs release {group})")
|
|
193
|
+
return 0
|
|
194
|
+
for r in got: # roll back this call's partial set
|
|
195
|
+
try:
|
|
196
|
+
lock_path(r).unlink()
|
|
197
|
+
except FileNotFoundError:
|
|
198
|
+
pass
|
|
199
|
+
if not a.wait:
|
|
200
|
+
print("BUSY " + holder_line(*blocker), file=sys.stderr)
|
|
201
|
+
return 2
|
|
202
|
+
if deadline and time.time() > deadline:
|
|
203
|
+
print("timeout waiting; " + holder_line(*blocker), file=sys.stderr)
|
|
204
|
+
return 4
|
|
205
|
+
time.sleep(a.poll)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def resolve_targets(names):
|
|
209
|
+
"""Each name is a resource with a live lock, or a group tag: expands to
|
|
210
|
+
every locked resource carrying that tag. Unknown names pass through."""
|
|
211
|
+
LEDGER.mkdir(parents=True, exist_ok=True)
|
|
212
|
+
locks = [rec for p in sorted(LEDGER.glob("*.lock.json"))
|
|
213
|
+
if (rec := read_lock(p))]
|
|
214
|
+
out = []
|
|
215
|
+
for name in names:
|
|
216
|
+
members = [r["resource"] for r in locks if r.get("group") == name]
|
|
217
|
+
if members and not any(r["resource"] == name for r in locks):
|
|
218
|
+
out.extend(members)
|
|
219
|
+
else:
|
|
220
|
+
out.append(name)
|
|
221
|
+
return sorted(set(out))
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def cmd_release(a):
|
|
225
|
+
owner = a.owner or default_owner()
|
|
226
|
+
rc = 0
|
|
227
|
+
for resource in resolve_targets(a.resources):
|
|
228
|
+
path = lock_path(resource)
|
|
229
|
+
rec = read_lock(path)
|
|
230
|
+
if rec is None:
|
|
231
|
+
print(f"{resource}: not held")
|
|
232
|
+
continue
|
|
233
|
+
if rec.get("owner") != owner and not a.force:
|
|
234
|
+
print(f"NOT YOURS {holder_line(resource, rec)}\n"
|
|
235
|
+
f"(you are {owner}; use --force to break it)", file=sys.stderr)
|
|
236
|
+
rc = 3
|
|
237
|
+
continue
|
|
238
|
+
try:
|
|
239
|
+
path.unlink()
|
|
240
|
+
except FileNotFoundError:
|
|
241
|
+
pass
|
|
242
|
+
verb = "broke" if rec.get("owner") != owner else "released"
|
|
243
|
+
print(f"{verb} {resource} (was {rec.get('owner')})")
|
|
244
|
+
return rc
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def cmd_status(a):
|
|
248
|
+
rows = ledger_rows()
|
|
249
|
+
if a.resource:
|
|
250
|
+
rows = [r for r in rows if r["resource"] == a.resource]
|
|
251
|
+
if not rows:
|
|
252
|
+
print(f"{a.resource}: free")
|
|
253
|
+
return 0
|
|
254
|
+
if a.json:
|
|
255
|
+
print(json.dumps(rows, indent=2))
|
|
256
|
+
return 0
|
|
257
|
+
if a.xbar:
|
|
258
|
+
held = [r for r in rows if r.get("owner")]
|
|
259
|
+
print(f"dibs ✋{len(held)}" if held else "dibs ✓")
|
|
260
|
+
print("---")
|
|
261
|
+
if not rows:
|
|
262
|
+
print("no resources.json yet | color=gray")
|
|
263
|
+
for r in rows:
|
|
264
|
+
if r.get("owner"):
|
|
265
|
+
note = f" · {r['note']}" if r.get("note") else ""
|
|
266
|
+
grp = f" · {r['group']}" if r.get("group") else ""
|
|
267
|
+
print(f"{r['resource']} — {r['owner']}{note}{grp} | color=#e05d44")
|
|
268
|
+
else:
|
|
269
|
+
print(f"{r['resource']} — free | color=#44a05d")
|
|
270
|
+
return 0
|
|
271
|
+
if not rows:
|
|
272
|
+
print(NO_REGISTRY, file=sys.stderr)
|
|
273
|
+
return 1
|
|
274
|
+
for r in rows:
|
|
275
|
+
if r.get("owner"):
|
|
276
|
+
print(holder_line(r["resource"], r))
|
|
277
|
+
else:
|
|
278
|
+
desc = f" ({r['description']})" if r.get("description") else ""
|
|
279
|
+
print(f"{r['resource']}: free{desc}")
|
|
280
|
+
return 0
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def cmd_wait(a):
|
|
284
|
+
deadline = time.time() + a.timeout if a.timeout else None
|
|
285
|
+
while True:
|
|
286
|
+
resources = resolve_targets(a.resources)
|
|
287
|
+
held = next(((r, rec) for r in resources
|
|
288
|
+
if (rec := read_lock(lock_path(r)))), None)
|
|
289
|
+
if held is None:
|
|
290
|
+
print(", ".join(a.resources) + ": free")
|
|
291
|
+
return 0
|
|
292
|
+
if deadline and time.time() > deadline:
|
|
293
|
+
print("timeout: " + holder_line(*held), file=sys.stderr)
|
|
294
|
+
return 4
|
|
295
|
+
time.sleep(a.poll)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def cmd_run(a):
|
|
299
|
+
import subprocess
|
|
300
|
+
if "--" in a.argv:
|
|
301
|
+
i = a.argv.index("--")
|
|
302
|
+
if a.argv[:i]:
|
|
303
|
+
print("dibs run: put dibs flags BEFORE <resource> "
|
|
304
|
+
f"(these were ignored: {' '.join(a.argv[:i])})", file=sys.stderr)
|
|
305
|
+
return 1
|
|
306
|
+
argv = a.argv[i + 1:]
|
|
307
|
+
else:
|
|
308
|
+
argv = a.argv
|
|
309
|
+
if argv and argv[0].startswith("-"):
|
|
310
|
+
print("dibs run: put dibs flags BEFORE <resource>; the command goes "
|
|
311
|
+
"after -- (dibs run --note x <resource> -- cmd ...)", file=sys.stderr)
|
|
312
|
+
return 1
|
|
313
|
+
if not argv:
|
|
314
|
+
print("dibs run: no command given (dibs run <resource> -- cmd ...)",
|
|
315
|
+
file=sys.stderr)
|
|
316
|
+
return 1
|
|
317
|
+
a.resources = [a.resource]
|
|
318
|
+
rc = cmd_claim(a)
|
|
319
|
+
if rc != 0:
|
|
320
|
+
return rc
|
|
321
|
+
try:
|
|
322
|
+
return subprocess.call(argv)
|
|
323
|
+
finally:
|
|
324
|
+
try:
|
|
325
|
+
lock_path(a.resource).unlink()
|
|
326
|
+
except FileNotFoundError:
|
|
327
|
+
pass
|
|
328
|
+
print(f"released {a.resource}", file=sys.stderr)
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def cmd_watch(a):
|
|
332
|
+
try:
|
|
333
|
+
while True:
|
|
334
|
+
rows = ledger_rows()
|
|
335
|
+
sys.stdout.write("\x1b[2J\x1b[H")
|
|
336
|
+
print(f"dibs ledger · {LEDGER} (ctrl-c to quit)\n")
|
|
337
|
+
if not rows:
|
|
338
|
+
print(" " + NO_REGISTRY)
|
|
339
|
+
for r in rows:
|
|
340
|
+
if r.get("owner"):
|
|
341
|
+
print(" 🔴 " + holder_line(r["resource"], r))
|
|
342
|
+
else:
|
|
343
|
+
print(f" 🟢 {r['resource']}: free")
|
|
344
|
+
sys.stdout.flush()
|
|
345
|
+
time.sleep(a.poll)
|
|
346
|
+
except KeyboardInterrupt:
|
|
347
|
+
return 0
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def main():
|
|
351
|
+
p = argparse.ArgumentParser(
|
|
352
|
+
prog="dibs", description=__doc__,
|
|
353
|
+
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
354
|
+
p.add_argument("--version", action="version", version=f"dibs {__version__}")
|
|
355
|
+
sub = p.add_subparsers(dest="command", required=True)
|
|
356
|
+
|
|
357
|
+
def add_claim_args(sp, multi):
|
|
358
|
+
if multi:
|
|
359
|
+
sp.add_argument("resources", nargs="+", metavar="resource",
|
|
360
|
+
help="resource or group names")
|
|
361
|
+
else:
|
|
362
|
+
sp.add_argument("resource", help="resource or group name")
|
|
363
|
+
sp.add_argument("--note", help="why you have it")
|
|
364
|
+
sp.add_argument("--as", dest="as_group", metavar="GROUP",
|
|
365
|
+
help="name the group (default: auto g-xxxxxx when "
|
|
366
|
+
"claiming several resources)")
|
|
367
|
+
sp.add_argument("--owner", help="override owner id (or set $DIBS_OWNER)")
|
|
368
|
+
sp.add_argument("--wait", action="store_true", help="block until claimable")
|
|
369
|
+
sp.add_argument("--timeout", type=int,
|
|
370
|
+
help="give up after N seconds (with --wait)")
|
|
371
|
+
sp.add_argument("--poll", type=int, default=5, help="poll interval seconds")
|
|
372
|
+
|
|
373
|
+
sp = sub.add_parser(
|
|
374
|
+
"claim", help="claim resources/groups, all-or-nothing (exit 2 if busy)")
|
|
375
|
+
add_claim_args(sp, multi=True)
|
|
376
|
+
sp.set_defaults(fn=cmd_claim)
|
|
377
|
+
|
|
378
|
+
sp = sub.add_parser("release", help="release resources/groups you hold")
|
|
379
|
+
sp.add_argument("resources", nargs="+", metavar="resource")
|
|
380
|
+
sp.add_argument("--owner")
|
|
381
|
+
sp.add_argument("--force", action="store_true",
|
|
382
|
+
help="break someone else's lock")
|
|
383
|
+
sp.set_defaults(fn=cmd_release)
|
|
384
|
+
|
|
385
|
+
sp = sub.add_parser("status", help="show the ledger (all, or one resource)")
|
|
386
|
+
sp.add_argument("resource", nargs="?")
|
|
387
|
+
sp.add_argument("--json", action="store_true")
|
|
388
|
+
sp.add_argument("--xbar", action="store_true",
|
|
389
|
+
help="xbar/SwiftBar plugin output")
|
|
390
|
+
sp.set_defaults(fn=cmd_status)
|
|
391
|
+
|
|
392
|
+
sp = sub.add_parser("wait", help="block until resources are free (no claim)")
|
|
393
|
+
sp.add_argument("resources", nargs="+", metavar="resource")
|
|
394
|
+
sp.add_argument("--timeout", type=int)
|
|
395
|
+
sp.add_argument("--poll", type=int, default=5)
|
|
396
|
+
sp.set_defaults(fn=cmd_wait)
|
|
397
|
+
|
|
398
|
+
sp = sub.add_parser(
|
|
399
|
+
"run", help="claim, run a command, auto-release: dibs run gpu -- make train")
|
|
400
|
+
add_claim_args(sp, multi=False)
|
|
401
|
+
sp.add_argument("argv", nargs=argparse.REMAINDER)
|
|
402
|
+
sp.set_defaults(fn=cmd_run)
|
|
403
|
+
|
|
404
|
+
sp = sub.add_parser("watch", help="live terminal view of the ledger")
|
|
405
|
+
sp.add_argument("--poll", type=int, default=2)
|
|
406
|
+
sp.set_defaults(fn=cmd_watch)
|
|
407
|
+
|
|
408
|
+
a = p.parse_args()
|
|
409
|
+
sys.exit(a.fn(a))
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
if __name__ == "__main__":
|
|
413
|
+
main()
|