cave-cli 3.6.0b1__tar.gz → 3.6.0b2__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.
- {cave_cli-3.6.0b1/cave_cli.egg-info → cave_cli-3.6.0b2}/PKG-INFO +1 -1
- {cave_cli-3.6.0b1 → cave_cli-3.6.0b2}/cave_cli/commands/upgrade.py +118 -30
- {cave_cli-3.6.0b1 → cave_cli-3.6.0b2}/cave_cli/utils/sync.py +44 -5
- {cave_cli-3.6.0b1 → cave_cli-3.6.0b2}/cave_cli/utils/validate.py +13 -6
- {cave_cli-3.6.0b1 → cave_cli-3.6.0b2/cave_cli.egg-info}/PKG-INFO +1 -1
- {cave_cli-3.6.0b1 → cave_cli-3.6.0b2}/cave_cli.egg-info/SOURCES.txt +1 -0
- {cave_cli-3.6.0b1 → cave_cli-3.6.0b2}/pyproject.toml +1 -1
- cave_cli-3.6.0b2/test/test_sync.py +116 -0
- cave_cli-3.6.0b2/test/test_upgrade.py +60 -0
- cave_cli-3.6.0b2/test/test_validate.py +103 -0
- cave_cli-3.6.0b1/test/test_sync.py +0 -42
- cave_cli-3.6.0b1/test/test_validate.py +0 -49
- {cave_cli-3.6.0b1 → cave_cli-3.6.0b2}/LICENSE +0 -0
- {cave_cli-3.6.0b1 → cave_cli-3.6.0b2}/NOTICE.md +0 -0
- {cave_cli-3.6.0b1 → cave_cli-3.6.0b2}/README.md +0 -0
- {cave_cli-3.6.0b1 → cave_cli-3.6.0b2}/cave_cli/__init__.py +0 -0
- {cave_cli-3.6.0b1 → cave_cli-3.6.0b2}/cave_cli/cli.py +0 -0
- {cave_cli-3.6.0b1 → cave_cli-3.6.0b2}/cave_cli/commands/__init__.py +0 -0
- {cave_cli-3.6.0b1 → cave_cli-3.6.0b2}/cave_cli/commands/create.py +0 -0
- {cave_cli-3.6.0b1 → cave_cli-3.6.0b2}/cave_cli/commands/doctor.py +0 -0
- {cave_cli-3.6.0b1 → cave_cli-3.6.0b2}/cave_cli/commands/kill.py +0 -0
- {cave_cli-3.6.0b1 → cave_cli-3.6.0b2}/cave_cli/commands/list_cmd.py +0 -0
- {cave_cli-3.6.0b1 → cave_cli-3.6.0b2}/cave_cli/commands/list_versions.py +0 -0
- {cave_cli-3.6.0b1 → cave_cli-3.6.0b2}/cave_cli/commands/prettify.py +0 -0
- {cave_cli-3.6.0b1 → cave_cli-3.6.0b2}/cave_cli/commands/purge.py +0 -0
- {cave_cli-3.6.0b1 → cave_cli-3.6.0b2}/cave_cli/commands/reset.py +0 -0
- {cave_cli-3.6.0b1 → cave_cli-3.6.0b2}/cave_cli/commands/run.py +0 -0
- {cave_cli-3.6.0b1 → cave_cli-3.6.0b2}/cave_cli/commands/sync_cmd.py +0 -0
- {cave_cli-3.6.0b1 → cave_cli-3.6.0b2}/cave_cli/commands/test.py +0 -0
- {cave_cli-3.6.0b1 → cave_cli-3.6.0b2}/cave_cli/commands/theme.py +0 -0
- {cave_cli-3.6.0b1 → cave_cli-3.6.0b2}/cave_cli/commands/uninstall.py +0 -0
- {cave_cli-3.6.0b1 → cave_cli-3.6.0b2}/cave_cli/commands/update.py +0 -0
- {cave_cli-3.6.0b1 → cave_cli-3.6.0b2}/cave_cli/commands/version.py +0 -0
- {cave_cli-3.6.0b1 → cave_cli-3.6.0b2}/cave_cli/utils/__init__.py +0 -0
- {cave_cli-3.6.0b1 → cave_cli-3.6.0b2}/cave_cli/utils/cache.py +0 -0
- {cave_cli-3.6.0b1 → cave_cli-3.6.0b2}/cave_cli/utils/constants.py +0 -0
- {cave_cli-3.6.0b1 → cave_cli-3.6.0b2}/cave_cli/utils/display.py +0 -0
- {cave_cli-3.6.0b1 → cave_cli-3.6.0b2}/cave_cli/utils/docker.py +0 -0
- {cave_cli-3.6.0b1 → cave_cli-3.6.0b2}/cave_cli/utils/env.py +0 -0
- {cave_cli-3.6.0b1 → cave_cli-3.6.0b2}/cave_cli/utils/git.py +0 -0
- {cave_cli-3.6.0b1 → cave_cli-3.6.0b2}/cave_cli/utils/logger.py +0 -0
- {cave_cli-3.6.0b1 → cave_cli-3.6.0b2}/cave_cli/utils/net.py +0 -0
- {cave_cli-3.6.0b1 → cave_cli-3.6.0b2}/cave_cli/utils/subprocess.py +0 -0
- {cave_cli-3.6.0b1 → cave_cli-3.6.0b2}/cave_cli/utils/version.py +0 -0
- {cave_cli-3.6.0b1 → cave_cli-3.6.0b2}/cave_cli.egg-info/dependency_links.txt +0 -0
- {cave_cli-3.6.0b1 → cave_cli-3.6.0b2}/cave_cli.egg-info/entry_points.txt +0 -0
- {cave_cli-3.6.0b1 → cave_cli-3.6.0b2}/cave_cli.egg-info/requires.txt +0 -0
- {cave_cli-3.6.0b1 → cave_cli-3.6.0b2}/cave_cli.egg-info/top_level.txt +0 -0
- {cave_cli-3.6.0b1 → cave_cli-3.6.0b2}/setup.cfg +0 -0
- {cave_cli-3.6.0b1 → cave_cli-3.6.0b2}/test/test_env.py +0 -0
- {cave_cli-3.6.0b1 → cave_cli-3.6.0b2}/test/test_net.py +0 -0
- {cave_cli-3.6.0b1 → cave_cli-3.6.0b2}/utils/prettify.py +0 -0
|
@@ -60,45 +60,85 @@ def migrate_3_6_0(app_dir: str) -> None:
|
|
|
60
60
|
if version_file.exists():
|
|
61
61
|
version_file.unlink()
|
|
62
62
|
|
|
63
|
-
#
|
|
64
|
-
|
|
63
|
+
# Move cave_core/models.py to legacy/cave_core/models.py if it exists
|
|
64
|
+
models_file = app_path / "cave_core" / "models.py"
|
|
65
|
+
if models_file.exists():
|
|
66
|
+
try:
|
|
67
|
+
legacy_cave_core = legacy_dir / "cave_core"
|
|
68
|
+
legacy_cave_core.mkdir(exist_ok=True)
|
|
69
|
+
shutil.move(models_file, legacy_cave_core / "models.py")
|
|
70
|
+
except Exception as e:
|
|
71
|
+
logger.warn(f"Failed to move cave_core/models.py to legacy/cave_core/models.py: {e}")
|
|
72
|
+
|
|
73
|
+
# Collect api requirements from cave_api/requirements.txt and cave_api/pyproject.toml
|
|
74
|
+
# before moving/restructuring the cave_api directory
|
|
75
|
+
api_requirements = []
|
|
76
|
+
|
|
77
|
+
# 1. Pull from cave_api/requirements.txt
|
|
65
78
|
cave_api_req = app_path / "cave_api" / "requirements.txt"
|
|
66
79
|
if cave_api_req.exists():
|
|
67
80
|
try:
|
|
68
|
-
|
|
81
|
+
for line in cave_api_req.read_text().splitlines():
|
|
82
|
+
line = line.strip()
|
|
83
|
+
if line and not line.startswith("#"):
|
|
84
|
+
api_requirements.append(line)
|
|
69
85
|
except Exception as e:
|
|
70
|
-
logger.
|
|
86
|
+
logger.warn(f"Failed to read cave_api requirements: {e}")
|
|
71
87
|
|
|
72
|
-
#
|
|
73
|
-
|
|
74
|
-
if
|
|
88
|
+
# 2. Pull from cave_api/pyproject.toml
|
|
89
|
+
cave_api_pyproject = app_path / "cave_api" / "pyproject.toml"
|
|
90
|
+
if cave_api_pyproject.exists():
|
|
75
91
|
try:
|
|
76
|
-
with open(
|
|
77
|
-
|
|
78
|
-
|
|
92
|
+
with open(cave_api_pyproject, "rb") as f:
|
|
93
|
+
cave_api_pyproject_data = tomllib.loads(f.read().decode())
|
|
94
|
+
deps = cave_api_pyproject_data.get("project", {}).get("dependencies", [])
|
|
95
|
+
for dep in deps:
|
|
96
|
+
dep = dep.strip()
|
|
97
|
+
if dep and dep not in api_requirements:
|
|
98
|
+
api_requirements.append(dep)
|
|
99
|
+
except Exception as e:
|
|
100
|
+
logger.warn(f"Failed to read cave_api pyproject.toml: {e}")
|
|
79
101
|
|
|
80
|
-
|
|
81
|
-
|
|
102
|
+
# Deduplicate requirements while preserving order
|
|
103
|
+
seen = set()
|
|
104
|
+
cleaned_requirements = []
|
|
105
|
+
for req in api_requirements:
|
|
106
|
+
if req not in seen:
|
|
107
|
+
seen.add(req)
|
|
108
|
+
cleaned_requirements.append(req)
|
|
82
109
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
110
|
+
# Update pyproject.toml optional-dependencies.api section
|
|
111
|
+
pyproject_path = app_path / "pyproject.toml"
|
|
112
|
+
if cleaned_requirements and pyproject_path.exists():
|
|
113
|
+
try:
|
|
114
|
+
with open(pyproject_path, "rb") as f:
|
|
115
|
+
content_bytes = f.read()
|
|
116
|
+
content = content_bytes.decode()
|
|
86
117
|
|
|
87
|
-
|
|
118
|
+
deps_str = "\n".join(f' "{dep}",' for dep in cleaned_requirements)
|
|
119
|
+
|
|
120
|
+
if "[project.optional-dependencies]" in content:
|
|
121
|
+
if re.search(r"api\s*=\s*\[", content):
|
|
88
122
|
content = re.sub(
|
|
89
|
-
r
|
|
90
|
-
f
|
|
91
|
-
content
|
|
123
|
+
r"(api\s*=\s*\[)[^\]]*(\])",
|
|
124
|
+
f"\\1\n{deps_str}\n\\2",
|
|
125
|
+
content,
|
|
92
126
|
flags=re.DOTALL
|
|
93
|
-
)
|
|
127
|
+
)
|
|
94
128
|
else:
|
|
95
|
-
content =
|
|
129
|
+
content = re.sub(
|
|
130
|
+
r"(\[project\.optional-dependencies\])",
|
|
131
|
+
f"\\1\napi = [\n{deps_str}\n]",
|
|
132
|
+
content
|
|
133
|
+
)
|
|
134
|
+
else:
|
|
135
|
+
content += f'\n[project.optional-dependencies]\napi = [\n{deps_str}\n]\n'
|
|
96
136
|
|
|
97
|
-
|
|
98
|
-
|
|
137
|
+
with open(pyproject_path, "wb") as f:
|
|
138
|
+
f.write(content.encode())
|
|
99
139
|
|
|
100
140
|
except Exception as e:
|
|
101
|
-
logger.
|
|
141
|
+
logger.warn(f"Failed to update pyproject.toml: {e}")
|
|
102
142
|
|
|
103
143
|
# Move cave_api/tests/ to top-level tests/ before restructuring cave_api
|
|
104
144
|
cave_api_tests = app_path / "cave_api" / "tests"
|
|
@@ -117,7 +157,7 @@ def migrate_3_6_0(app_dir: str) -> None:
|
|
|
117
157
|
shutil.move(cave_api, legacy_cave_api)
|
|
118
158
|
shutil.copytree(legacy_cave_api / "cave_api", cave_api)
|
|
119
159
|
else:
|
|
120
|
-
logger.
|
|
160
|
+
logger.warn("Expected cave_api/cave_api directory not found, skipping API migration steps.")
|
|
121
161
|
|
|
122
162
|
# Update code references, skipping legacy/ and tool directories
|
|
123
163
|
for py_file in app_path.rglob("*.py"):
|
|
@@ -140,7 +180,7 @@ def migrate_3_6_0(app_dir: str) -> None:
|
|
|
140
180
|
if new_content != content:
|
|
141
181
|
py_file.write_text(new_content)
|
|
142
182
|
except Exception as e:
|
|
143
|
-
logger.
|
|
183
|
+
logger.warn(f"Failed to migrate code in {py_file}: {e}")
|
|
144
184
|
|
|
145
185
|
step_done("Applying 3.6.0 Migrations")
|
|
146
186
|
|
|
@@ -157,7 +197,24 @@ def upgrade(args: argparse.Namespace) -> None:
|
|
|
157
197
|
version = getattr(args, "version", None)
|
|
158
198
|
skip_env_upgrade = getattr(args, "skip_env_upgrade", False)
|
|
159
199
|
|
|
200
|
+
# Collect existing api optional dependencies
|
|
201
|
+
api_deps = []
|
|
202
|
+
pyproject_path = os.path.join(app_dir, "pyproject.toml")
|
|
203
|
+
if os.path.exists(pyproject_path):
|
|
204
|
+
try:
|
|
205
|
+
import tomllib
|
|
206
|
+
with open(pyproject_path, "rb") as f:
|
|
207
|
+
pyproject = tomllib.load(f)
|
|
208
|
+
api_deps = pyproject.get("project", {}).get("optional-dependencies", {}).get("api", [])
|
|
209
|
+
except Exception:
|
|
210
|
+
pass
|
|
211
|
+
|
|
160
212
|
current_v = get_app_version(app_dir)
|
|
213
|
+
current_version = version_tuple(current_v)
|
|
214
|
+
is_pre_3_6 = (
|
|
215
|
+
(current_version and current_version < (3, 6, 0)) or
|
|
216
|
+
(not current_version and os.path.exists(os.path.join(app_dir, "requirements.txt")))
|
|
217
|
+
)
|
|
161
218
|
target_v = version or "main"
|
|
162
219
|
|
|
163
220
|
temp_dir = None
|
|
@@ -193,9 +250,40 @@ def upgrade(args: argparse.Namespace) -> None:
|
|
|
193
250
|
|
|
194
251
|
sync_cmd(sync_args, do_reset=False)
|
|
195
252
|
|
|
196
|
-
|
|
253
|
+
if api_deps and os.path.exists(pyproject_path):
|
|
254
|
+
try:
|
|
255
|
+
with open(pyproject_path, "r") as f:
|
|
256
|
+
content = f.read()
|
|
257
|
+
|
|
258
|
+
deps_list_str = "\n".join(f' "{dep}",' for dep in api_deps)
|
|
259
|
+
|
|
260
|
+
if "[project.optional-dependencies]" in content:
|
|
261
|
+
if re.search(r"api\s*=\s*\[", content):
|
|
262
|
+
content = re.sub(
|
|
263
|
+
r"(api\s*=\s*\[)[^\]]*(\])",
|
|
264
|
+
f"\\1\n{deps_list_str}\n \\2",
|
|
265
|
+
content,
|
|
266
|
+
flags=re.DOTALL,
|
|
267
|
+
)
|
|
268
|
+
else:
|
|
269
|
+
content = re.sub(
|
|
270
|
+
r"(\[project\.optional-dependencies\])",
|
|
271
|
+
f"\\1\napi = [\n{deps_list_str}\n]",
|
|
272
|
+
content,
|
|
273
|
+
)
|
|
274
|
+
else:
|
|
275
|
+
content += (
|
|
276
|
+
f"\n[project.optional-dependencies]\napi = [\n{deps_list_str}\n]\n"
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
with open(pyproject_path, "w") as f:
|
|
280
|
+
f.write(content)
|
|
281
|
+
logger.info("Preserved 'api' optional dependencies in pyproject.toml")
|
|
282
|
+
except Exception as e:
|
|
283
|
+
logger.warn(f"Failed to preserve 'api' optional dependencies: {e}")
|
|
284
|
+
|
|
197
285
|
target_version = version_tuple(target_v)
|
|
198
|
-
if
|
|
286
|
+
if is_pre_3_6 and target_version and target_version >= (3, 6, 0):
|
|
199
287
|
should_migrate = confirm_action(
|
|
200
288
|
"Upgrading to 3.6.0 requires structural changes to your codebase "
|
|
201
289
|
"(directory layout, import paths, and dependency files). "
|
|
@@ -229,7 +317,7 @@ def upgrade(args: argparse.Namespace) -> None:
|
|
|
229
317
|
|
|
230
318
|
remove_licence_info(app_dir)
|
|
231
319
|
|
|
232
|
-
step_start("Updating
|
|
320
|
+
step_start("Updating API docs")
|
|
233
321
|
docs_args = argparse.Namespace(
|
|
234
322
|
entrypoint="./utils/generate_docs.sh",
|
|
235
323
|
interactive=False,
|
|
@@ -241,6 +329,6 @@ def upgrade(args: argparse.Namespace) -> None:
|
|
|
241
329
|
loglevel=getattr(args, "loglevel", "INFO"),
|
|
242
330
|
)
|
|
243
331
|
run_cave(app_dir, app_name, docs_args)
|
|
244
|
-
step_done("Updating
|
|
332
|
+
step_done("Updating API docs")
|
|
245
333
|
|
|
246
334
|
logger.success("Upgrade complete.")
|
|
@@ -6,6 +6,24 @@ from pathlib import Path
|
|
|
6
6
|
from cave_cli.utils.logger import logger
|
|
7
7
|
|
|
8
8
|
|
|
9
|
+
def can_create_symlinks() -> bool:
|
|
10
|
+
"""
|
|
11
|
+
Check if the current user/OS environment has permission to create symbolic links.
|
|
12
|
+
On Windows, this requires Developer Mode or Administrator privileges.
|
|
13
|
+
"""
|
|
14
|
+
import tempfile
|
|
15
|
+
try:
|
|
16
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
17
|
+
tmp_file = os.path.join(tmpdir, "file")
|
|
18
|
+
tmp_link = os.path.join(tmpdir, "link")
|
|
19
|
+
with open(tmp_file, "w") as f:
|
|
20
|
+
f.write("")
|
|
21
|
+
os.symlink(tmp_file, tmp_link)
|
|
22
|
+
return True
|
|
23
|
+
except OSError:
|
|
24
|
+
return False
|
|
25
|
+
|
|
26
|
+
|
|
9
27
|
def sync_files(
|
|
10
28
|
source: str,
|
|
11
29
|
dest: str,
|
|
@@ -50,6 +68,8 @@ def sync_files(
|
|
|
50
68
|
clean_excludes = [strip_quotes(p) for p in (excludes or [])]
|
|
51
69
|
clean_excludes.append(".git")
|
|
52
70
|
|
|
71
|
+
symlinks_supported = can_create_symlinks()
|
|
72
|
+
|
|
53
73
|
def ignore_fn(directory: str, contents: list[str]) -> set[str]:
|
|
54
74
|
rel_dir = os.path.relpath(directory, source)
|
|
55
75
|
ignored: set[str] = set()
|
|
@@ -58,14 +78,33 @@ def sync_files(
|
|
|
58
78
|
rel_path = name
|
|
59
79
|
else:
|
|
60
80
|
rel_path = os.path.join(rel_dir, name)
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
if matches_any(rel_path, name,
|
|
64
|
-
|
|
81
|
+
|
|
82
|
+
is_ignored = False
|
|
83
|
+
if not matches_any(rel_path, name, clean_includes):
|
|
84
|
+
if matches_any(rel_path, name, clean_excludes):
|
|
85
|
+
ignored.add(name)
|
|
86
|
+
is_ignored = True
|
|
87
|
+
|
|
88
|
+
if not is_ignored:
|
|
89
|
+
# If we are syncing this file/directory, resolve any conflicts at the destination.
|
|
90
|
+
dst_path = os.path.join(dest, rel_path)
|
|
91
|
+
if os.path.lexists(dst_path):
|
|
92
|
+
src_path = os.path.join(directory, name)
|
|
93
|
+
# If both are concrete directories, let shutil.copytree's dirs_exist_ok merge them.
|
|
94
|
+
# Otherwise (file vs file, link vs file, etc.), we must remove the destination path
|
|
95
|
+
# first to avoid conflicts and FileExistsError (especially when copying symbolic links).
|
|
96
|
+
is_src_dir = os.path.isdir(src_path) and not os.path.islink(src_path)
|
|
97
|
+
is_dst_dir = os.path.isdir(dst_path) and not os.path.islink(dst_path)
|
|
98
|
+
if not (is_src_dir and is_dst_dir):
|
|
99
|
+
if is_dst_dir:
|
|
100
|
+
shutil.rmtree(dst_path)
|
|
101
|
+
else:
|
|
102
|
+
os.unlink(dst_path)
|
|
103
|
+
|
|
65
104
|
return ignored
|
|
66
105
|
|
|
67
106
|
shutil.copytree(
|
|
68
|
-
source, dest, ignore=ignore_fn, dirs_exist_ok=True
|
|
107
|
+
source, dest, ignore=ignore_fn, dirs_exist_ok=True, symlinks=symlinks_supported
|
|
69
108
|
)
|
|
70
109
|
|
|
71
110
|
|
|
@@ -84,7 +84,16 @@ def validate_app_dir(path: str) -> list[str]:
|
|
|
84
84
|
p = Path(path)
|
|
85
85
|
errors: list[str] = []
|
|
86
86
|
|
|
87
|
-
if
|
|
87
|
+
# Check if the directory has any characteristic CAVE project elements.
|
|
88
|
+
# If not, we immediately return "Not a CAVE app directory" to avoid printing
|
|
89
|
+
# unrelated errors for completely different directories when searching up the tree.
|
|
90
|
+
is_cave_candidate = (
|
|
91
|
+
(p / "manage.py").is_file()
|
|
92
|
+
or (p / "cave_core").is_dir()
|
|
93
|
+
or (p / "cave_api").is_dir()
|
|
94
|
+
or (p / "cave_app").is_dir()
|
|
95
|
+
)
|
|
96
|
+
if not is_cave_candidate:
|
|
88
97
|
return ["Not a CAVE app directory"]
|
|
89
98
|
|
|
90
99
|
for folder in ("cave_api", "cave_app", "cave_core"):
|
|
@@ -114,7 +123,6 @@ def validate_app_dir(path: str) -> list[str]:
|
|
|
114
123
|
"One of these must be present in the root project directory."
|
|
115
124
|
)
|
|
116
125
|
|
|
117
|
-
|
|
118
126
|
env_path = p / ".env"
|
|
119
127
|
if env_path.is_file():
|
|
120
128
|
from cave_cli.utils.env import parse_env
|
|
@@ -133,9 +141,6 @@ def validate_app_dir(path: str) -> list[str]:
|
|
|
133
141
|
"should be removed from the '.env' file."
|
|
134
142
|
)
|
|
135
143
|
|
|
136
|
-
if not (p / "Dockerfile").is_file():
|
|
137
|
-
errors.append("No Dockerfile found in current directory.")
|
|
138
|
-
|
|
139
144
|
return errors
|
|
140
145
|
|
|
141
146
|
|
|
@@ -159,13 +164,15 @@ def find_app_dir(start: str | None = None) -> str:
|
|
|
159
164
|
- What: The absolute path to the CAVE app directory
|
|
160
165
|
"""
|
|
161
166
|
path = Path(start or os.getcwd()).resolve()
|
|
167
|
+
start_path = path
|
|
162
168
|
while True:
|
|
163
169
|
errors = validate_app_dir(str(path))
|
|
164
170
|
if not errors:
|
|
165
171
|
return str(path)
|
|
166
172
|
parent = path.parent
|
|
167
173
|
if parent == path:
|
|
168
|
-
|
|
174
|
+
start_errors = validate_app_dir(str(start_path))
|
|
175
|
+
for err in start_errors:
|
|
169
176
|
logger.error(err)
|
|
170
177
|
logger.error("Ensure you are in a valid CAVE app directory")
|
|
171
178
|
sys.exit(1)
|
|
@@ -12,7 +12,7 @@ build-backend = "setuptools.build_meta"
|
|
|
12
12
|
|
|
13
13
|
[project]
|
|
14
14
|
name = "cave_cli"
|
|
15
|
-
version = "3.6.
|
|
15
|
+
version = "3.6.0b2"
|
|
16
16
|
description = "CLI for creating and managing Docker-based CAVE web applications."
|
|
17
17
|
authors = [
|
|
18
18
|
{name = "MIT-CAVE", email = "cave-contact@mit.edu"}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from cave_cli.utils.sync import strip_quotes, matches_any, sync_files
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class TestStripQuotes:
|
|
6
|
+
def test_single_quotes(self):
|
|
7
|
+
assert strip_quotes("'hello'") == "hello"
|
|
8
|
+
|
|
9
|
+
def test_double_quotes(self):
|
|
10
|
+
assert strip_quotes('"hello"') == "hello"
|
|
11
|
+
|
|
12
|
+
def test_no_quotes(self):
|
|
13
|
+
assert strip_quotes("hello") == "hello"
|
|
14
|
+
|
|
15
|
+
def test_mismatched_quotes(self):
|
|
16
|
+
assert strip_quotes("'hello\"") == "'hello\""
|
|
17
|
+
|
|
18
|
+
def test_strips_whitespace(self):
|
|
19
|
+
assert strip_quotes(" 'hello' ") == "hello"
|
|
20
|
+
|
|
21
|
+
def test_empty_quoted(self):
|
|
22
|
+
assert strip_quotes("''") == ""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class TestMatchesAny:
|
|
26
|
+
def test_name_wildcard(self):
|
|
27
|
+
assert matches_any("subdir/file.py", "file.py", ["*.py"])
|
|
28
|
+
|
|
29
|
+
def test_rel_path_match(self):
|
|
30
|
+
assert matches_any("subdir/file.txt", "file.txt", ["subdir/*"])
|
|
31
|
+
|
|
32
|
+
def test_no_match(self):
|
|
33
|
+
assert not matches_any("file.txt", "file.txt", ["*.py"])
|
|
34
|
+
|
|
35
|
+
def test_empty_patterns(self):
|
|
36
|
+
assert not matches_any("file.txt", "file.txt", [])
|
|
37
|
+
|
|
38
|
+
def test_git_directory(self):
|
|
39
|
+
assert matches_any(".git", ".git", [".git"])
|
|
40
|
+
|
|
41
|
+
def test_exact_rel_path(self):
|
|
42
|
+
assert matches_any("docs/readme.md", "readme.md", ["docs/readme.md"])
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class TestSyncFiles:
|
|
46
|
+
def test_sync_files_preserves_and_overwrites_symlinks(self, tmp_path):
|
|
47
|
+
import os
|
|
48
|
+
|
|
49
|
+
src = tmp_path / "src"
|
|
50
|
+
dst = tmp_path / "dst"
|
|
51
|
+
src.mkdir()
|
|
52
|
+
dst.mkdir()
|
|
53
|
+
|
|
54
|
+
# Create source structure:
|
|
55
|
+
# target.txt (file)
|
|
56
|
+
# link.txt (symlink -> target.txt)
|
|
57
|
+
# file.txt (file)
|
|
58
|
+
# dir_to_file (file)
|
|
59
|
+
with open(src / "target.txt", "w") as f:
|
|
60
|
+
f.write("target content")
|
|
61
|
+
os.symlink("target.txt", src / "link.txt")
|
|
62
|
+
with open(src / "file.txt", "w") as f:
|
|
63
|
+
f.write("new file content")
|
|
64
|
+
with open(src / "dir_to_file", "w") as f:
|
|
65
|
+
f.write("new file content replacing directory")
|
|
66
|
+
|
|
67
|
+
# Create destination structure:
|
|
68
|
+
# link.txt (regular file)
|
|
69
|
+
# file.txt (symlink -> other)
|
|
70
|
+
# dir_to_file (directory)
|
|
71
|
+
with open(dst / "link.txt", "w") as f:
|
|
72
|
+
f.write("old regular file")
|
|
73
|
+
with open(dst / "other.txt", "w") as f:
|
|
74
|
+
f.write("other")
|
|
75
|
+
os.symlink("other.txt", dst / "file.txt")
|
|
76
|
+
(dst / "dir_to_file").mkdir()
|
|
77
|
+
|
|
78
|
+
sync_files(str(src), str(dst))
|
|
79
|
+
|
|
80
|
+
assert os.path.islink(dst / "link.txt")
|
|
81
|
+
assert os.readlink(dst / "link.txt") == "target.txt"
|
|
82
|
+
assert not os.path.islink(dst / "file.txt")
|
|
83
|
+
assert (dst / "file.txt").read_text() == "new file content"
|
|
84
|
+
assert not os.path.islink(dst / "dir_to_file")
|
|
85
|
+
assert not os.path.isdir(dst / "dir_to_file")
|
|
86
|
+
assert (dst / "dir_to_file").read_text() == "new file content replacing directory"
|
|
87
|
+
|
|
88
|
+
def test_can_create_symlinks(self):
|
|
89
|
+
from cave_cli.utils.sync import can_create_symlinks
|
|
90
|
+
res = can_create_symlinks()
|
|
91
|
+
assert isinstance(res, bool)
|
|
92
|
+
|
|
93
|
+
def test_sync_files_fallback_when_symlinks_unsupported(self, tmp_path, monkeypatch):
|
|
94
|
+
import os
|
|
95
|
+
import cave_cli.utils.sync
|
|
96
|
+
from cave_cli.utils.sync import sync_files
|
|
97
|
+
|
|
98
|
+
monkeypatch.setattr(cave_cli.utils.sync, "can_create_symlinks", lambda: False)
|
|
99
|
+
|
|
100
|
+
src = tmp_path / "src"
|
|
101
|
+
dst = tmp_path / "dst"
|
|
102
|
+
src.mkdir()
|
|
103
|
+
dst.mkdir()
|
|
104
|
+
|
|
105
|
+
# Create source structure:
|
|
106
|
+
# target.txt (file)
|
|
107
|
+
# link.txt (symlink -> target.txt)
|
|
108
|
+
with open(src / "target.txt", "w") as f:
|
|
109
|
+
f.write("target content")
|
|
110
|
+
os.symlink("target.txt", src / "link.txt")
|
|
111
|
+
|
|
112
|
+
sync_files(str(src), str(dst))
|
|
113
|
+
|
|
114
|
+
# Because symlinks are unsupported, dst/link.txt should be created as a regular file with the content of target.txt.
|
|
115
|
+
assert not os.path.islink(dst / "link.txt")
|
|
116
|
+
assert (dst / "link.txt").read_text() == "target content"
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import shutil
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from cave_cli.commands.upgrade import migrate_3_6_0
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def test_migrate_3_6_0_renames_models_file(tmp_path):
|
|
8
|
+
# Set up directory layout
|
|
9
|
+
app_dir = tmp_path / "app"
|
|
10
|
+
app_dir.mkdir()
|
|
11
|
+
|
|
12
|
+
# Create cave_core directory and models.py
|
|
13
|
+
cave_core_dir = app_dir / "cave_core"
|
|
14
|
+
cave_core_dir.mkdir()
|
|
15
|
+
models_file = cave_core_dir / "models.py"
|
|
16
|
+
models_file.write_text("class MyModel:")
|
|
17
|
+
|
|
18
|
+
# Create requirements.txt
|
|
19
|
+
req_file = app_dir / "requirements.txt"
|
|
20
|
+
req_file.write_text("numpy\npandas")
|
|
21
|
+
|
|
22
|
+
# Create VERSION file to verify it gets removed
|
|
23
|
+
version_file = app_dir / "VERSION"
|
|
24
|
+
version_file.write_text("3.5.0")
|
|
25
|
+
|
|
26
|
+
# Create root pyproject.toml
|
|
27
|
+
pyproject_file = app_dir / "pyproject.toml"
|
|
28
|
+
pyproject_file.write_text("[project]\nname = 'myapp'\nversion = '1.0.0'\ndependencies = []\n")
|
|
29
|
+
|
|
30
|
+
# Create cave_api directory
|
|
31
|
+
cave_api_dir = app_dir / "cave_api"
|
|
32
|
+
cave_api_dir.mkdir()
|
|
33
|
+
|
|
34
|
+
# Create cave_api/requirements.txt
|
|
35
|
+
cave_api_req = cave_api_dir / "requirements.txt"
|
|
36
|
+
cave_api_req.write_text("requests>=2.0.0\n# comment\npytest\n")
|
|
37
|
+
|
|
38
|
+
# Create cave_api/pyproject.toml
|
|
39
|
+
cave_api_pyproject = cave_api_dir / "pyproject.toml"
|
|
40
|
+
cave_api_pyproject.write_text("[project]\ndependencies = [\n 'pytest',\n 'django>=4.0'\n]\n")
|
|
41
|
+
|
|
42
|
+
# Run migration
|
|
43
|
+
migrate_3_6_0(str(app_dir))
|
|
44
|
+
|
|
45
|
+
# Assertions
|
|
46
|
+
assert not models_file.exists()
|
|
47
|
+
legacy_dir = app_dir / "legacy"
|
|
48
|
+
assert (legacy_dir / "cave_core" / "models.py").exists()
|
|
49
|
+
assert (legacy_dir / "cave_core" / "models.py").read_text() == "class MyModel:"
|
|
50
|
+
assert not version_file.exists()
|
|
51
|
+
assert not req_file.exists()
|
|
52
|
+
assert (legacy_dir / "requirements.txt").exists()
|
|
53
|
+
assert (legacy_dir / "requirements.txt").read_text() == "numpy\npandas"
|
|
54
|
+
|
|
55
|
+
# Assert that pyproject.toml has merged api dependencies
|
|
56
|
+
pyproject_content = pyproject_file.read_text()
|
|
57
|
+
assert "[project.optional-dependencies]" in pyproject_content
|
|
58
|
+
assert '"requests>=2.0.0"' in pyproject_content
|
|
59
|
+
assert '"pytest"' in pyproject_content
|
|
60
|
+
assert '"django>=4.0"' in pyproject_content
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from cave_cli.utils.validate import validate_app_name
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class TestValidateAppName:
|
|
6
|
+
def test_valid_simple(self):
|
|
7
|
+
assert validate_app_name("myapp") is None
|
|
8
|
+
|
|
9
|
+
def test_valid_with_hyphen(self):
|
|
10
|
+
assert validate_app_name("my-app") is None
|
|
11
|
+
|
|
12
|
+
def test_valid_with_underscore(self):
|
|
13
|
+
assert validate_app_name("my_app") is None
|
|
14
|
+
|
|
15
|
+
def test_valid_with_numbers(self):
|
|
16
|
+
assert validate_app_name("app123") is None
|
|
17
|
+
|
|
18
|
+
def test_too_short(self):
|
|
19
|
+
assert validate_app_name("a") is not None
|
|
20
|
+
|
|
21
|
+
def test_uppercase_rejected(self):
|
|
22
|
+
assert validate_app_name("MyApp") is not None
|
|
23
|
+
|
|
24
|
+
def test_starts_with_hyphen(self):
|
|
25
|
+
assert validate_app_name("-myapp") is not None
|
|
26
|
+
|
|
27
|
+
def test_starts_with_underscore(self):
|
|
28
|
+
assert validate_app_name("_myapp") is not None
|
|
29
|
+
|
|
30
|
+
def test_ends_with_hyphen(self):
|
|
31
|
+
assert validate_app_name("myapp-") is not None
|
|
32
|
+
|
|
33
|
+
def test_ends_with_underscore(self):
|
|
34
|
+
assert validate_app_name("myapp_") is not None
|
|
35
|
+
|
|
36
|
+
def test_hyphen_followed_by_underscore(self):
|
|
37
|
+
assert validate_app_name("my-_app") is not None
|
|
38
|
+
|
|
39
|
+
def test_underscore_followed_by_hyphen(self):
|
|
40
|
+
assert validate_app_name("my_-app") is not None
|
|
41
|
+
|
|
42
|
+
def test_spaces_rejected(self):
|
|
43
|
+
assert validate_app_name("my app") is not None
|
|
44
|
+
|
|
45
|
+
def test_too_long(self):
|
|
46
|
+
assert validate_app_name("a" * 256) is not None
|
|
47
|
+
|
|
48
|
+
def test_max_valid_length(self):
|
|
49
|
+
assert validate_app_name("a" * 255) is None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class TestValidateAppDir:
|
|
53
|
+
def test_not_a_candidate(self, tmp_path):
|
|
54
|
+
from cave_cli.utils.validate import validate_app_dir
|
|
55
|
+
# Completely empty dir
|
|
56
|
+
res = validate_app_dir(str(tmp_path))
|
|
57
|
+
assert res == ["Not a CAVE app directory"]
|
|
58
|
+
|
|
59
|
+
def test_candidate_missing_folders_files(self, tmp_path):
|
|
60
|
+
from cave_cli.utils.validate import validate_app_dir
|
|
61
|
+
# Contains manage.py, so it is a candidate
|
|
62
|
+
(tmp_path / "manage.py").write_text("")
|
|
63
|
+
|
|
64
|
+
res = validate_app_dir(str(tmp_path))
|
|
65
|
+
assert any("The folder 'cave_core' is missing" in err for err in res)
|
|
66
|
+
assert any("The folder 'cave_api' is missing" in err for err in res)
|
|
67
|
+
assert any("The file 'Dockerfile' is missing" in err for err in res)
|
|
68
|
+
assert any("Neither 'requirements.txt' nor 'pyproject.toml' exists." in err for err in res)
|
|
69
|
+
|
|
70
|
+
def test_find_app_dir_reports_starting_dir_errors(self, tmp_path, monkeypatch):
|
|
71
|
+
import sys
|
|
72
|
+
from cave_cli.utils.validate import find_app_dir
|
|
73
|
+
|
|
74
|
+
# We start in a directory that has manage.py (so it's a candidate) but is missing other things.
|
|
75
|
+
start_dir = tmp_path / "my_app"
|
|
76
|
+
start_dir.mkdir()
|
|
77
|
+
(start_dir / "manage.py").write_text("")
|
|
78
|
+
|
|
79
|
+
# We'll mock sys.exit and logger to verify that when it reaches the root,
|
|
80
|
+
# it prints the errors of the starting directory start_dir.
|
|
81
|
+
logged_errors = []
|
|
82
|
+
class MockLogger:
|
|
83
|
+
def error(self, msg):
|
|
84
|
+
logged_errors.append(msg)
|
|
85
|
+
|
|
86
|
+
import cave_cli.utils.validate
|
|
87
|
+
monkeypatch.setattr(cave_cli.utils.validate, "logger", MockLogger())
|
|
88
|
+
|
|
89
|
+
exit_called = False
|
|
90
|
+
def mock_exit(code):
|
|
91
|
+
nonlocal exit_called
|
|
92
|
+
exit_called = True
|
|
93
|
+
raise SystemExit(code)
|
|
94
|
+
|
|
95
|
+
monkeypatch.setattr(sys, "exit", mock_exit)
|
|
96
|
+
|
|
97
|
+
with pytest.raises(SystemExit):
|
|
98
|
+
find_app_dir(str(start_dir))
|
|
99
|
+
|
|
100
|
+
assert exit_called
|
|
101
|
+
# Check that it printed errors about start_dir (e.g. missing cave_core)
|
|
102
|
+
any_missing_core = any("The folder 'cave_core' is missing" in err for err in logged_errors)
|
|
103
|
+
assert any_missing_core
|
|
@@ -1,42 +0,0 @@
|
|
|
1
|
-
import pytest
|
|
2
|
-
from cave_cli.utils.sync import strip_quotes, matches_any
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
class TestStripQuotes:
|
|
6
|
-
def test_single_quotes(self):
|
|
7
|
-
assert strip_quotes("'hello'") == "hello"
|
|
8
|
-
|
|
9
|
-
def test_double_quotes(self):
|
|
10
|
-
assert strip_quotes('"hello"') == "hello"
|
|
11
|
-
|
|
12
|
-
def test_no_quotes(self):
|
|
13
|
-
assert strip_quotes("hello") == "hello"
|
|
14
|
-
|
|
15
|
-
def test_mismatched_quotes(self):
|
|
16
|
-
assert strip_quotes("'hello\"") == "'hello\""
|
|
17
|
-
|
|
18
|
-
def test_strips_whitespace(self):
|
|
19
|
-
assert strip_quotes(" 'hello' ") == "hello"
|
|
20
|
-
|
|
21
|
-
def test_empty_quoted(self):
|
|
22
|
-
assert strip_quotes("''") == ""
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
class TestMatchesAny:
|
|
26
|
-
def test_name_wildcard(self):
|
|
27
|
-
assert matches_any("subdir/file.py", "file.py", ["*.py"])
|
|
28
|
-
|
|
29
|
-
def test_rel_path_match(self):
|
|
30
|
-
assert matches_any("subdir/file.txt", "file.txt", ["subdir/*"])
|
|
31
|
-
|
|
32
|
-
def test_no_match(self):
|
|
33
|
-
assert not matches_any("file.txt", "file.txt", ["*.py"])
|
|
34
|
-
|
|
35
|
-
def test_empty_patterns(self):
|
|
36
|
-
assert not matches_any("file.txt", "file.txt", [])
|
|
37
|
-
|
|
38
|
-
def test_git_directory(self):
|
|
39
|
-
assert matches_any(".git", ".git", [".git"])
|
|
40
|
-
|
|
41
|
-
def test_exact_rel_path(self):
|
|
42
|
-
assert matches_any("docs/readme.md", "readme.md", ["docs/readme.md"])
|
|
@@ -1,49 +0,0 @@
|
|
|
1
|
-
import pytest
|
|
2
|
-
from cave_cli.utils.validate import validate_app_name
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
class TestValidateAppName:
|
|
6
|
-
def test_valid_simple(self):
|
|
7
|
-
assert validate_app_name("myapp") is None
|
|
8
|
-
|
|
9
|
-
def test_valid_with_hyphen(self):
|
|
10
|
-
assert validate_app_name("my-app") is None
|
|
11
|
-
|
|
12
|
-
def test_valid_with_underscore(self):
|
|
13
|
-
assert validate_app_name("my_app") is None
|
|
14
|
-
|
|
15
|
-
def test_valid_with_numbers(self):
|
|
16
|
-
assert validate_app_name("app123") is None
|
|
17
|
-
|
|
18
|
-
def test_too_short(self):
|
|
19
|
-
assert validate_app_name("a") is not None
|
|
20
|
-
|
|
21
|
-
def test_uppercase_rejected(self):
|
|
22
|
-
assert validate_app_name("MyApp") is not None
|
|
23
|
-
|
|
24
|
-
def test_starts_with_hyphen(self):
|
|
25
|
-
assert validate_app_name("-myapp") is not None
|
|
26
|
-
|
|
27
|
-
def test_starts_with_underscore(self):
|
|
28
|
-
assert validate_app_name("_myapp") is not None
|
|
29
|
-
|
|
30
|
-
def test_ends_with_hyphen(self):
|
|
31
|
-
assert validate_app_name("myapp-") is not None
|
|
32
|
-
|
|
33
|
-
def test_ends_with_underscore(self):
|
|
34
|
-
assert validate_app_name("myapp_") is not None
|
|
35
|
-
|
|
36
|
-
def test_hyphen_followed_by_underscore(self):
|
|
37
|
-
assert validate_app_name("my-_app") is not None
|
|
38
|
-
|
|
39
|
-
def test_underscore_followed_by_hyphen(self):
|
|
40
|
-
assert validate_app_name("my_-app") is not None
|
|
41
|
-
|
|
42
|
-
def test_spaces_rejected(self):
|
|
43
|
-
assert validate_app_name("my app") is not None
|
|
44
|
-
|
|
45
|
-
def test_too_long(self):
|
|
46
|
-
assert validate_app_name("a" * 256) is not None
|
|
47
|
-
|
|
48
|
-
def test_max_valid_length(self):
|
|
49
|
-
assert validate_app_name("a" * 255) is None
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|