fastws-cli 0.0.4__tar.gz → 0.0.6__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.
- {fastws_cli-0.0.4 → fastws_cli-0.0.6}/CHANGELOG.md +8 -1
- {fastws_cli-0.0.4/fastws_cli.egg-info → fastws_cli-0.0.6}/PKG-INFO +3 -2
- {fastws_cli-0.0.4 → fastws_cli-0.0.6}/README.md +2 -1
- fastws_cli-0.0.6/fastws/__init__.py +4 -0
- {fastws_cli-0.0.4 → fastws_cli-0.0.6}/fastws/core.py +46 -24
- {fastws_cli-0.0.4 → fastws_cli-0.0.6/fastws_cli.egg-info}/PKG-INFO +3 -2
- {fastws_cli-0.0.4 → fastws_cli-0.0.6}/tests/test_sync.py +75 -7
- fastws_cli-0.0.4/fastws/__init__.py +0 -5
- {fastws_cli-0.0.4 → fastws_cli-0.0.6}/LICENSE +0 -0
- {fastws_cli-0.0.4 → fastws_cli-0.0.6}/MANIFEST.in +0 -0
- {fastws_cli-0.0.4 → fastws_cli-0.0.6}/fastws_cli.egg-info/SOURCES.txt +0 -0
- {fastws_cli-0.0.4 → fastws_cli-0.0.6}/fastws_cli.egg-info/dependency_links.txt +0 -0
- {fastws_cli-0.0.4 → fastws_cli-0.0.6}/fastws_cli.egg-info/entry_points.txt +0 -0
- {fastws_cli-0.0.4 → fastws_cli-0.0.6}/fastws_cli.egg-info/requires.txt +0 -0
- {fastws_cli-0.0.4 → fastws_cli-0.0.6}/fastws_cli.egg-info/top_level.txt +0 -0
- {fastws_cli-0.0.4 → fastws_cli-0.0.6}/pyproject.toml +0 -0
- {fastws_cli-0.0.4 → fastws_cli-0.0.6}/setup.cfg +0 -0
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
<!-- do not remove -->
|
|
2
2
|
|
|
3
|
-
## 0.0.
|
|
3
|
+
## 0.0.6
|
|
4
|
+
|
|
5
|
+
### New Features
|
|
6
|
+
|
|
7
|
+
- Add root-aware clone/pull helpers, auto-pull in ws-sync, and docstrings to CLI entry points ([#2](https://github.com/AnswerDotAI/fastws/issues/2))
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
## 0.0.5
|
|
4
11
|
|
|
5
12
|
### New Features
|
|
6
13
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fastws-cli
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.6
|
|
4
4
|
Summary: Fast workspace tools for multi-repo management
|
|
5
5
|
Author: Jeremy Howard
|
|
6
6
|
License: Apache-2.0
|
|
@@ -66,6 +66,7 @@ Show uncommitted changes and unpushed commits:
|
|
|
66
66
|
|
|
67
67
|
```bash
|
|
68
68
|
ws-status
|
|
69
|
+
ws-status --branches
|
|
69
70
|
```
|
|
70
71
|
|
|
71
72
|
### `ws-branches`
|
|
@@ -79,7 +80,7 @@ ws-branches --expected develop
|
|
|
79
80
|
|
|
80
81
|
### `ws-sync`
|
|
81
82
|
|
|
82
|
-
Sync the workspace metadata, install updates, and refresh Pyright editable paths.
|
|
83
|
+
Sync the workspace metadata, pull local repos, install updates, and refresh Pyright editable paths.
|
|
83
84
|
By default it uses the active venv parent as the workspace root, so you do not need to `cd` first:
|
|
84
85
|
It respects `tool.uv.workspace.members` and `exclude` when scanning local projects.
|
|
85
86
|
|
|
@@ -45,6 +45,7 @@ Show uncommitted changes and unpushed commits:
|
|
|
45
45
|
|
|
46
46
|
```bash
|
|
47
47
|
ws-status
|
|
48
|
+
ws-status --branches
|
|
48
49
|
```
|
|
49
50
|
|
|
50
51
|
### `ws-branches`
|
|
@@ -58,7 +59,7 @@ ws-branches --expected develop
|
|
|
58
59
|
|
|
59
60
|
### `ws-sync`
|
|
60
61
|
|
|
61
|
-
Sync the workspace metadata, install updates, and refresh Pyright editable paths.
|
|
62
|
+
Sync the workspace metadata, pull local repos, install updates, and refresh Pyright editable paths.
|
|
62
63
|
By default it uses the active venv parent as the workspace root, so you do not need to `cd` first:
|
|
63
64
|
It respects `tool.uv.workspace.members` and `exclude` when scanning local projects.
|
|
64
65
|
|
|
@@ -206,22 +206,26 @@ def _write_pyright_pth_files(root: Path) -> list[Path]:
|
|
|
206
206
|
created.append(pth)
|
|
207
207
|
return created
|
|
208
208
|
|
|
209
|
-
def _clone_one(repo: str) -> str:
|
|
210
|
-
d = _repo_dir(repo)
|
|
211
|
-
if
|
|
209
|
+
def _clone_one(repo: str, root: str = ".") -> str:
|
|
210
|
+
d = Path(root)/_repo_dir(repo)
|
|
211
|
+
if d.exists(): return f"✓ {d.name}: already exists"
|
|
212
212
|
try:
|
|
213
|
-
subprocess.run(["git", "clone", f"git@github.com:{repo}.git"], check=True, capture_output=True)
|
|
214
|
-
return f"✓ {d}: cloned"
|
|
215
|
-
except subprocess.CalledProcessError as e: return f"✗ {d}: {e.stderr.decode().strip()}"
|
|
213
|
+
subprocess.run(["git", "clone", f"git@github.com:{repo}.git", str(d)], check=True, capture_output=True)
|
|
214
|
+
return f"✓ {d.name}: cloned"
|
|
215
|
+
except subprocess.CalledProcessError as e: return f"✗ {d.name}: {e.stderr.decode().strip()}"
|
|
216
216
|
|
|
217
|
-
def _pull_one(repo: str) -> str:
|
|
218
|
-
d = _repo_dir(repo)
|
|
219
|
-
if not
|
|
217
|
+
def _pull_one(repo: str, root: str = ".") -> str:
|
|
218
|
+
d = Path(root)/_repo_dir(repo)
|
|
219
|
+
if not d.exists(): return f"✗ {d.name}: directory not found"
|
|
220
220
|
try:
|
|
221
|
-
res = subprocess.run(["git", "-C", d, "pull", "-q", "--stat"], check=True, capture_output=True, text=True)
|
|
222
|
-
return f"✓ {d}" + (f"\n{res.stdout.strip()}" if res.stdout.strip() else "")
|
|
221
|
+
res = subprocess.run(["git", "-C", str(d), "pull", "-q", "--stat"], check=True, capture_output=True, text=True)
|
|
222
|
+
return f"✓ {d.name}" + (f"\n{res.stdout.strip()}" if res.stdout.strip() else "")
|
|
223
223
|
except subprocess.CalledProcessError as e: return f"✗ {d}: {e.stderr.strip()}"
|
|
224
224
|
|
|
225
|
+
def _pull(repos: list[str], workers: int = 16, root: str = "."):
|
|
226
|
+
with ThreadPoolExecutor(max_workers=workers) as ex:
|
|
227
|
+
for result in as_completed([ex.submit(_pull_one, r, root) for r in repos]): print(result.result())
|
|
228
|
+
|
|
225
229
|
def ws_clone(
|
|
226
230
|
repos_file: str = "repos.txt", # File containing repo list (one per line: owner/repo)
|
|
227
231
|
workers: int = 16, # Number of parallel workers
|
|
@@ -235,27 +239,30 @@ def ws_clone(
|
|
|
235
239
|
def ws_clone_cli(
|
|
236
240
|
repos_file: str = "repos.txt", # File containing repo list (one per line: owner/repo)
|
|
237
241
|
workers: int = 16, # Number of parallel workers
|
|
238
|
-
):
|
|
242
|
+
):
|
|
243
|
+
"Clone all repos from a repos file."
|
|
244
|
+
ws_clone(repos_file, workers)
|
|
239
245
|
|
|
240
246
|
def ws_pull(
|
|
241
247
|
repos_file: str = "repos.txt", # File containing repo list
|
|
242
248
|
workers: int = 16, # Number of parallel workers
|
|
243
249
|
):
|
|
244
250
|
"Pull updates for all repos."
|
|
245
|
-
|
|
246
|
-
with ThreadPoolExecutor(max_workers=workers) as ex:
|
|
247
|
-
for result in as_completed([ex.submit(_pull_one, r) for r in repos]): print(result.result())
|
|
251
|
+
_pull(_load_repos(repos_file), workers)
|
|
248
252
|
|
|
249
253
|
@call_parse
|
|
250
254
|
def ws_pull_cli(
|
|
251
255
|
repos_file: str = "repos.txt", # File containing repo list
|
|
252
256
|
workers: int = 16, # Number of parallel workers
|
|
253
|
-
):
|
|
257
|
+
):
|
|
258
|
+
"Pull updates for all repos."
|
|
259
|
+
ws_pull(repos_file, workers)
|
|
254
260
|
|
|
255
261
|
def ws_status(
|
|
256
262
|
repos_file: str = "repos.txt", # File containing repo list
|
|
263
|
+
branches: bool = False, # Show unpushed commit details
|
|
257
264
|
):
|
|
258
|
-
"Show uncommitted changes and unpushed
|
|
265
|
+
"Show uncommitted changes and optionally unpushed commit details across repos."
|
|
259
266
|
repos = _load_repos(repos_file)
|
|
260
267
|
for repo in repos:
|
|
261
268
|
d = _repo_dir(repo)
|
|
@@ -265,18 +272,24 @@ def ws_status(
|
|
|
265
272
|
changes = g.status('-s') or ""
|
|
266
273
|
if isinstance(changes, list): changes = "\n".join(changes)
|
|
267
274
|
unpushed = ""
|
|
268
|
-
try:
|
|
275
|
+
try:
|
|
276
|
+
if branches: unpushed = g.log('--branches', '--not', '--remotes', format='%h %s') or ""
|
|
277
|
+
elif (branch := g.branch(show_current=True).strip()) in ('main', 'master'):
|
|
278
|
+
unpushed = g.log('@{upstream}..HEAD', '-1', format='%h %s') or ""
|
|
269
279
|
except Exception: pass
|
|
270
280
|
if isinstance(unpushed, list): unpushed = "\n".join(unpushed)
|
|
271
281
|
if changes or unpushed:
|
|
272
282
|
print(f"\n=== {d} ===")
|
|
273
283
|
if changes: print(changes)
|
|
274
|
-
if unpushed: print(unpushed)
|
|
284
|
+
if unpushed: print(unpushed if branches else "unpushed commits")
|
|
275
285
|
|
|
276
286
|
@call_parse
|
|
277
287
|
def ws_status_cli(
|
|
278
288
|
repos_file: str = "repos.txt", # File containing repo list
|
|
279
|
-
|
|
289
|
+
branches: bool = False, # Show unpushed commit details
|
|
290
|
+
):
|
|
291
|
+
"Show uncommitted changes and optionally unpushed commit details across repos."
|
|
292
|
+
ws_status(repos_file, branches)
|
|
280
293
|
|
|
281
294
|
def ws_branches(
|
|
282
295
|
repos_file: str = "repos.txt", # File containing repo list
|
|
@@ -300,7 +313,9 @@ def ws_branches(
|
|
|
300
313
|
def ws_branches_cli(
|
|
301
314
|
repos_file: str = "repos.txt", # File containing repo list
|
|
302
315
|
expected: str = "main", # Expected branch name
|
|
303
|
-
):
|
|
316
|
+
):
|
|
317
|
+
"Check if all repos are on the expected branch."
|
|
318
|
+
ws_branches(repos_file, expected)
|
|
304
319
|
|
|
305
320
|
def ws_sync(
|
|
306
321
|
workspace: str = "", # Workspace root; defaults to active venv parent when available
|
|
@@ -313,8 +328,10 @@ def ws_sync(
|
|
|
313
328
|
repos_path = _resolve_path(root, repos_file)
|
|
314
329
|
pyproject_path = _resolve_path(root, pyproject_file)
|
|
315
330
|
template_path = _resolve_path(root, template_file)
|
|
331
|
+
repos = _discover_ws_repos(root)
|
|
316
332
|
|
|
317
|
-
if missing_repos := _update_repos_file(repos_path,
|
|
333
|
+
if missing_repos := _update_repos_file(repos_path, repos): print(f"Added repos: {', '.join(missing_repos)}")
|
|
334
|
+
_pull(repos, root=str(root))
|
|
318
335
|
|
|
319
336
|
if missing_projects := _sync_ws_pyproject(pyproject_path, template_path, _ws_projects(root)): print(f"Added workspace projects: {', '.join(missing_projects)}")
|
|
320
337
|
|
|
@@ -327,7 +344,9 @@ def ws_sync_cli(
|
|
|
327
344
|
repos_file: str = "repos.txt", # Repo list to update from local git remotes
|
|
328
345
|
pyproject_file: str = "pyproject.toml", # Workspace pyproject to update
|
|
329
346
|
template_file: str = "pyproject.tmpl", # Template copied when pyproject.toml is missing
|
|
330
|
-
):
|
|
347
|
+
):
|
|
348
|
+
"Sync workspace metadata, run uv sync -U, and refresh Pyright editable paths."
|
|
349
|
+
ws_sync(workspace, repos_file, pyproject_file, template_file)
|
|
331
350
|
|
|
332
351
|
def ws_add(
|
|
333
352
|
repo: str, # Repo to add, e.g. AnswerDotAI/fastws
|
|
@@ -343,6 +362,7 @@ def ws_add(
|
|
|
343
362
|
added = _update_repos_file(repos_path, [repo])
|
|
344
363
|
if added: print(f"Added repo: {repo}")
|
|
345
364
|
else: print(f"Repo already present: {repo}")
|
|
365
|
+
print(_clone_one(repo, str(root)))
|
|
346
366
|
ws_sync(str(root), repos_file, pyproject_file, template_file)
|
|
347
367
|
|
|
348
368
|
@call_parse
|
|
@@ -352,4 +372,6 @@ def ws_add_cli(
|
|
|
352
372
|
repos_file: str = "repos.txt", # Repo list to update
|
|
353
373
|
pyproject_file: str = "pyproject.toml", # Workspace pyproject to update
|
|
354
374
|
template_file: str = "pyproject.tmpl", # Template copied when pyproject.toml is missing
|
|
355
|
-
):
|
|
375
|
+
):
|
|
376
|
+
"Add a repo to repos.txt and then run ws-sync."
|
|
377
|
+
ws_add(repo, workspace, repos_file, pyproject_file, template_file)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fastws-cli
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.6
|
|
4
4
|
Summary: Fast workspace tools for multi-repo management
|
|
5
5
|
Author: Jeremy Howard
|
|
6
6
|
License: Apache-2.0
|
|
@@ -66,6 +66,7 @@ Show uncommitted changes and unpushed commits:
|
|
|
66
66
|
|
|
67
67
|
```bash
|
|
68
68
|
ws-status
|
|
69
|
+
ws-status --branches
|
|
69
70
|
```
|
|
70
71
|
|
|
71
72
|
### `ws-branches`
|
|
@@ -79,7 +80,7 @@ ws-branches --expected develop
|
|
|
79
80
|
|
|
80
81
|
### `ws-sync`
|
|
81
82
|
|
|
82
|
-
Sync the workspace metadata, install updates, and refresh Pyright editable paths.
|
|
83
|
+
Sync the workspace metadata, pull local repos, install updates, and refresh Pyright editable paths.
|
|
83
84
|
By default it uses the active venv parent as the workspace root, so you do not need to `cd` first:
|
|
84
85
|
It respects `tool.uv.workspace.members` and `exclude` when scanning local projects.
|
|
85
86
|
|
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
from pathlib import Path
|
|
2
|
-
|
|
3
1
|
import fastws.core as core
|
|
4
2
|
|
|
5
3
|
|
|
@@ -98,6 +96,9 @@ def test_ws_sync_updates_workspace_and_runs_uv(tmp_path, monkeypatch):
|
|
|
98
96
|
if cmd[:5] == ["git", "-C", str(repo), "remote", "get-url"]:
|
|
99
97
|
class Res: stdout = "git@github.com:AnswerDotAI/repo1.git\n"
|
|
100
98
|
return Res()
|
|
99
|
+
if cmd[:4] == ["git", "-C", str(repo), "pull"]:
|
|
100
|
+
class Res: stdout = ""
|
|
101
|
+
return Res()
|
|
101
102
|
if cmd == ["uv", "sync", "-U"]:
|
|
102
103
|
class Res: stdout = ""
|
|
103
104
|
return Res()
|
|
@@ -112,6 +113,7 @@ def test_ws_sync_updates_workspace_and_runs_uv(tmp_path, monkeypatch):
|
|
|
112
113
|
assert 'newpkg = { workspace = true }' in pyproject
|
|
113
114
|
assert '"newpkg"' in pyproject
|
|
114
115
|
assert (site/"_pyright_editable_newpkg.pth").read_text() == "/tmp/ws/src/newpkg\n"
|
|
116
|
+
assert next(i for i,(cmd,_) in enumerate(calls) if cmd[:4] == ["git", "-C", str(repo), "pull"]) < next(i for i,(cmd,_) in enumerate(calls) if cmd == ["uv", "sync", "-U"])
|
|
115
117
|
assert any(cmd == ["uv", "sync", "-U"] and kwargs["cwd"] == tmp_path for cmd,kwargs in calls)
|
|
116
118
|
|
|
117
119
|
|
|
@@ -138,6 +140,9 @@ def test_ws_sync_uses_active_venv_parent_by_default(tmp_path, monkeypatch):
|
|
|
138
140
|
if cmd[:5] == ["git", "-C", str(repo), "remote", "get-url"]:
|
|
139
141
|
class Res: stdout = "git@github.com:AnswerDotAI/repo1.git\n"
|
|
140
142
|
return Res()
|
|
143
|
+
if cmd[:4] == ["git", "-C", str(repo), "pull"]:
|
|
144
|
+
class Res: stdout = ""
|
|
145
|
+
return Res()
|
|
141
146
|
if cmd == ["uv", "sync", "-U"]:
|
|
142
147
|
class Res: stdout = ""
|
|
143
148
|
return Res()
|
|
@@ -153,16 +158,79 @@ def test_ws_sync_uses_active_venv_parent_by_default(tmp_path, monkeypatch):
|
|
|
153
158
|
assert any(cmd == ["uv", "sync", "-U"] and kwargs["cwd"] == workspace for cmd,kwargs in calls)
|
|
154
159
|
|
|
155
160
|
|
|
156
|
-
def
|
|
161
|
+
def test_ws_add_clones_and_syncs(tmp_path, monkeypatch):
|
|
157
162
|
(tmp_path/"repos.txt").write_text("AnswerDotAI/existing\n")
|
|
158
|
-
|
|
163
|
+
sync_calls, clone_calls = [], []
|
|
159
164
|
|
|
160
|
-
def fake_sync(
|
|
161
|
-
|
|
165
|
+
def fake_sync(*a): sync_calls.append(a)
|
|
166
|
+
def fake_clone(repo, root="."):
|
|
167
|
+
clone_calls.append((repo, root))
|
|
168
|
+
return "✓ cloned"
|
|
162
169
|
|
|
163
170
|
monkeypatch.setattr(core, "ws_sync", fake_sync)
|
|
171
|
+
monkeypatch.setattr(core, "_clone_one", fake_clone)
|
|
164
172
|
|
|
165
173
|
core.ws_add("answerdotai/fastws", workspace=str(tmp_path))
|
|
166
174
|
|
|
167
175
|
assert (tmp_path/"repos.txt").read_text() == "AnswerDotAI/existing\nanswerdotai/fastws\n"
|
|
168
|
-
assert
|
|
176
|
+
assert clone_calls == [("answerdotai/fastws", str(tmp_path))]
|
|
177
|
+
assert sync_calls == [(str(tmp_path), "repos.txt", "pyproject.toml", "pyproject.tmpl")]
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def test_ws_status_summarizes_unpushed_commits_by_default(tmp_path, monkeypatch, capsys):
|
|
181
|
+
(tmp_path/"repo1").mkdir()
|
|
182
|
+
|
|
183
|
+
class FakeGit:
|
|
184
|
+
exists = True
|
|
185
|
+
def __init__(self, d): pass
|
|
186
|
+
def branch(self, **kwargs): return "main"
|
|
187
|
+
def status(self, *args): return ""
|
|
188
|
+
def log(self, *args, **kwargs): return "abc first commit"
|
|
189
|
+
|
|
190
|
+
monkeypatch.chdir(tmp_path)
|
|
191
|
+
monkeypatch.setattr(core, "_load_repos", lambda repos_file="repos.txt": ["AnswerDotAI/repo1"])
|
|
192
|
+
monkeypatch.setattr(core, "Git", FakeGit)
|
|
193
|
+
|
|
194
|
+
core.ws_status()
|
|
195
|
+
out = capsys.readouterr().out
|
|
196
|
+
|
|
197
|
+
assert "unpushed commits" in out
|
|
198
|
+
assert "abc first commit" not in out
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def test_ws_status_ignores_unpushed_commits_off_main_by_default(tmp_path, monkeypatch, capsys):
|
|
202
|
+
(tmp_path/"repo1").mkdir()
|
|
203
|
+
|
|
204
|
+
class FakeGit:
|
|
205
|
+
exists = True
|
|
206
|
+
def __init__(self, d): pass
|
|
207
|
+
def branch(self, **kwargs): return "feature"
|
|
208
|
+
def status(self, *args): return ""
|
|
209
|
+
def log(self, *args, **kwargs): return "abc first commit"
|
|
210
|
+
|
|
211
|
+
monkeypatch.chdir(tmp_path)
|
|
212
|
+
monkeypatch.setattr(core, "_load_repos", lambda repos_file="repos.txt": ["AnswerDotAI/repo1"])
|
|
213
|
+
monkeypatch.setattr(core, "Git", FakeGit)
|
|
214
|
+
|
|
215
|
+
core.ws_status()
|
|
216
|
+
|
|
217
|
+
assert capsys.readouterr().out == ""
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def test_ws_status_shows_unpushed_commits_with_branches_flag(tmp_path, monkeypatch, capsys):
|
|
221
|
+
(tmp_path/"repo1").mkdir()
|
|
222
|
+
|
|
223
|
+
class FakeGit:
|
|
224
|
+
exists = True
|
|
225
|
+
def __init__(self, d): pass
|
|
226
|
+
def branch(self, **kwargs): return "feature"
|
|
227
|
+
def status(self, *args): return ""
|
|
228
|
+
def log(self, *args, **kwargs): return "abc first commit"
|
|
229
|
+
|
|
230
|
+
monkeypatch.chdir(tmp_path)
|
|
231
|
+
monkeypatch.setattr(core, "_load_repos", lambda repos_file="repos.txt": ["AnswerDotAI/repo1"])
|
|
232
|
+
monkeypatch.setattr(core, "Git", FakeGit)
|
|
233
|
+
|
|
234
|
+
core.ws_status(branches=True)
|
|
235
|
+
|
|
236
|
+
assert "abc first commit" in capsys.readouterr().out
|
|
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
|