fastws-cli 0.0.2__tar.gz → 0.0.4__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.
@@ -0,0 +1,17 @@
1
+ <!-- do not remove -->
2
+
3
+ ## 0.0.4
4
+
5
+ ### New Features
6
+
7
+ - Add ws-sync and ws-add commands with workspace metadata sync and Pyright editable path support ([#1](https://github.com/AnswerDotAI/fastws/issues/1))
8
+
9
+
10
+ ## 0.0.3
11
+
12
+ - change names
13
+
14
+ ## 0.0.2
15
+
16
+ - init release
17
+
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fastws-cli
3
- Version: 0.0.2
3
+ Version: 0.0.4
4
4
  Summary: Fast workspace tools for multi-repo management
5
5
  Author: Jeremy Howard
6
6
  License: Apache-2.0
@@ -12,6 +12,7 @@ Description-Content-Type: text/markdown
12
12
  License-File: LICENSE
13
13
  Requires-Dist: fastcore>=1.5.29
14
14
  Requires-Dist: fastgit>=0.0.2
15
+ Requires-Dist: tomli; python_version < "3.11"
15
16
  Provides-Extra: dev
16
17
  Requires-Dist: fastship; extra == "dev"
17
18
  Requires-Dist: build; extra == "dev"
@@ -41,37 +42,57 @@ AnswerDotAI/fastws
41
42
 
42
43
  ## Commands
43
44
 
44
- ### `ws_clone`
45
+ ### `ws-clone`
45
46
 
46
47
  Clone all repos from your repos file:
47
48
 
48
49
  ```bash
49
- ws_clone
50
- ws_clone --repos-file myrepos.txt
51
- ws_clone --workers 8
50
+ ws-clone
51
+ ws-clone --repos-file myrepos.txt
52
+ ws-clone --workers 8
52
53
  ```
53
54
 
54
- ### `ws_pull`
55
+ ### `ws-pull`
55
56
 
56
57
  Pull updates for all repos (parallel):
57
58
 
58
59
  ```bash
59
- ws_pull
60
+ ws-pull
60
61
  ```
61
62
 
62
- ### `ws_status`
63
+ ### `ws-status`
63
64
 
64
65
  Show uncommitted changes and unpushed commits:
65
66
 
66
67
  ```bash
67
- ws_status
68
+ ws-status
68
69
  ```
69
70
 
70
- ### `ws_branches`
71
+ ### `ws-branches`
71
72
 
72
73
  Check if all repos are on the expected branch:
73
74
 
74
75
  ```bash
75
- ws_branches
76
- ws_branches --expected develop
76
+ ws-branches
77
+ ws-branches --expected develop
78
+ ```
79
+
80
+ ### `ws-sync`
81
+
82
+ Sync the workspace metadata, install updates, and refresh Pyright editable paths.
83
+ By default it uses the active venv parent as the workspace root, so you do not need to `cd` first:
84
+ It respects `tool.uv.workspace.members` and `exclude` when scanning local projects.
85
+
86
+ ```bash
87
+ ws-sync
88
+ ws-sync --workspace ~/aai-ws
89
+ ```
90
+
91
+ ### `ws-add`
92
+
93
+ Add a repo to `repos.txt`, then run `ws-sync`:
94
+
95
+ ```bash
96
+ ws-add AnswerDotAI/fastws
97
+ ws-add answerdotai/fastws
77
98
  ```
@@ -0,0 +1,77 @@
1
+ # fastws
2
+
3
+ Fast workspace tools for multi-repo management.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install fastws-cli
9
+ ```
10
+
11
+ ## Setup
12
+
13
+ Create a `repos.txt` file listing your repos (one per line):
14
+
15
+ ```
16
+ AnswerDotAI/fastcore
17
+ AnswerDotAI/fastgit
18
+ AnswerDotAI/fastship
19
+ AnswerDotAI/fastws
20
+ ```
21
+
22
+ ## Commands
23
+
24
+ ### `ws-clone`
25
+
26
+ Clone all repos from your repos file:
27
+
28
+ ```bash
29
+ ws-clone
30
+ ws-clone --repos-file myrepos.txt
31
+ ws-clone --workers 8
32
+ ```
33
+
34
+ ### `ws-pull`
35
+
36
+ Pull updates for all repos (parallel):
37
+
38
+ ```bash
39
+ ws-pull
40
+ ```
41
+
42
+ ### `ws-status`
43
+
44
+ Show uncommitted changes and unpushed commits:
45
+
46
+ ```bash
47
+ ws-status
48
+ ```
49
+
50
+ ### `ws-branches`
51
+
52
+ Check if all repos are on the expected branch:
53
+
54
+ ```bash
55
+ ws-branches
56
+ ws-branches --expected develop
57
+ ```
58
+
59
+ ### `ws-sync`
60
+
61
+ Sync the workspace metadata, install updates, and refresh Pyright editable paths.
62
+ By default it uses the active venv parent as the workspace root, so you do not need to `cd` first:
63
+ It respects `tool.uv.workspace.members` and `exclude` when scanning local projects.
64
+
65
+ ```bash
66
+ ws-sync
67
+ ws-sync --workspace ~/aai-ws
68
+ ```
69
+
70
+ ### `ws-add`
71
+
72
+ Add a repo to `repos.txt`, then run `ws-sync`:
73
+
74
+ ```bash
75
+ ws-add AnswerDotAI/fastws
76
+ ws-add answerdotai/fastws
77
+ ```
@@ -0,0 +1,5 @@
1
+ __version__ = "0.0.4"
2
+
3
+
4
+ from .core import *
5
+
@@ -0,0 +1,355 @@
1
+ "Fast workspace tools for multi-repo management."
2
+
3
+ __all__ = ["ws_clone", "ws_clone_cli", "ws_pull", "ws_pull_cli", "ws_status", "ws_status_cli", "ws_branches", "ws_branches_cli",
4
+ "ws_sync", "ws_sync_cli", "ws_add", "ws_add_cli"]
5
+
6
+ import ast, fnmatch, os, re, shutil, subprocess
7
+ from pathlib import Path
8
+ from concurrent.futures import ThreadPoolExecutor, as_completed
9
+
10
+ from fastcore.script import call_parse
11
+ from fastgit import Git
12
+
13
+ try: import tomllib
14
+ except ModuleNotFoundError: import tomli as tomllib
15
+
16
+ def _load_repos(repos_file: str = "repos.txt") -> list[str]:
17
+ p = Path(repos_file)
18
+ if not p.exists(): raise SystemExit(f"File not found: {repos_file}")
19
+ return [line.strip() for line in p.read_text().splitlines() if line.strip() and not line.startswith("#")]
20
+
21
+ def _repo_dir(repo: str) -> str: return repo.split("/")[-1]
22
+
23
+ def _resolve_path(root: Path, path: str) -> Path:
24
+ p = Path(path)
25
+ return p if p.is_absolute() else root/p
26
+
27
+ def _repo_key(repo: str) -> str: return repo.strip().rstrip("/").removesuffix(".git").casefold()
28
+
29
+ def _pkg_key(name: str) -> str: return name.casefold()
30
+
31
+ def _dep_key(dep: str) -> str:
32
+ dep = dep.split(";", 1)[0].strip()
33
+ dep = re.split(r"[\s<>=!~]", dep, maxsplit=1)[0]
34
+ return dep.split("[", 1)[0].casefold()
35
+
36
+ def _ws_root(workspace: str = "", repos_file: str = "repos.txt", pyproject_file: str = "pyproject.toml",
37
+ template_file: str = "pyproject.tmpl") -> Path:
38
+ if workspace: return Path(workspace).expanduser().resolve()
39
+ for env_name in "UV_PROJECT_ENVIRONMENT","VIRTUAL_ENV":
40
+ if not (env := os.environ.get(env_name)): continue
41
+ root = Path(env).expanduser().resolve().parent
42
+ if any((_resolve_path(root, repos_file).exists(), _resolve_path(root, pyproject_file).exists(), _resolve_path(root, template_file).exists())):
43
+ return root
44
+ return Path.cwd().resolve()
45
+
46
+ def _parse_github_repo(remote: str) -> str|None:
47
+ m = re.search(r"github\.com[:/](?P<owner>[^/]+)/(?P<repo>[^/]+?)(?:\.git)?/?$", remote.strip())
48
+ return f"{m['owner']}/{m['repo']}" if m else None
49
+
50
+ def _normalize_repo(repo: str) -> str:
51
+ repo = repo.strip().rstrip("/").removesuffix(".git")
52
+ if parsed := _parse_github_repo(repo): return parsed
53
+ if re.fullmatch(r"[^/\s]+/[^/\s]+", repo): return repo
54
+ raise SystemExit(f"Invalid repo: {repo}. Expected owner/repo or GitHub URL")
55
+
56
+ def _ws_cfg(root: Path):
57
+ pyproject = root/"pyproject.toml"
58
+ if not pyproject.exists(): return ["./*"], []
59
+ try: data = tomllib.loads(pyproject.read_text())
60
+ except tomllib.TOMLDecodeError: return ["./*"], []
61
+ ws = data.get("tool", {}).get("uv", {}).get("workspace", {})
62
+ members = ws.get("members") or ["./*"]
63
+ exclude = ws.get("exclude") or []
64
+ return members, exclude
65
+
66
+ def _matches_ws(name: str, pattern: str) -> bool:
67
+ pattern = pattern.strip()
68
+ return any(fnmatch.fnmatch(candidate, normalized) for candidate in (name, f"./{name}") for normalized in (pattern, pattern.removeprefix("./")))
69
+
70
+ def _is_ws_dir(d: Path, members, exclude) -> bool:
71
+ return d.is_dir() and not d.name.startswith(".") and any(_matches_ws(d.name, o) for o in members) and not any(_matches_ws(d.name, o) for o in exclude)
72
+
73
+ def _ws_dirs(root: Path) -> list[Path]:
74
+ members, exclude = _ws_cfg(root)
75
+ return [d for d in sorted(root.iterdir()) if _is_ws_dir(d, members, exclude)]
76
+
77
+ def _discover_ws_repos(root: Path) -> list[str]:
78
+ repos = []
79
+ for d in (o for o in _ws_dirs(root) if (o/'.git').exists()):
80
+ try: res = subprocess.run(["git", "-C", str(d), "remote", "get-url", "origin"], check=True, capture_output=True, text=True)
81
+ except subprocess.CalledProcessError: continue
82
+ if repo := _parse_github_repo(res.stdout): repos.append(repo)
83
+ return repos
84
+
85
+ def _update_repos_file(repos_path: Path, repos: list[str]) -> list[str]:
86
+ existing = _load_repos(repos_path) if repos_path.exists() else []
87
+ seen = {_repo_key(repo) for repo in existing}
88
+ missing = []
89
+ for repo in repos:
90
+ if (key := _repo_key(repo)) in seen: continue
91
+ seen.add(key)
92
+ missing.append(repo)
93
+ if not missing: return []
94
+ content = repos_path.read_text() if repos_path.exists() else ""
95
+ if content and not content.endswith("\n"): content += "\n"
96
+ repos_path.write_text(content + "\n".join(missing) + "\n")
97
+ return missing
98
+
99
+ def _read_pyproject_name(path: Path) -> str|None:
100
+ try: data = tomllib.loads(path.read_text())
101
+ except tomllib.TOMLDecodeError:
102
+ print(f"Skipping invalid TOML: {path}")
103
+ return None
104
+ name = data.get("project", {}).get("name")
105
+ return name if isinstance(name, str) and name and "{" not in name and "}" not in name else None
106
+
107
+ def _ws_projects(root: Path) -> list[str]:
108
+ return [name for d in (o for o in _ws_dirs(root) if (o/"pyproject.toml").exists())
109
+ if (name := _read_pyproject_name(d/"pyproject.toml"))]
110
+
111
+ def _table_span(content: str, name: str) -> tuple[int,int]|None:
112
+ m = re.search(rf"(?m)^\[{re.escape(name)}\]\s*$", content)
113
+ if not m: return None
114
+ n = re.search(r"(?m)^\[", content[m.end():])
115
+ end = m.end()+n.start() if n else len(content)
116
+ return m.start(), end
117
+
118
+ def _replace_table(content: str, name: str, body: str) -> str:
119
+ table = f"[{name}]\n{body.rstrip()}\n\n"
120
+ if not (span := _table_span(content, name)): return content.rstrip() + "\n\n" + table
121
+ start,end = span
122
+ return content[:start] + table + content[end:]
123
+
124
+ def _find_array_end(content: str, start: int) -> int:
125
+ depth = 0
126
+ in_str = escaped = False
127
+ for i,ch in enumerate(content[start:], start):
128
+ if in_str:
129
+ if escaped: escaped = False
130
+ elif ch == "\\": escaped = True
131
+ elif ch == '"': in_str = False
132
+ continue
133
+ if ch == '"': in_str = True
134
+ elif ch == "[": depth += 1
135
+ elif ch == "]":
136
+ depth -= 1
137
+ if depth == 0: return i
138
+ raise ValueError("Unterminated TOML array")
139
+
140
+ def _replace_project_dependencies(content: str, deps: list[str]) -> str:
141
+ if not (span := _table_span(content, "project")): raise SystemExit("Missing [project] table in pyproject.toml")
142
+ start,end = span
143
+ section = content[start:end]
144
+ dep_block = "dependencies = [\n" + "".join(f' "{dep}",\n' for dep in deps) + "]"
145
+ if m := re.search(r"(?m)^dependencies\s*=\s*\[", section):
146
+ arr_start = m.end()-1
147
+ arr_end = _find_array_end(section, arr_start)
148
+ section = section[:m.start()] + dep_block + section[arr_end+1:]
149
+ else: section = section.rstrip() + "\n" + dep_block + "\n"
150
+ return content[:start] + section + content[end:]
151
+
152
+ def _sync_ws_pyproject(pyproject_path: Path, template_path: Path, projects: list[str]) -> list[str]:
153
+ if not pyproject_path.exists():
154
+ if not template_path.exists(): raise SystemExit(f"File not found: {template_path}")
155
+ shutil.copyfile(template_path, pyproject_path)
156
+ content = pyproject_path.read_text()
157
+ data = tomllib.loads(content)
158
+ sources = dict(data.get("tool", {}).get("uv", {}).get("sources", {}))
159
+ source_keys = {_pkg_key(proj) for proj in sources}
160
+ missing = [proj for proj in projects if _pkg_key(proj) not in source_keys]
161
+ if not missing: return []
162
+ for proj in missing: sources[proj] = {"workspace": True}
163
+ deps = list(data.get("project", {}).get("dependencies", []))
164
+ dep_keys = {_dep_key(dep) for dep in deps}
165
+ for proj in missing:
166
+ if _pkg_key(proj) in dep_keys: continue
167
+ deps.append(proj)
168
+ dep_keys.add(_pkg_key(proj))
169
+ source_lines = "\n".join(f"{proj} = {{ workspace = true }}" for proj in sources)
170
+ content = _replace_table(content, "tool.uv.sources", source_lines)
171
+ content = _replace_project_dependencies(content, deps)
172
+ pyproject_path.write_text(content)
173
+ return missing
174
+
175
+ def _editable_mapping(path: Path) -> dict[str,str]:
176
+ tree = ast.parse(path.read_text(), filename=str(path))
177
+ for node in tree.body:
178
+ if isinstance(node, ast.Assign) and any(isinstance(o, ast.Name) and o.id == "MAPPING" for o in node.targets):
179
+ data = ast.literal_eval(node.value)
180
+ if isinstance(data, dict): return {str(k): str(v) for k,v in data.items()}
181
+ if isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name) and node.target.id == "MAPPING":
182
+ data = ast.literal_eval(node.value)
183
+ if isinstance(data, dict): return {str(k): str(v) for k,v in data.items()}
184
+ return {}
185
+
186
+ def _site_packages(root: Path) -> Path|None:
187
+ envs = []
188
+ if uv_env := os.environ.get("UV_PROJECT_ENVIRONMENT"): envs.append(Path(uv_env))
189
+ envs.append(root/".venv")
190
+ if virtual_env := os.environ.get("VIRTUAL_ENV"): envs.append(Path(virtual_env))
191
+ for env in envs:
192
+ candidates = sorted(env.glob("lib/python*/site-packages")) + sorted(env.glob("Lib/site-packages"))
193
+ if candidates: return candidates[0]
194
+ return None
195
+
196
+ def _write_pyright_pth_files(root: Path) -> list[Path]:
197
+ site = _site_packages(root)
198
+ if not site:
199
+ print("No site-packages directory found for editable Pyright paths")
200
+ return []
201
+ created = []
202
+ for finder in sorted(site.glob("__editable__*_finder.py")):
203
+ for pkg,path in _editable_mapping(finder).items():
204
+ pth = site/f"_pyright_editable_{pkg}.pth"
205
+ pth.write_text(str(Path(path).parent) + "\n")
206
+ created.append(pth)
207
+ return created
208
+
209
+ def _clone_one(repo: str) -> str:
210
+ d = _repo_dir(repo)
211
+ if Path(d).exists(): return f"✓ {d}: already exists"
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()}"
216
+
217
+ def _pull_one(repo: str) -> str:
218
+ d = _repo_dir(repo)
219
+ if not Path(d).exists(): return f"✗ {d}: directory not found"
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 "")
223
+ except subprocess.CalledProcessError as e: return f"✗ {d}: {e.stderr.strip()}"
224
+
225
+ def ws_clone(
226
+ repos_file: str = "repos.txt", # File containing repo list (one per line: owner/repo)
227
+ workers: int = 16, # Number of parallel workers
228
+ ):
229
+ "Clone all repos from a repos file."
230
+ repos = _load_repos(repos_file)
231
+ with ThreadPoolExecutor(max_workers=workers) as ex:
232
+ for result in as_completed([ex.submit(_clone_one, r) for r in repos]): print(result.result())
233
+
234
+ @call_parse
235
+ def ws_clone_cli(
236
+ repos_file: str = "repos.txt", # File containing repo list (one per line: owner/repo)
237
+ workers: int = 16, # Number of parallel workers
238
+ ): ws_clone(repos_file, workers)
239
+
240
+ def ws_pull(
241
+ repos_file: str = "repos.txt", # File containing repo list
242
+ workers: int = 16, # Number of parallel workers
243
+ ):
244
+ "Pull updates for all repos."
245
+ repos = _load_repos(repos_file)
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())
248
+
249
+ @call_parse
250
+ def ws_pull_cli(
251
+ repos_file: str = "repos.txt", # File containing repo list
252
+ workers: int = 16, # Number of parallel workers
253
+ ): ws_pull(repos_file, workers)
254
+
255
+ def ws_status(
256
+ repos_file: str = "repos.txt", # File containing repo list
257
+ ):
258
+ "Show uncommitted changes and unpushed commits across repos."
259
+ repos = _load_repos(repos_file)
260
+ for repo in repos:
261
+ d = _repo_dir(repo)
262
+ if not Path(d).exists(): continue
263
+ g = Git(d)
264
+ if not g.exists: continue
265
+ changes = g.status('-s') or ""
266
+ if isinstance(changes, list): changes = "\n".join(changes)
267
+ unpushed = ""
268
+ try: unpushed = g.log('--branches', '--not', '--remotes', format='%h %s') or ""
269
+ except Exception: pass
270
+ if isinstance(unpushed, list): unpushed = "\n".join(unpushed)
271
+ if changes or unpushed:
272
+ print(f"\n=== {d} ===")
273
+ if changes: print(changes)
274
+ if unpushed: print(unpushed)
275
+
276
+ @call_parse
277
+ def ws_status_cli(
278
+ repos_file: str = "repos.txt", # File containing repo list
279
+ ): ws_status(repos_file)
280
+
281
+ def ws_branches(
282
+ repos_file: str = "repos.txt", # File containing repo list
283
+ expected: str = "main", # Expected branch name
284
+ ):
285
+ "Check if all repos are on the expected branch."
286
+ repos = _load_repos(repos_file)
287
+ for repo in repos:
288
+ d = _repo_dir(repo)
289
+ if not Path(d).exists():
290
+ print(f"⚠️ {d}: directory not found")
291
+ continue
292
+ g = Git(d)
293
+ if not g.exists:
294
+ print(f"⚠️ {d}: not a git repo")
295
+ continue
296
+ branch = g.branch(show_current=True).strip()
297
+ print(f"✓ {d}: OK (on {expected})" if branch == expected else f"⚠️ {d}: WARNING (on {branch})")
298
+
299
+ @call_parse
300
+ def ws_branches_cli(
301
+ repos_file: str = "repos.txt", # File containing repo list
302
+ expected: str = "main", # Expected branch name
303
+ ): ws_branches(repos_file, expected)
304
+
305
+ def ws_sync(
306
+ workspace: str = "", # Workspace root; defaults to active venv parent when available
307
+ repos_file: str = "repos.txt", # Repo list to update from local git remotes
308
+ pyproject_file: str = "pyproject.toml", # Workspace pyproject to update
309
+ template_file: str = "pyproject.tmpl", # Template copied when pyproject.toml is missing
310
+ ):
311
+ "Sync workspace metadata, run uv sync -U, and refresh Pyright editable paths."
312
+ root = _ws_root(workspace, repos_file, pyproject_file, template_file)
313
+ repos_path = _resolve_path(root, repos_file)
314
+ pyproject_path = _resolve_path(root, pyproject_file)
315
+ template_path = _resolve_path(root, template_file)
316
+
317
+ if missing_repos := _update_repos_file(repos_path, _discover_ws_repos(root)): print(f"Added repos: {', '.join(missing_repos)}")
318
+
319
+ if missing_projects := _sync_ws_pyproject(pyproject_path, template_path, _ws_projects(root)): print(f"Added workspace projects: {', '.join(missing_projects)}")
320
+
321
+ subprocess.run(["uv", "sync", "-U"], check=True, cwd=root)
322
+ _write_pyright_pth_files(root)
323
+
324
+ @call_parse
325
+ def ws_sync_cli(
326
+ workspace: str = "", # Workspace root; defaults to active venv parent when available
327
+ repos_file: str = "repos.txt", # Repo list to update from local git remotes
328
+ pyproject_file: str = "pyproject.toml", # Workspace pyproject to update
329
+ template_file: str = "pyproject.tmpl", # Template copied when pyproject.toml is missing
330
+ ): ws_sync(workspace, repos_file, pyproject_file, template_file)
331
+
332
+ def ws_add(
333
+ repo: str, # Repo to add, e.g. AnswerDotAI/fastws
334
+ workspace: str = "", # Workspace root; defaults to active venv parent when available
335
+ repos_file: str = "repos.txt", # Repo list to update
336
+ pyproject_file: str = "pyproject.toml", # Workspace pyproject to update
337
+ template_file: str = "pyproject.tmpl", # Template copied when pyproject.toml is missing
338
+ ):
339
+ "Add a repo to repos.txt and then run ws-sync."
340
+ root = _ws_root(workspace, repos_file, pyproject_file, template_file)
341
+ repos_path = _resolve_path(root, repos_file)
342
+ repo = _normalize_repo(repo)
343
+ added = _update_repos_file(repos_path, [repo])
344
+ if added: print(f"Added repo: {repo}")
345
+ else: print(f"Repo already present: {repo}")
346
+ ws_sync(str(root), repos_file, pyproject_file, template_file)
347
+
348
+ @call_parse
349
+ def ws_add_cli(
350
+ repo: str, # Repo to add, e.g. AnswerDotAI/fastws
351
+ workspace: str = "", # Workspace root; defaults to active venv parent when available
352
+ repos_file: str = "repos.txt", # Repo list to update
353
+ pyproject_file: str = "pyproject.toml", # Workspace pyproject to update
354
+ template_file: str = "pyproject.tmpl", # Template copied when pyproject.toml is missing
355
+ ): 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.2
3
+ Version: 0.0.4
4
4
  Summary: Fast workspace tools for multi-repo management
5
5
  Author: Jeremy Howard
6
6
  License: Apache-2.0
@@ -12,6 +12,7 @@ Description-Content-Type: text/markdown
12
12
  License-File: LICENSE
13
13
  Requires-Dist: fastcore>=1.5.29
14
14
  Requires-Dist: fastgit>=0.0.2
15
+ Requires-Dist: tomli; python_version < "3.11"
15
16
  Provides-Extra: dev
16
17
  Requires-Dist: fastship; extra == "dev"
17
18
  Requires-Dist: build; extra == "dev"
@@ -41,37 +42,57 @@ AnswerDotAI/fastws
41
42
 
42
43
  ## Commands
43
44
 
44
- ### `ws_clone`
45
+ ### `ws-clone`
45
46
 
46
47
  Clone all repos from your repos file:
47
48
 
48
49
  ```bash
49
- ws_clone
50
- ws_clone --repos-file myrepos.txt
51
- ws_clone --workers 8
50
+ ws-clone
51
+ ws-clone --repos-file myrepos.txt
52
+ ws-clone --workers 8
52
53
  ```
53
54
 
54
- ### `ws_pull`
55
+ ### `ws-pull`
55
56
 
56
57
  Pull updates for all repos (parallel):
57
58
 
58
59
  ```bash
59
- ws_pull
60
+ ws-pull
60
61
  ```
61
62
 
62
- ### `ws_status`
63
+ ### `ws-status`
63
64
 
64
65
  Show uncommitted changes and unpushed commits:
65
66
 
66
67
  ```bash
67
- ws_status
68
+ ws-status
68
69
  ```
69
70
 
70
- ### `ws_branches`
71
+ ### `ws-branches`
71
72
 
72
73
  Check if all repos are on the expected branch:
73
74
 
74
75
  ```bash
75
- ws_branches
76
- ws_branches --expected develop
76
+ ws-branches
77
+ ws-branches --expected develop
78
+ ```
79
+
80
+ ### `ws-sync`
81
+
82
+ Sync the workspace metadata, install updates, and refresh Pyright editable paths.
83
+ By default it uses the active venv parent as the workspace root, so you do not need to `cd` first:
84
+ It respects `tool.uv.workspace.members` and `exclude` when scanning local projects.
85
+
86
+ ```bash
87
+ ws-sync
88
+ ws-sync --workspace ~/aai-ws
89
+ ```
90
+
91
+ ### `ws-add`
92
+
93
+ Add a repo to `repos.txt`, then run `ws-sync`:
94
+
95
+ ```bash
96
+ ws-add AnswerDotAI/fastws
97
+ ws-add answerdotai/fastws
77
98
  ```
@@ -10,4 +10,5 @@ fastws_cli.egg-info/SOURCES.txt
10
10
  fastws_cli.egg-info/dependency_links.txt
11
11
  fastws_cli.egg-info/entry_points.txt
12
12
  fastws_cli.egg-info/requires.txt
13
- fastws_cli.egg-info/top_level.txt
13
+ fastws_cli.egg-info/top_level.txt
14
+ tests/test_sync.py
@@ -0,0 +1,7 @@
1
+ [console_scripts]
2
+ ws-add = fastws.core:ws_add_cli
3
+ ws-branches = fastws.core:ws_branches_cli
4
+ ws-clone = fastws.core:ws_clone_cli
5
+ ws-pull = fastws.core:ws_pull_cli
6
+ ws-status = fastws.core:ws_status_cli
7
+ ws-sync = fastws.core:ws_sync_cli
@@ -1,6 +1,9 @@
1
1
  fastcore>=1.5.29
2
2
  fastgit>=0.0.2
3
3
 
4
+ [:python_version < "3.11"]
5
+ tomli
6
+
4
7
  [dev]
5
8
  fastship
6
9
  build
@@ -18,6 +18,7 @@ classifiers = [
18
18
  dependencies = [
19
19
  "fastcore>=1.5.29",
20
20
  "fastgit>=0.0.2",
21
+ "tomli; python_version < '3.11'",
21
22
  ]
22
23
 
23
24
  [project.optional-dependencies]
@@ -28,10 +29,12 @@ dev = [
28
29
  ]
29
30
 
30
31
  [project.scripts]
31
- ws_clone = "fastws.core:ws_clone_cli"
32
- ws_pull = "fastws.core:ws_pull_cli"
33
- ws_status = "fastws.core:ws_status_cli"
34
- ws_branches = "fastws.core:ws_branches_cli"
32
+ ws-clone = "fastws.core:ws_clone_cli"
33
+ ws-pull = "fastws.core:ws_pull_cli"
34
+ ws-status = "fastws.core:ws_status_cli"
35
+ ws-branches = "fastws.core:ws_branches_cli"
36
+ ws-sync = "fastws.core:ws_sync_cli"
37
+ ws-add = "fastws.core:ws_add_cli"
35
38
 
36
39
  [project.urls]
37
40
  Homepage = "https://github.com/AnswerDotAI/fastws"
@@ -0,0 +1,168 @@
1
+ from pathlib import Path
2
+
3
+ import fastws.core as core
4
+
5
+
6
+ def test_update_repos_file_appends_missing_entries(tmp_path):
7
+ repos_path = tmp_path/"repos.txt"
8
+ repos_path.write_text("AnswerDotAI/existing\n")
9
+
10
+ added = core._update_repos_file(repos_path, ["AnswerDotAI/existing", "fastai/fastai"])
11
+
12
+ assert added == ["fastai/fastai"]
13
+ assert repos_path.read_text() == "AnswerDotAI/existing\nfastai/fastai\n"
14
+
15
+
16
+ def test_update_repos_file_is_case_insensitive(tmp_path):
17
+ repos_path = tmp_path/"repos.txt"
18
+ repos_path.write_text("AnswerDotAI/fastws\n")
19
+
20
+ added = core._update_repos_file(repos_path, ["answerdotai/fastws"])
21
+
22
+ assert added == []
23
+ assert repos_path.read_text() == "AnswerDotAI/fastws\n"
24
+
25
+
26
+ def test_sync_workspace_pyproject_copies_template_and_adds_projects(tmp_path):
27
+ (tmp_path/"pyproject.tmpl").write_text('[project]\nname = "uvws"\ndependencies = [\n "ipython>=8.34.0",\n]\n\n[tool.uv.sources]\n\n')
28
+ alpha = tmp_path/"alpha"
29
+ beta = tmp_path/"beta"
30
+ alpha.mkdir()
31
+ beta.mkdir()
32
+ (alpha/"pyproject.toml").write_text('[project]\nname = "alpha"\n')
33
+ (beta/"pyproject.toml").write_text('[project]\nname = "beta"\n')
34
+
35
+ added = core._sync_ws_pyproject(tmp_path/"pyproject.toml", tmp_path/"pyproject.tmpl", ["alpha", "beta"])
36
+ content = (tmp_path/"pyproject.toml").read_text()
37
+
38
+ assert added == ["alpha", "beta"]
39
+ assert 'alpha = { workspace = true }' in content
40
+ assert 'beta = { workspace = true }' in content
41
+ assert '"alpha"' in content
42
+ assert '"beta"' in content
43
+
44
+
45
+ def test_sync_workspace_pyproject_skips_case_only_source_differences(tmp_path):
46
+ pyproject = tmp_path/"pyproject.toml"
47
+ pyproject.write_text('[project]\nname = "uvws"\ndependencies = ["FastWS"]\n\n[tool.uv.sources]\nFastWS = { workspace = true }\n')
48
+
49
+ added = core._sync_ws_pyproject(pyproject, tmp_path/"pyproject.tmpl", ["fastws"])
50
+
51
+ assert added == []
52
+ assert pyproject.read_text() == '[project]\nname = "uvws"\ndependencies = ["FastWS"]\n\n[tool.uv.sources]\nFastWS = { workspace = true }\n'
53
+
54
+
55
+ def test_workspace_projects_skip_excluded_dirs_and_template_names(tmp_path):
56
+ (tmp_path/"pyproject.toml").write_text('[tool.uv.workspace]\nmembers = ["./*"]\nexclude = ["skip-*"]\n')
57
+ keep = tmp_path/"keep"
58
+ skip = tmp_path/"skip-template"
59
+ templ = tmp_path/"template"
60
+ keep.mkdir()
61
+ skip.mkdir()
62
+ templ.mkdir()
63
+ (keep/"pyproject.toml").write_text('[project]\nname = "keepme"\n')
64
+ (skip/"pyproject.toml").write_text('[project]\nname = "skipme"\n')
65
+ (templ/"pyproject.toml").write_text('[project]\nname = "{repo}"\n')
66
+
67
+ assert core._ws_projects(tmp_path) == ["keepme"]
68
+
69
+
70
+ def test_write_pyright_pth_files_from_editable_finder(tmp_path):
71
+ site = tmp_path/".venv"/"lib"/"python3.12"/"site-packages"
72
+ site.mkdir(parents=True)
73
+ (site/"__editable___demo_finder.py").write_text("MAPPING: dict[str, str] = {'demo': '/tmp/workspace/src/demo/__init__.py', 'tool': '/tmp/workspace/tool.py'}\n")
74
+
75
+ created = core._write_pyright_pth_files(tmp_path)
76
+
77
+ assert [p.name for p in created] == ["_pyright_editable_demo.pth", "_pyright_editable_tool.pth"]
78
+ assert (site/"_pyright_editable_demo.pth").read_text() == "/tmp/workspace/src/demo\n"
79
+ assert (site/"_pyright_editable_tool.pth").read_text() == "/tmp/workspace\n"
80
+
81
+
82
+ def test_ws_sync_updates_workspace_and_runs_uv(tmp_path, monkeypatch):
83
+ (tmp_path/"repos.txt").write_text("AnswerDotAI/existing\n")
84
+ (tmp_path/"pyproject.tmpl").write_text('[project]\nname = "uvws"\ndependencies = [\n]\n\n[tool.uv.sources]\n\n')
85
+ pkg = tmp_path/"newpkg"
86
+ repo = tmp_path/"repo1"
87
+ pkg.mkdir()
88
+ repo.mkdir()
89
+ (pkg/"pyproject.toml").write_text('[project]\nname = "newpkg"\n')
90
+ (repo/".git").write_text("gitdir: .git/worktrees/repo1\n")
91
+ site = tmp_path/".venv"/"lib"/"python3.12"/"site-packages"
92
+ site.mkdir(parents=True)
93
+ (site/"__editable___newpkg_finder.py").write_text("MAPPING: dict[str, str] = {'newpkg': '/tmp/ws/src/newpkg/__init__.py'}\n")
94
+ calls = []
95
+
96
+ def fake_run(cmd, **kwargs):
97
+ calls.append((cmd, kwargs))
98
+ if cmd[:5] == ["git", "-C", str(repo), "remote", "get-url"]:
99
+ class Res: stdout = "git@github.com:AnswerDotAI/repo1.git\n"
100
+ return Res()
101
+ if cmd == ["uv", "sync", "-U"]:
102
+ class Res: stdout = ""
103
+ return Res()
104
+ raise AssertionError(f"Unexpected command: {cmd}")
105
+
106
+ monkeypatch.setattr(core.subprocess, "run", fake_run)
107
+
108
+ core.ws_sync(workspace=str(tmp_path))
109
+
110
+ assert "AnswerDotAI/repo1" in (tmp_path/"repos.txt").read_text()
111
+ pyproject = (tmp_path/"pyproject.toml").read_text()
112
+ assert 'newpkg = { workspace = true }' in pyproject
113
+ assert '"newpkg"' in pyproject
114
+ assert (site/"_pyright_editable_newpkg.pth").read_text() == "/tmp/ws/src/newpkg\n"
115
+ assert any(cmd == ["uv", "sync", "-U"] and kwargs["cwd"] == tmp_path for cmd,kwargs in calls)
116
+
117
+
118
+ def test_ws_sync_uses_active_venv_parent_by_default(tmp_path, monkeypatch):
119
+ workspace = tmp_path/"workspace"
120
+ elsewhere = tmp_path/"elsewhere"
121
+ workspace.mkdir()
122
+ elsewhere.mkdir()
123
+ (workspace/"repos.txt").write_text("AnswerDotAI/existing\n")
124
+ (workspace/"pyproject.tmpl").write_text('[project]\nname = "uvws"\ndependencies = [\n]\n\n[tool.uv.sources]\n\n')
125
+ pkg = workspace/"newpkg"
126
+ repo = workspace/"repo1"
127
+ pkg.mkdir()
128
+ repo.mkdir()
129
+ (pkg/"pyproject.toml").write_text('[project]\nname = "newpkg"\n')
130
+ (repo/".git").write_text("gitdir: .git/worktrees/repo1\n")
131
+ site = workspace/".venv"/"lib"/"python3.12"/"site-packages"
132
+ site.mkdir(parents=True)
133
+ (site/"__editable___newpkg_finder.py").write_text("MAPPING: dict[str, str] = {'newpkg': '/tmp/ws/src/newpkg/__init__.py'}\n")
134
+ calls = []
135
+
136
+ def fake_run(cmd, **kwargs):
137
+ calls.append((cmd, kwargs))
138
+ if cmd[:5] == ["git", "-C", str(repo), "remote", "get-url"]:
139
+ class Res: stdout = "git@github.com:AnswerDotAI/repo1.git\n"
140
+ return Res()
141
+ if cmd == ["uv", "sync", "-U"]:
142
+ class Res: stdout = ""
143
+ return Res()
144
+ raise AssertionError(f"Unexpected command: {cmd}")
145
+
146
+ monkeypatch.setattr(core.subprocess, "run", fake_run)
147
+ monkeypatch.setenv("VIRTUAL_ENV", str(workspace/".venv"))
148
+ monkeypatch.chdir(elsewhere)
149
+
150
+ core.ws_sync()
151
+
152
+ assert "AnswerDotAI/repo1" in (workspace/"repos.txt").read_text()
153
+ assert any(cmd == ["uv", "sync", "-U"] and kwargs["cwd"] == workspace for cmd,kwargs in calls)
154
+
155
+
156
+ def test_ws_add_updates_repos_then_runs_sync(tmp_path, monkeypatch):
157
+ (tmp_path/"repos.txt").write_text("AnswerDotAI/existing\n")
158
+ calls = []
159
+
160
+ def fake_sync(workspace, repos_file, pyproject_file, template_file):
161
+ calls.append((workspace, repos_file, pyproject_file, template_file))
162
+
163
+ monkeypatch.setattr(core, "ws_sync", fake_sync)
164
+
165
+ core.ws_add("answerdotai/fastws", workspace=str(tmp_path))
166
+
167
+ assert (tmp_path/"repos.txt").read_text() == "AnswerDotAI/existing\nanswerdotai/fastws\n"
168
+ assert calls == [(str(tmp_path), "repos.txt", "pyproject.toml", "pyproject.tmpl")]
@@ -1,6 +0,0 @@
1
- <!-- do not remove -->
2
-
3
- ## 0.0.2
4
-
5
- - init release
6
-
@@ -1,57 +0,0 @@
1
- # fastws
2
-
3
- Fast workspace tools for multi-repo management.
4
-
5
- ## Install
6
-
7
- ```bash
8
- pip install fastws-cli
9
- ```
10
-
11
- ## Setup
12
-
13
- Create a `repos.txt` file listing your repos (one per line):
14
-
15
- ```
16
- AnswerDotAI/fastcore
17
- AnswerDotAI/fastgit
18
- AnswerDotAI/fastship
19
- AnswerDotAI/fastws
20
- ```
21
-
22
- ## Commands
23
-
24
- ### `ws_clone`
25
-
26
- Clone all repos from your repos file:
27
-
28
- ```bash
29
- ws_clone
30
- ws_clone --repos-file myrepos.txt
31
- ws_clone --workers 8
32
- ```
33
-
34
- ### `ws_pull`
35
-
36
- Pull updates for all repos (parallel):
37
-
38
- ```bash
39
- ws_pull
40
- ```
41
-
42
- ### `ws_status`
43
-
44
- Show uncommitted changes and unpushed commits:
45
-
46
- ```bash
47
- ws_status
48
- ```
49
-
50
- ### `ws_branches`
51
-
52
- Check if all repos are on the expected branch:
53
-
54
- ```bash
55
- ws_branches
56
- ws_branches --expected develop
57
- ```
@@ -1,3 +0,0 @@
1
- __version__ = "0.0.2"
2
-
3
- from .core import *
@@ -1,115 +0,0 @@
1
- "Fast workspace tools for multi-repo management."
2
-
3
- from __future__ import annotations
4
-
5
- __all__ = ["ws_clone", "ws_clone_cli", "ws_pull", "ws_pull_cli", "ws_status", "ws_status_cli", "ws_branches", "ws_branches_cli"]
6
-
7
- import subprocess
8
- from pathlib import Path
9
- from concurrent.futures import ThreadPoolExecutor, as_completed
10
-
11
- from fastcore.script import call_parse
12
- from fastgit import Git
13
-
14
- def _load_repos(repos_file: str = "repos.txt") -> list[str]:
15
- p = Path(repos_file)
16
- if not p.exists(): raise SystemExit(f"File not found: {repos_file}")
17
- return [line.strip() for line in p.read_text().splitlines() if line.strip() and not line.startswith("#")]
18
-
19
- def _repo_dir(repo: str) -> str: return repo.split("/")[-1]
20
-
21
- def _clone_one(repo: str) -> str:
22
- d = _repo_dir(repo)
23
- if Path(d).exists(): return f"✓ {d}: already exists"
24
- try:
25
- subprocess.run(["git", "clone", f"git@github.com:{repo}.git"], check=True, capture_output=True)
26
- return f"✓ {d}: cloned"
27
- except subprocess.CalledProcessError as e: return f"✗ {d}: {e.stderr.decode().strip()}"
28
-
29
- def _pull_one(repo: str) -> str:
30
- d = _repo_dir(repo)
31
- if not Path(d).exists(): return f"✗ {d}: directory not found"
32
- try:
33
- res = subprocess.run(["git", "-C", d, "pull", "-q", "--stat"], check=True, capture_output=True, text=True)
34
- return f"✓ {d}" + (f"\n{res.stdout.strip()}" if res.stdout.strip() else "")
35
- except subprocess.CalledProcessError as e: return f"✗ {d}: {e.stderr.strip()}"
36
-
37
- def ws_clone(
38
- repos_file: str = "repos.txt", # File containing repo list (one per line: owner/repo)
39
- workers: int = 16, # Number of parallel workers
40
- ):
41
- "Clone all repos from a repos file."
42
- repos = _load_repos(repos_file)
43
- with ThreadPoolExecutor(max_workers=workers) as ex:
44
- for result in as_completed([ex.submit(_clone_one, r) for r in repos]): print(result.result())
45
-
46
- @call_parse
47
- def ws_clone_cli(
48
- repos_file: str = "repos.txt", # File containing repo list (one per line: owner/repo)
49
- workers: int = 16, # Number of parallel workers
50
- ): ws_clone(repos_file, workers)
51
-
52
- def ws_pull(
53
- repos_file: str = "repos.txt", # File containing repo list
54
- workers: int = 16, # Number of parallel workers
55
- ):
56
- "Pull updates for all repos."
57
- repos = _load_repos(repos_file)
58
- with ThreadPoolExecutor(max_workers=workers) as ex:
59
- for result in as_completed([ex.submit(_pull_one, r) for r in repos]): print(result.result())
60
-
61
- @call_parse
62
- def ws_pull_cli(
63
- repos_file: str = "repos.txt", # File containing repo list
64
- workers: int = 16, # Number of parallel workers
65
- ): ws_pull(repos_file, workers)
66
-
67
- def ws_status(
68
- repos_file: str = "repos.txt", # File containing repo list
69
- ):
70
- "Show uncommitted changes and unpushed commits across repos."
71
- repos = _load_repos(repos_file)
72
- for repo in repos:
73
- d = _repo_dir(repo)
74
- if not Path(d).exists(): continue
75
- g = Git(d)
76
- if not g.exists: continue
77
- changes = g.status('-s') or ""
78
- if isinstance(changes, list): changes = "\n".join(changes)
79
- unpushed = ""
80
- try: unpushed = g.log('--branches', '--not', '--remotes', format='%h %s') or ""
81
- except Exception: pass
82
- if isinstance(unpushed, list): unpushed = "\n".join(unpushed)
83
- if changes or unpushed:
84
- print(f"\n=== {d} ===")
85
- if changes: print(changes)
86
- if unpushed: print(unpushed)
87
-
88
- @call_parse
89
- def ws_status_cli(
90
- repos_file: str = "repos.txt", # File containing repo list
91
- ): ws_status(repos_file)
92
-
93
- def ws_branches(
94
- repos_file: str = "repos.txt", # File containing repo list
95
- expected: str = "main", # Expected branch name
96
- ):
97
- "Check if all repos are on the expected branch."
98
- repos = _load_repos(repos_file)
99
- for repo in repos:
100
- d = _repo_dir(repo)
101
- if not Path(d).exists():
102
- print(f"⚠️ {d}: directory not found")
103
- continue
104
- g = Git(d)
105
- if not g.exists:
106
- print(f"⚠️ {d}: not a git repo")
107
- continue
108
- branch = g.branch(show_current=True).strip()
109
- print(f"✓ {d}: OK (on {expected})" if branch == expected else f"⚠️ {d}: WARNING (on {branch})")
110
-
111
- @call_parse
112
- def ws_branches_cli(
113
- repos_file: str = "repos.txt", # File containing repo list
114
- expected: str = "main", # Expected branch name
115
- ): ws_branches(repos_file, expected)
@@ -1,5 +0,0 @@
1
- [console_scripts]
2
- ws_branches = fastws.core:ws_branches_cli
3
- ws_clone = fastws.core:ws_clone_cli
4
- ws_pull = fastws.core:ws_pull_cli
5
- ws_status = fastws.core:ws_status_cli
File without changes
File without changes
File without changes