fastws-cli 0.0.6__tar.gz → 0.0.8__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.8/CHANGELOG.md +47 -0
- fastws_cli-0.0.8/PKG-INFO +154 -0
- fastws_cli-0.0.8/README.md +131 -0
- fastws_cli-0.0.8/fastws/__init__.py +7 -0
- {fastws_cli-0.0.6 → fastws_cli-0.0.8}/fastws/core.py +148 -67
- fastws_cli-0.0.8/fastws/releases.py +108 -0
- fastws_cli-0.0.8/fastws_cli.egg-info/PKG-INFO +154 -0
- {fastws_cli-0.0.6 → fastws_cli-0.0.8}/fastws_cli.egg-info/SOURCES.txt +2 -0
- fastws_cli-0.0.8/fastws_cli.egg-info/entry_points.txt +9 -0
- {fastws_cli-0.0.6 → fastws_cli-0.0.8}/fastws_cli.egg-info/requires.txt +4 -2
- {fastws_cli-0.0.6 → fastws_cli-0.0.8}/pyproject.toml +12 -8
- fastws_cli-0.0.8/tests/test_releases.py +60 -0
- fastws_cli-0.0.8/tests/test_sync.py +500 -0
- fastws_cli-0.0.6/CHANGELOG.md +0 -24
- fastws_cli-0.0.6/PKG-INFO +0 -99
- fastws_cli-0.0.6/README.md +0 -78
- fastws_cli-0.0.6/fastws/__init__.py +0 -4
- fastws_cli-0.0.6/fastws_cli.egg-info/PKG-INFO +0 -99
- fastws_cli-0.0.6/fastws_cli.egg-info/entry_points.txt +0 -7
- fastws_cli-0.0.6/tests/test_sync.py +0 -236
- {fastws_cli-0.0.6 → fastws_cli-0.0.8}/LICENSE +0 -0
- {fastws_cli-0.0.6 → fastws_cli-0.0.8}/MANIFEST.in +0 -0
- {fastws_cli-0.0.6 → fastws_cli-0.0.8}/fastws_cli.egg-info/dependency_links.txt +0 -0
- {fastws_cli-0.0.6 → fastws_cli-0.0.8}/fastws_cli.egg-info/top_level.txt +0 -0
- {fastws_cli-0.0.6 → fastws_cli-0.0.8}/setup.cfg +0 -0
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
<!-- do not remove -->
|
|
2
|
+
|
|
3
|
+
## 0.0.8
|
|
4
|
+
|
|
5
|
+
### New Features
|
|
6
|
+
|
|
7
|
+
- Add ws-releases: report unreleased commits across the workspace, with configurable skip patterns and repo exclusions ([#17](https://github.com/AnswerDotAI/fastws/pull/17)), thanks to [@jph00](https://github.com/jph00)
|
|
8
|
+
- Add once-daily dependency floating to ws_sync (uv sync -U + cargo update), with --upgrade flag to force ([#15](https://github.com/AnswerDotAI/fastws/issues/15))
|
|
9
|
+
- ws-sync skips uv sync for member dirs lacking pyproject.toml; ws-add resolves local folder names via origin remote ([#12](https://github.com/AnswerDotAI/fastws/issues/12))
|
|
10
|
+
- Allow bare folder name in ws-remove; separate directory deletion confirmation from metadata removal ([#9](https://github.com/AnswerDotAI/fastws/issues/9))
|
|
11
|
+
- Add `ws-remove` command to delete a repo from the workspace; refactor CLI wrappers with `@delegates` ([#8](https://github.com/AnswerDotAI/fastws/issues/8))
|
|
12
|
+
- Add [dev] extra to workspace dependencies and filter spurious warnings ([#7](https://github.com/AnswerDotAI/fastws/pull/7)), thanks to [@RensDimmendaal](https://github.com/RensDimmendaal)
|
|
13
|
+
- add --workers flag to ws-sync ([#6](https://github.com/AnswerDotAI/fastws/pull/6)), thanks to [@RensDimmendaal](https://github.com/RensDimmendaal)
|
|
14
|
+
|
|
15
|
+
### Bugs Squashed
|
|
16
|
+
|
|
17
|
+
- Preserving existing tool.uv.sources entries during ws-sync ([#14](https://github.com/AnswerDotAI/fastws/pull/14)), thanks to [@kafkasl](https://github.com/kafkasl)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
## 0.0.7
|
|
21
|
+
|
|
22
|
+
### New Features
|
|
23
|
+
|
|
24
|
+
- Refactor Pyright editable path setup to write pyrightconfig.json instead of .pth files ([#4](https://github.com/AnswerDotAI/fastws/issues/4))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
## 0.0.6
|
|
28
|
+
|
|
29
|
+
### New Features
|
|
30
|
+
|
|
31
|
+
- 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))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
## 0.0.5
|
|
35
|
+
|
|
36
|
+
### New Features
|
|
37
|
+
|
|
38
|
+
- Add ws-sync and ws-add commands with workspace metadata sync and Pyright editable path support ([#1](https://github.com/AnswerDotAI/fastws/issues/1))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
## 0.0.3
|
|
42
|
+
|
|
43
|
+
- change names
|
|
44
|
+
|
|
45
|
+
## 0.0.2
|
|
46
|
+
|
|
47
|
+
- init release
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: fastws-cli
|
|
3
|
+
Version: 0.0.8
|
|
4
|
+
Summary: Fast workspace tools for multi-repo management
|
|
5
|
+
Author: Jeremy Howard
|
|
6
|
+
License: Apache-2.0
|
|
7
|
+
Project-URL: Homepage, https://github.com/AnswerDotAI/fastws
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
10
|
+
Requires-Python: >=3.10
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Requires-Dist: fastcore>=1.14.3
|
|
14
|
+
Requires-Dist: fastgit>=0.0.7
|
|
15
|
+
Requires-Dist: ghapi>=1.0.7
|
|
16
|
+
Requires-Dist: packaging
|
|
17
|
+
Requires-Dist: tomli; python_version < "3.11"
|
|
18
|
+
Provides-Extra: dev
|
|
19
|
+
Requires-Dist: fastship; extra == "dev"
|
|
20
|
+
Requires-Dist: build; extra == "dev"
|
|
21
|
+
Requires-Dist: twine; extra == "dev"
|
|
22
|
+
Dynamic: license-file
|
|
23
|
+
|
|
24
|
+
# fastws
|
|
25
|
+
|
|
26
|
+
Fast workspace tools for multi-repo management.
|
|
27
|
+
|
|
28
|
+
## Install
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pip install fastws-cli
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Setup
|
|
35
|
+
|
|
36
|
+
Create a `repos.txt` file listing your repos (one per line):
|
|
37
|
+
|
|
38
|
+
```
|
|
39
|
+
AnswerDotAI/fastcore
|
|
40
|
+
AnswerDotAI/fastgit
|
|
41
|
+
AnswerDotAI/fastship
|
|
42
|
+
AnswerDotAI/fastws
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Commands
|
|
46
|
+
|
|
47
|
+
### `ws-clone`
|
|
48
|
+
|
|
49
|
+
Clone all repos from your repos file:
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
ws-clone
|
|
53
|
+
ws-clone --repos-file myrepos.txt
|
|
54
|
+
ws-clone --workers 8
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### `ws-pull`
|
|
58
|
+
|
|
59
|
+
Pull updates for all repos (parallel):
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
ws-pull
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### `ws-status`
|
|
66
|
+
|
|
67
|
+
Show uncommitted changes and unpushed commits:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
ws-status
|
|
71
|
+
ws-status --branches
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
### `ws-branches`
|
|
75
|
+
|
|
76
|
+
Check if all repos are on the expected branch:
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
ws-branches
|
|
80
|
+
ws-branches --expected develop
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### `ws-sync`
|
|
84
|
+
|
|
85
|
+
Sync the workspace metadata, pull local repos, and install updates.
|
|
86
|
+
By default it uses the active venv parent as the workspace root, so you do not need to `cd` first.
|
|
87
|
+
It respects `tool.uv.workspace.members` and `exclude` when scanning local projects, and if any
|
|
88
|
+
member directory isn't a Python project yet (no `pyproject.toml`, e.g. a fresh empty clone), it
|
|
89
|
+
warns and skips the `uv sync` step instead of letting uv fail on the whole workspace.
|
|
90
|
+
|
|
91
|
+
At most once per day (tracked by a stamp file inside the workspace's `.git`, so git never
|
|
92
|
+
sees it), the sync also floats dependencies: `uv sync -U` instead of plain `uv sync`, plus
|
|
93
|
+
a parallel `cargo update` in every member with a `Cargo.toml`, printing what moved. Pass
|
|
94
|
+
`--upgrade` to force that pass regardless of when it last ran.
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
ws-sync
|
|
98
|
+
ws-sync --workspace ~/aai-ws
|
|
99
|
+
ws-sync --upgrade
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
### `ws-add`
|
|
103
|
+
|
|
104
|
+
Add a repo to `repos.txt`, then run `ws-sync`. Given `owner/repo`, it clones; given the
|
|
105
|
+
name of an existing local folder (e.g. one just scaffolded with `nbdev-new` or `ship-new`),
|
|
106
|
+
it resolves `owner/repo` from the folder's `origin` remote instead, telling you exactly
|
|
107
|
+
what's missing if the folder has no git repo, no GitHub origin, or no `pyproject.toml`:
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
ws-add AnswerDotAI/fastws
|
|
111
|
+
ws-add answerdotai/fastws
|
|
112
|
+
ws-add fastws # existing local folder, resolved via its origin remote
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
### `ws-remove`
|
|
116
|
+
|
|
117
|
+
Remove a repo: delete its clone, and drop it from `repos.txt` and the workspace
|
|
118
|
+
`pyproject.toml`, then run `uv sync`. It refuses if the directory has uncommitted
|
|
119
|
+
changes, unpushed commits, no `origin` remote, or isn't a clean git checkout, and
|
|
120
|
+
always prompts for confirmation before deleting anything:
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
ws-remove AnswerDotAI/fastws
|
|
124
|
+
ws-remove fastws # bare folder name also works if the directory exists
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
### `ws-releases`
|
|
128
|
+
|
|
129
|
+
Report repos with commits since their newest GitHub release, so nothing reviewed sits unshipped:
|
|
130
|
+
|
|
131
|
+
```bash
|
|
132
|
+
ws-releases # sweep every repo in repos.txt
|
|
133
|
+
ws-releases solveit # only solveit's transitive workspace dependencies
|
|
134
|
+
ws-releases --skip 'wip' # extra start-of-message regex for commits that need no release
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
Each pending repo lists its unreleased commit summaries; repos with no releases yet get one quiet `no releases:` line, and fully-released repos appear in `up to date:`. The newest release is picked by version number (publish timestamps can be out of order), and repos whose default branch isn't `main` are handled automatically.
|
|
138
|
+
|
|
139
|
+
Commits whose message matches a start-anchored regex from the skip set need no release and aren't reported. The built-in set covers version bumps and housekeeping (`bump`, `nbdev regen`, `.gitignore`, `docs`, `CI`, ...: `DEFAULT_SKIP` in `fastws.releases`); `[tool.fastws]` in the workspace root `pyproject.toml` adds to it, and names repos that should never be swept (apps that deploy rather than release):
|
|
140
|
+
|
|
141
|
+
```toml
|
|
142
|
+
[tool.fastws]
|
|
143
|
+
release_skip = ["docs only"]
|
|
144
|
+
release_exclude = ["solveit", "md_site"]
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
From Python (the primary interface - the CLI is a thin wrapper over `fastws.releases`):
|
|
148
|
+
|
|
149
|
+
```python
|
|
150
|
+
from fastws import check_releases, check_release
|
|
151
|
+
await check_releases() # ReleaseReport: the repr is the report
|
|
152
|
+
await check_releases('solveit') # dependency-closure mode
|
|
153
|
+
await check_release('mdhtml') # one repo: list of unreleased commit summaries (None = no releases)
|
|
154
|
+
```
|
|
@@ -0,0 +1,131 @@
|
|
|
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
|
+
ws-status --branches
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
### `ws-branches`
|
|
52
|
+
|
|
53
|
+
Check if all repos are on the expected branch:
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
ws-branches
|
|
57
|
+
ws-branches --expected develop
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### `ws-sync`
|
|
61
|
+
|
|
62
|
+
Sync the workspace metadata, pull local repos, and install updates.
|
|
63
|
+
By default it uses the active venv parent as the workspace root, so you do not need to `cd` first.
|
|
64
|
+
It respects `tool.uv.workspace.members` and `exclude` when scanning local projects, and if any
|
|
65
|
+
member directory isn't a Python project yet (no `pyproject.toml`, e.g. a fresh empty clone), it
|
|
66
|
+
warns and skips the `uv sync` step instead of letting uv fail on the whole workspace.
|
|
67
|
+
|
|
68
|
+
At most once per day (tracked by a stamp file inside the workspace's `.git`, so git never
|
|
69
|
+
sees it), the sync also floats dependencies: `uv sync -U` instead of plain `uv sync`, plus
|
|
70
|
+
a parallel `cargo update` in every member with a `Cargo.toml`, printing what moved. Pass
|
|
71
|
+
`--upgrade` to force that pass regardless of when it last ran.
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
ws-sync
|
|
75
|
+
ws-sync --workspace ~/aai-ws
|
|
76
|
+
ws-sync --upgrade
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### `ws-add`
|
|
80
|
+
|
|
81
|
+
Add a repo to `repos.txt`, then run `ws-sync`. Given `owner/repo`, it clones; given the
|
|
82
|
+
name of an existing local folder (e.g. one just scaffolded with `nbdev-new` or `ship-new`),
|
|
83
|
+
it resolves `owner/repo` from the folder's `origin` remote instead, telling you exactly
|
|
84
|
+
what's missing if the folder has no git repo, no GitHub origin, or no `pyproject.toml`:
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
ws-add AnswerDotAI/fastws
|
|
88
|
+
ws-add answerdotai/fastws
|
|
89
|
+
ws-add fastws # existing local folder, resolved via its origin remote
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
### `ws-remove`
|
|
93
|
+
|
|
94
|
+
Remove a repo: delete its clone, and drop it from `repos.txt` and the workspace
|
|
95
|
+
`pyproject.toml`, then run `uv sync`. It refuses if the directory has uncommitted
|
|
96
|
+
changes, unpushed commits, no `origin` remote, or isn't a clean git checkout, and
|
|
97
|
+
always prompts for confirmation before deleting anything:
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
ws-remove AnswerDotAI/fastws
|
|
101
|
+
ws-remove fastws # bare folder name also works if the directory exists
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
### `ws-releases`
|
|
105
|
+
|
|
106
|
+
Report repos with commits since their newest GitHub release, so nothing reviewed sits unshipped:
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
ws-releases # sweep every repo in repos.txt
|
|
110
|
+
ws-releases solveit # only solveit's transitive workspace dependencies
|
|
111
|
+
ws-releases --skip 'wip' # extra start-of-message regex for commits that need no release
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Each pending repo lists its unreleased commit summaries; repos with no releases yet get one quiet `no releases:` line, and fully-released repos appear in `up to date:`. The newest release is picked by version number (publish timestamps can be out of order), and repos whose default branch isn't `main` are handled automatically.
|
|
115
|
+
|
|
116
|
+
Commits whose message matches a start-anchored regex from the skip set need no release and aren't reported. The built-in set covers version bumps and housekeeping (`bump`, `nbdev regen`, `.gitignore`, `docs`, `CI`, ...: `DEFAULT_SKIP` in `fastws.releases`); `[tool.fastws]` in the workspace root `pyproject.toml` adds to it, and names repos that should never be swept (apps that deploy rather than release):
|
|
117
|
+
|
|
118
|
+
```toml
|
|
119
|
+
[tool.fastws]
|
|
120
|
+
release_skip = ["docs only"]
|
|
121
|
+
release_exclude = ["solveit", "md_site"]
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
From Python (the primary interface - the CLI is a thin wrapper over `fastws.releases`):
|
|
125
|
+
|
|
126
|
+
```python
|
|
127
|
+
from fastws import check_releases, check_release
|
|
128
|
+
await check_releases() # ReleaseReport: the repr is the report
|
|
129
|
+
await check_releases('solveit') # dependency-closure mode
|
|
130
|
+
await check_release('mdhtml') # one repo: list of unreleased commit summaries (None = no releases)
|
|
131
|
+
```
|
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
"Fast workspace tools for multi-repo management."
|
|
2
2
|
|
|
3
|
-
__all__ = ["ws_clone", "
|
|
4
|
-
"ws_sync", "ws_sync_cli", "ws_add", "ws_add_cli"]
|
|
3
|
+
__all__ = ["ws_clone", "ws_pull", "ws_status", "ws_branches", "ws_sync", "ws_add", "ws_remove"]
|
|
5
4
|
|
|
6
|
-
import ast, fnmatch, os, re, shutil, subprocess
|
|
5
|
+
import ast, fnmatch, json, os, re, shutil, subprocess, time
|
|
7
6
|
from pathlib import Path
|
|
8
7
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
9
8
|
|
|
@@ -96,6 +95,31 @@ def _update_repos_file(repos_path: Path, repos: list[str]) -> list[str]:
|
|
|
96
95
|
repos_path.write_text(content + "\n".join(missing) + "\n")
|
|
97
96
|
return missing
|
|
98
97
|
|
|
98
|
+
def _remove_from_repos_file(repos_path: Path, repo: str) -> bool:
|
|
99
|
+
if not repos_path.exists(): return False
|
|
100
|
+
key = _repo_key(repo)
|
|
101
|
+
lines = repos_path.read_text().splitlines()
|
|
102
|
+
kept = [l for l in lines if l.startswith("#") or not l.strip() or _repo_key(l) != key]
|
|
103
|
+
if len(kept) == len(lines): return False
|
|
104
|
+
repos_path.write_text("\n".join(kept) + ("\n" if kept else ""))
|
|
105
|
+
return True
|
|
106
|
+
|
|
107
|
+
def _remove_from_pyproject(pyproject_path: Path, names: list[str]) -> list[str]:
|
|
108
|
+
if not pyproject_path.exists(): return []
|
|
109
|
+
content = pyproject_path.read_text()
|
|
110
|
+
data = tomllib.loads(content)
|
|
111
|
+
sources = dict(data.get("tool", {}).get("uv", {}).get("sources", {}))
|
|
112
|
+
deps = list(data.get("project", {}).get("dependencies", []))
|
|
113
|
+
targets = {_pkg_key(n) for n in names}
|
|
114
|
+
removed = sorted({_pkg_key(s) for s in sources if _pkg_key(s) in targets} | {_dep_key(d) for d in deps if _dep_key(d) in targets})
|
|
115
|
+
if not removed: return []
|
|
116
|
+
sources = {k:v for k,v in sources.items() if _pkg_key(k) not in targets}
|
|
117
|
+
deps = [d for d in deps if _dep_key(d) not in targets]
|
|
118
|
+
content = _replace_table(content, "tool.uv.sources", "\n".join(f"{k} = {{ workspace = true }}" for k in sources))
|
|
119
|
+
content = _replace_project_dependencies(content, deps)
|
|
120
|
+
pyproject_path.write_text(content)
|
|
121
|
+
return removed
|
|
122
|
+
|
|
99
123
|
def _read_pyproject_name(path: Path) -> str|None:
|
|
100
124
|
try: data = tomllib.loads(path.read_text())
|
|
101
125
|
except tomllib.TOMLDecodeError:
|
|
@@ -193,22 +217,21 @@ def _site_packages(root: Path) -> Path|None:
|
|
|
193
217
|
if candidates: return candidates[0]
|
|
194
218
|
return None
|
|
195
219
|
|
|
196
|
-
def
|
|
220
|
+
def _write_pyright_config(root: Path):
|
|
197
221
|
site = _site_packages(root)
|
|
198
222
|
if not site:
|
|
199
223
|
print("No site-packages directory found for editable Pyright paths")
|
|
200
|
-
return
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
return created
|
|
224
|
+
return
|
|
225
|
+
paths = sorted({str(Path(p).parent) for f in site.glob("__editable__*_finder.py") for p in _editable_mapping(f).values()})
|
|
226
|
+
cfg_path = root / 'pyrightconfig.json'
|
|
227
|
+
cfg = json.loads(cfg_path.read_text()) if cfg_path.exists() else {}
|
|
228
|
+
cfg['extraPaths'] = paths
|
|
229
|
+
cfg_path.write_text(json.dumps(cfg, indent=2) + '\n')
|
|
230
|
+
for pth in site.glob('_pyright_editable_*.pth'): pth.unlink()
|
|
208
231
|
|
|
209
232
|
def _clone_one(repo: str, root: str = ".") -> str:
|
|
210
233
|
d = Path(root)/_repo_dir(repo)
|
|
211
|
-
if d.exists(): return
|
|
234
|
+
if d.exists(): return
|
|
212
235
|
try:
|
|
213
236
|
subprocess.run(["git", "clone", f"git@github.com:{repo}.git", str(d)], check=True, capture_output=True)
|
|
214
237
|
return f"✓ {d.name}: cloned"
|
|
@@ -226,6 +249,7 @@ def _pull(repos: list[str], workers: int = 16, root: str = "."):
|
|
|
226
249
|
with ThreadPoolExecutor(max_workers=workers) as ex:
|
|
227
250
|
for result in as_completed([ex.submit(_pull_one, r, root) for r in repos]): print(result.result())
|
|
228
251
|
|
|
252
|
+
@call_parse
|
|
229
253
|
def ws_clone(
|
|
230
254
|
repos_file: str = "repos.txt", # File containing repo list (one per line: owner/repo)
|
|
231
255
|
workers: int = 16, # Number of parallel workers
|
|
@@ -233,16 +257,10 @@ def ws_clone(
|
|
|
233
257
|
"Clone all repos from a repos file."
|
|
234
258
|
repos = _load_repos(repos_file)
|
|
235
259
|
with ThreadPoolExecutor(max_workers=workers) as ex:
|
|
236
|
-
for result in as_completed([ex.submit(_clone_one, r) for r in repos]):
|
|
260
|
+
for result in as_completed([ex.submit(_clone_one, r) for r in repos]):
|
|
261
|
+
if result.result(): print(result.result())
|
|
237
262
|
|
|
238
263
|
@call_parse
|
|
239
|
-
def ws_clone_cli(
|
|
240
|
-
repos_file: str = "repos.txt", # File containing repo list (one per line: owner/repo)
|
|
241
|
-
workers: int = 16, # Number of parallel workers
|
|
242
|
-
):
|
|
243
|
-
"Clone all repos from a repos file."
|
|
244
|
-
ws_clone(repos_file, workers)
|
|
245
|
-
|
|
246
264
|
def ws_pull(
|
|
247
265
|
repos_file: str = "repos.txt", # File containing repo list
|
|
248
266
|
workers: int = 16, # Number of parallel workers
|
|
@@ -251,13 +269,6 @@ def ws_pull(
|
|
|
251
269
|
_pull(_load_repos(repos_file), workers)
|
|
252
270
|
|
|
253
271
|
@call_parse
|
|
254
|
-
def ws_pull_cli(
|
|
255
|
-
repos_file: str = "repos.txt", # File containing repo list
|
|
256
|
-
workers: int = 16, # Number of parallel workers
|
|
257
|
-
):
|
|
258
|
-
"Pull updates for all repos."
|
|
259
|
-
ws_pull(repos_file, workers)
|
|
260
|
-
|
|
261
272
|
def ws_status(
|
|
262
273
|
repos_file: str = "repos.txt", # File containing repo list
|
|
263
274
|
branches: bool = False, # Show unpushed commit details
|
|
@@ -270,27 +281,17 @@ def ws_status(
|
|
|
270
281
|
g = Git(d)
|
|
271
282
|
if not g.exists: continue
|
|
272
283
|
changes = g.status('-s') or ""
|
|
273
|
-
if isinstance(changes, list): changes = "\n".join(changes)
|
|
274
284
|
unpushed = ""
|
|
275
285
|
try:
|
|
276
286
|
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 ""
|
|
287
|
+
elif (branch := g.branch(show_current=True).strip()) in ('main', 'master'): unpushed = g.log('@{upstream}..HEAD', '-1', format='%h %s') or ""
|
|
279
288
|
except Exception: pass
|
|
280
|
-
if isinstance(unpushed, list): unpushed = "\n".join(unpushed)
|
|
281
289
|
if changes or unpushed:
|
|
282
290
|
print(f"\n=== {d} ===")
|
|
283
291
|
if changes: print(changes)
|
|
284
292
|
if unpushed: print(unpushed if branches else "unpushed commits")
|
|
285
293
|
|
|
286
294
|
@call_parse
|
|
287
|
-
def ws_status_cli(
|
|
288
|
-
repos_file: str = "repos.txt", # File containing repo list
|
|
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)
|
|
293
|
-
|
|
294
295
|
def ws_branches(
|
|
295
296
|
repos_file: str = "repos.txt", # File containing repo list
|
|
296
297
|
expected: str = "main", # Expected branch name
|
|
@@ -309,21 +310,41 @@ def ws_branches(
|
|
|
309
310
|
branch = g.branch(show_current=True).strip()
|
|
310
311
|
print(f"✓ {d}: OK (on {expected})" if branch == expected else f"⚠️ {d}: WARNING (on {branch})")
|
|
311
312
|
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
313
|
+
def _upgrade_stamp(root: Path) -> Path:
|
|
314
|
+
"Stamp whose mtime records the last dependency upgrade; kept inside .git (never tracked) when the root is a git repo."
|
|
315
|
+
gitdir = root/".git"
|
|
316
|
+
return gitdir/"fastws-upgraded" if gitdir.is_dir() else root/".fastws-upgraded"
|
|
317
|
+
|
|
318
|
+
def _should_upgrade(root: Path, max_age: float = 86400) -> bool:
|
|
319
|
+
"True when this workspace hasn't upgraded dependencies within `max_age` seconds."
|
|
320
|
+
stamp = _upgrade_stamp(root)
|
|
321
|
+
return not stamp.exists() or time.time() - stamp.stat().st_mtime > max_age
|
|
319
322
|
|
|
323
|
+
def _cargo_update(root: Path, workers: int = 16):
|
|
324
|
+
"Float each member crate's Cargo.lock to latest matching versions, in parallel; prints what moved."
|
|
325
|
+
crates = [d for d in _ws_dirs(root) if (d/"Cargo.toml").exists()]
|
|
326
|
+
if not crates: return
|
|
327
|
+
|
|
328
|
+
def upd(d):
|
|
329
|
+
res = subprocess.run(["cargo", "update"], cwd=d, capture_output=True, text=True)
|
|
330
|
+
return d.name, res.returncode, (res.stdout or "") + (res.stderr or "")
|
|
331
|
+
|
|
332
|
+
with ThreadPoolExecutor(max_workers=workers) as ex:
|
|
333
|
+
for name, code, out in ex.map(upd, crates):
|
|
334
|
+
if code: print(f"⚠️ {name}: cargo update failed\n{out}")
|
|
335
|
+
elif lines := [l for l in out.splitlines() if l.lstrip().startswith(("Updating ", "Adding ", "Removing "))]:
|
|
336
|
+
print(f"{name}:\n" + "\n".join(lines))
|
|
337
|
+
|
|
338
|
+
@call_parse
|
|
320
339
|
def ws_sync(
|
|
321
340
|
workspace: str = "", # Workspace root; defaults to active venv parent when available
|
|
322
341
|
repos_file: str = "repos.txt", # Repo list to update from local git remotes
|
|
323
342
|
pyproject_file: str = "pyproject.toml", # Workspace pyproject to update
|
|
324
343
|
template_file: str = "pyproject.tmpl", # Template copied when pyproject.toml is missing
|
|
344
|
+
workers: int = 16, # Number of parallel workers
|
|
345
|
+
upgrade: bool = False, # Force the once-daily dependency upgrade pass
|
|
325
346
|
):
|
|
326
|
-
"Sync workspace metadata
|
|
347
|
+
"Sync workspace metadata and run uv sync; at most once per day (or with `upgrade`), float dependencies with uv sync -U plus cargo update in member crates."
|
|
327
348
|
root = _ws_root(workspace, repos_file, pyproject_file, template_file)
|
|
328
349
|
repos_path = _resolve_path(root, repos_file)
|
|
329
350
|
pyproject_path = _resolve_path(root, pyproject_file)
|
|
@@ -331,47 +352,107 @@ def ws_sync(
|
|
|
331
352
|
repos = _discover_ws_repos(root)
|
|
332
353
|
|
|
333
354
|
if missing_repos := _update_repos_file(repos_path, repos): print(f"Added repos: {', '.join(missing_repos)}")
|
|
334
|
-
_pull(repos, root=str(root))
|
|
355
|
+
_pull(repos, workers=workers, root=str(root))
|
|
335
356
|
|
|
336
357
|
if missing_projects := _sync_ws_pyproject(pyproject_path, template_path, _ws_projects(root)): print(f"Added workspace projects: {', '.join(missing_projects)}")
|
|
337
358
|
|
|
338
|
-
|
|
339
|
-
|
|
359
|
+
if bad := [d.name for d in _ws_dirs(root) if not (d/"pyproject.toml").exists()]:
|
|
360
|
+
print(f"⚠️ Skipping uv sync, not Python projects yet (scaffold with e.g. nbdev-new or ship-new, or remove): {', '.join(bad)}")
|
|
361
|
+
return
|
|
362
|
+
up = upgrade or _should_upgrade(root)
|
|
363
|
+
subprocess.run(["uv", "sync", "-U"] if up else ["uv", "sync"], check=True, cwd=root)
|
|
364
|
+
if up:
|
|
365
|
+
_cargo_update(root, workers=workers)
|
|
366
|
+
_upgrade_stamp(root).touch()
|
|
340
367
|
|
|
341
368
|
@call_parse
|
|
342
|
-
def ws_sync_cli(
|
|
343
|
-
workspace: str = "", # Workspace root; defaults to active venv parent when available
|
|
344
|
-
repos_file: str = "repos.txt", # Repo list to update from local git remotes
|
|
345
|
-
pyproject_file: str = "pyproject.toml", # Workspace pyproject to update
|
|
346
|
-
template_file: str = "pyproject.tmpl", # Template copied when pyproject.toml is missing
|
|
347
|
-
):
|
|
348
|
-
"Sync workspace metadata, run uv sync -U, and refresh Pyright editable paths."
|
|
349
|
-
ws_sync(workspace, repos_file, pyproject_file, template_file)
|
|
350
|
-
|
|
351
369
|
def ws_add(
|
|
352
|
-
repo: str, # Repo to add,
|
|
370
|
+
repo: str, # Repo to add (owner/repo to clone), or an existing local folder name
|
|
353
371
|
workspace: str = "", # Workspace root; defaults to active venv parent when available
|
|
354
372
|
repos_file: str = "repos.txt", # Repo list to update
|
|
355
373
|
pyproject_file: str = "pyproject.toml", # Workspace pyproject to update
|
|
356
374
|
template_file: str = "pyproject.tmpl", # Template copied when pyproject.toml is missing
|
|
357
375
|
):
|
|
358
|
-
"Add a repo to repos.txt and then run ws-sync."
|
|
376
|
+
"Add a repo to repos.txt (a local folder resolves via its origin remote) and then run ws-sync."
|
|
359
377
|
root = _ws_root(workspace, repos_file, pyproject_file, template_file)
|
|
360
378
|
repos_path = _resolve_path(root, repos_file)
|
|
361
|
-
repo =
|
|
379
|
+
repo, local = _resolve_add_target(root, repo)
|
|
362
380
|
added = _update_repos_file(repos_path, [repo])
|
|
363
381
|
if added: print(f"Added repo: {repo}")
|
|
364
382
|
else: print(f"Repo already present: {repo}")
|
|
365
|
-
print(_clone_one(repo, str(root)))
|
|
383
|
+
if not local: print(_clone_one(repo, str(root)))
|
|
366
384
|
ws_sync(str(root), repos_file, pyproject_file, template_file)
|
|
367
385
|
|
|
386
|
+
def _is_repo_spec(repo: str) -> bool:
|
|
387
|
+
repo = repo.strip().rstrip("/").removesuffix(".git")
|
|
388
|
+
return bool(_parse_github_repo(repo) or re.fullmatch(r"[^/\s]+/[^/\s]+", repo))
|
|
389
|
+
|
|
390
|
+
def _resolve_add_target(root: Path, repo: str) -> tuple[str, bool]:
|
|
391
|
+
"Canonical owner/repo for `repo` and whether it named a local folder, which must be a git repo with a GitHub origin and a pyproject.toml"
|
|
392
|
+
if _is_repo_spec(repo): return _normalize_repo(repo), False
|
|
393
|
+
d = root/repo
|
|
394
|
+
if not d.is_dir(): return _normalize_repo(repo), False # invalid spec and no such folder: raise the standard error
|
|
395
|
+
if not (d/".git").exists(): raise SystemExit(f"{d.name} is not a git repository: `git init` it and create a remote, e.g. `gh repo create <owner>/{d.name} --source={d} --push`")
|
|
396
|
+
res = subprocess.run(["git", "-C", str(d), "remote", "get-url", "origin"], capture_output=True, text=True)
|
|
397
|
+
if res.returncode != 0 or not (parsed := _parse_github_repo(res.stdout)):
|
|
398
|
+
raise SystemExit(f"{d.name} has no GitHub 'origin' remote: create one, e.g. `gh repo create <owner>/{d.name} --source={d} --push`")
|
|
399
|
+
if not (d/"pyproject.toml").exists():
|
|
400
|
+
raise SystemExit(f"{d.name} has no pyproject.toml: scaffold it first (e.g. `nbdev-new` or `ship-new`), then re-run ws-add")
|
|
401
|
+
return parsed, True
|
|
402
|
+
|
|
403
|
+
def _resolve_removal_target(root: Path, repo: str, repos_path: Path) -> str:
|
|
404
|
+
"Canonical owner/repo for `repo`, matching an existing folder name when `repo` isn't a valid spec."
|
|
405
|
+
if _is_repo_spec(repo): return _normalize_repo(repo)
|
|
406
|
+
if not (root/repo).is_dir(): return _normalize_repo(repo) # invalid spec and no such folder: raise
|
|
407
|
+
for r in (_load_repos(repos_path) if repos_path.exists() else []):
|
|
408
|
+
if _repo_dir(r).casefold() == repo.casefold(): return r
|
|
409
|
+
if (root/repo/".git").exists():
|
|
410
|
+
res = subprocess.run(["git", "-C", str(root/repo), "remote", "get-url", "origin"], capture_output=True, text=True)
|
|
411
|
+
if res.returncode == 0 and (parsed := _parse_github_repo(res.stdout)): return parsed
|
|
412
|
+
return repo
|
|
413
|
+
|
|
414
|
+
def _resolve_repo_dir(root: Path, repo: str) -> Path:
|
|
415
|
+
root = root.resolve()
|
|
416
|
+
d = root/_repo_dir(repo)
|
|
417
|
+
if d.is_symlink(): raise SystemExit(f"Refusing to remove {d}: it is a symlink")
|
|
418
|
+
if (rd := d.resolve()) == root or rd.parent != root: raise SystemExit(f"Refusing to remove {d}: not directly inside {root}")
|
|
419
|
+
return d
|
|
420
|
+
|
|
421
|
+
def _git_out(d: Path, *args: str) -> str:
|
|
422
|
+
return subprocess.run(["git", "-C", str(d), *args], check=True, capture_output=True, text=True).stdout
|
|
423
|
+
|
|
424
|
+
def _repo_safety_issues(d: Path) -> list[str]:
|
|
425
|
+
if not (d/".git").exists(): return [f"{d.name} is not a git repository"]
|
|
426
|
+
issues = []
|
|
427
|
+
if subprocess.run(["git", "-C", str(d), "remote", "get-url", "origin"], capture_output=True, text=True).returncode != 0:
|
|
428
|
+
issues.append(f"{d.name} has no 'origin' remote")
|
|
429
|
+
if _git_out(d, "status", "--porcelain").strip(): issues.append(f"{d.name} has uncommitted changes")
|
|
430
|
+
if _git_out(d, "log", "--branches", "--not", "--remotes", "--format=%h").strip(): issues.append(f"{d.name} has unpushed commits")
|
|
431
|
+
return issues
|
|
432
|
+
|
|
368
433
|
@call_parse
|
|
369
|
-
def
|
|
370
|
-
repo: str, # Repo to
|
|
434
|
+
def ws_remove(
|
|
435
|
+
repo: str, # Repo to remove, e.g. AnswerDotAI/fastws
|
|
371
436
|
workspace: str = "", # Workspace root; defaults to active venv parent when available
|
|
372
437
|
repos_file: str = "repos.txt", # Repo list to update
|
|
373
438
|
pyproject_file: str = "pyproject.toml", # Workspace pyproject to update
|
|
374
439
|
template_file: str = "pyproject.tmpl", # Template copied when pyproject.toml is missing
|
|
375
440
|
):
|
|
376
|
-
"
|
|
377
|
-
|
|
441
|
+
"Remove a repo: delete its clone and drop it from repos.txt and the workspace pyproject."
|
|
442
|
+
root = _ws_root(workspace, repos_file, pyproject_file, template_file)
|
|
443
|
+
repos_path = _resolve_path(root, repos_file)
|
|
444
|
+
pyproject_path = _resolve_path(root, pyproject_file)
|
|
445
|
+
repo = _resolve_removal_target(root, repo, repos_path)
|
|
446
|
+
d = _resolve_repo_dir(root, repo)
|
|
447
|
+
name = _read_pyproject_name(d/"pyproject.toml") if (d/"pyproject.toml").exists() else None
|
|
448
|
+
names = [name] if name else [d.name]
|
|
449
|
+
in_repos = _repo_key(repo) in {_repo_key(r) for r in (_load_repos(repos_path) if repos_path.exists() else [])}
|
|
450
|
+
if not d.exists() and not in_repos: raise SystemExit(f"Nothing to remove for {repo}")
|
|
451
|
+
if d.exists() and (issues := _repo_safety_issues(d)): raise SystemExit("Refusing to remove:\n" + "\n".join(f" - {i}" for i in issues))
|
|
452
|
+
_remove_from_repos_file(repos_path, repo)
|
|
453
|
+
_remove_from_pyproject(pyproject_path, names)
|
|
454
|
+
if d.exists():
|
|
455
|
+
try: ans = input(f"Remove directory {d}? [y/N] ")
|
|
456
|
+
except EOFError: ans = ""
|
|
457
|
+
if ans.strip().lower() in ("y", "yes"): shutil.rmtree(d)
|
|
458
|
+
subprocess.run(["uv", "sync"], check=True, cwd=root)
|