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.
Files changed (52) hide show
  1. {cave_cli-3.6.0b1/cave_cli.egg-info → cave_cli-3.6.0b2}/PKG-INFO +1 -1
  2. {cave_cli-3.6.0b1 → cave_cli-3.6.0b2}/cave_cli/commands/upgrade.py +118 -30
  3. {cave_cli-3.6.0b1 → cave_cli-3.6.0b2}/cave_cli/utils/sync.py +44 -5
  4. {cave_cli-3.6.0b1 → cave_cli-3.6.0b2}/cave_cli/utils/validate.py +13 -6
  5. {cave_cli-3.6.0b1 → cave_cli-3.6.0b2/cave_cli.egg-info}/PKG-INFO +1 -1
  6. {cave_cli-3.6.0b1 → cave_cli-3.6.0b2}/cave_cli.egg-info/SOURCES.txt +1 -0
  7. {cave_cli-3.6.0b1 → cave_cli-3.6.0b2}/pyproject.toml +1 -1
  8. cave_cli-3.6.0b2/test/test_sync.py +116 -0
  9. cave_cli-3.6.0b2/test/test_upgrade.py +60 -0
  10. cave_cli-3.6.0b2/test/test_validate.py +103 -0
  11. cave_cli-3.6.0b1/test/test_sync.py +0 -42
  12. cave_cli-3.6.0b1/test/test_validate.py +0 -49
  13. {cave_cli-3.6.0b1 → cave_cli-3.6.0b2}/LICENSE +0 -0
  14. {cave_cli-3.6.0b1 → cave_cli-3.6.0b2}/NOTICE.md +0 -0
  15. {cave_cli-3.6.0b1 → cave_cli-3.6.0b2}/README.md +0 -0
  16. {cave_cli-3.6.0b1 → cave_cli-3.6.0b2}/cave_cli/__init__.py +0 -0
  17. {cave_cli-3.6.0b1 → cave_cli-3.6.0b2}/cave_cli/cli.py +0 -0
  18. {cave_cli-3.6.0b1 → cave_cli-3.6.0b2}/cave_cli/commands/__init__.py +0 -0
  19. {cave_cli-3.6.0b1 → cave_cli-3.6.0b2}/cave_cli/commands/create.py +0 -0
  20. {cave_cli-3.6.0b1 → cave_cli-3.6.0b2}/cave_cli/commands/doctor.py +0 -0
  21. {cave_cli-3.6.0b1 → cave_cli-3.6.0b2}/cave_cli/commands/kill.py +0 -0
  22. {cave_cli-3.6.0b1 → cave_cli-3.6.0b2}/cave_cli/commands/list_cmd.py +0 -0
  23. {cave_cli-3.6.0b1 → cave_cli-3.6.0b2}/cave_cli/commands/list_versions.py +0 -0
  24. {cave_cli-3.6.0b1 → cave_cli-3.6.0b2}/cave_cli/commands/prettify.py +0 -0
  25. {cave_cli-3.6.0b1 → cave_cli-3.6.0b2}/cave_cli/commands/purge.py +0 -0
  26. {cave_cli-3.6.0b1 → cave_cli-3.6.0b2}/cave_cli/commands/reset.py +0 -0
  27. {cave_cli-3.6.0b1 → cave_cli-3.6.0b2}/cave_cli/commands/run.py +0 -0
  28. {cave_cli-3.6.0b1 → cave_cli-3.6.0b2}/cave_cli/commands/sync_cmd.py +0 -0
  29. {cave_cli-3.6.0b1 → cave_cli-3.6.0b2}/cave_cli/commands/test.py +0 -0
  30. {cave_cli-3.6.0b1 → cave_cli-3.6.0b2}/cave_cli/commands/theme.py +0 -0
  31. {cave_cli-3.6.0b1 → cave_cli-3.6.0b2}/cave_cli/commands/uninstall.py +0 -0
  32. {cave_cli-3.6.0b1 → cave_cli-3.6.0b2}/cave_cli/commands/update.py +0 -0
  33. {cave_cli-3.6.0b1 → cave_cli-3.6.0b2}/cave_cli/commands/version.py +0 -0
  34. {cave_cli-3.6.0b1 → cave_cli-3.6.0b2}/cave_cli/utils/__init__.py +0 -0
  35. {cave_cli-3.6.0b1 → cave_cli-3.6.0b2}/cave_cli/utils/cache.py +0 -0
  36. {cave_cli-3.6.0b1 → cave_cli-3.6.0b2}/cave_cli/utils/constants.py +0 -0
  37. {cave_cli-3.6.0b1 → cave_cli-3.6.0b2}/cave_cli/utils/display.py +0 -0
  38. {cave_cli-3.6.0b1 → cave_cli-3.6.0b2}/cave_cli/utils/docker.py +0 -0
  39. {cave_cli-3.6.0b1 → cave_cli-3.6.0b2}/cave_cli/utils/env.py +0 -0
  40. {cave_cli-3.6.0b1 → cave_cli-3.6.0b2}/cave_cli/utils/git.py +0 -0
  41. {cave_cli-3.6.0b1 → cave_cli-3.6.0b2}/cave_cli/utils/logger.py +0 -0
  42. {cave_cli-3.6.0b1 → cave_cli-3.6.0b2}/cave_cli/utils/net.py +0 -0
  43. {cave_cli-3.6.0b1 → cave_cli-3.6.0b2}/cave_cli/utils/subprocess.py +0 -0
  44. {cave_cli-3.6.0b1 → cave_cli-3.6.0b2}/cave_cli/utils/version.py +0 -0
  45. {cave_cli-3.6.0b1 → cave_cli-3.6.0b2}/cave_cli.egg-info/dependency_links.txt +0 -0
  46. {cave_cli-3.6.0b1 → cave_cli-3.6.0b2}/cave_cli.egg-info/entry_points.txt +0 -0
  47. {cave_cli-3.6.0b1 → cave_cli-3.6.0b2}/cave_cli.egg-info/requires.txt +0 -0
  48. {cave_cli-3.6.0b1 → cave_cli-3.6.0b2}/cave_cli.egg-info/top_level.txt +0 -0
  49. {cave_cli-3.6.0b1 → cave_cli-3.6.0b2}/setup.cfg +0 -0
  50. {cave_cli-3.6.0b1 → cave_cli-3.6.0b2}/test/test_env.py +0 -0
  51. {cave_cli-3.6.0b1 → cave_cli-3.6.0b2}/test/test_net.py +0 -0
  52. {cave_cli-3.6.0b1 → cave_cli-3.6.0b2}/utils/prettify.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cave_cli
3
- Version: 3.6.0b1
3
+ Version: 3.6.0b2
4
4
  Summary: CLI for creating and managing Docker-based CAVE web applications.
5
5
  Author-email: MIT-CAVE <cave-contact@mit.edu>
6
6
  License: Apache-2.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
- # Collect cave_api requirements before moving the directory
64
- cave_api_requirements = []
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
- cave_api_requirements.extend(cave_api_req.read_text().splitlines())
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.warning(f"Failed to read cave_api requirements: {e}")
86
+ logger.warn(f"Failed to read cave_api requirements: {e}")
71
87
 
72
- # Update pyproject.toml with cave_api optional dependencies
73
- pyproject_path = app_path / "pyproject.toml"
74
- if cave_api_requirements and pyproject_path.exists():
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(pyproject_path, "rb") as f:
77
- content = f.read()
78
- pyproject = tomllib.load(f)
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
- existing_deps = pyproject.get("project", {}).get("dependencies", [])
81
- new_deps = [req for req in cave_api_requirements if req not in existing_deps]
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
- if new_deps:
84
- deps_str = "\n".join(f' "{dep}",' for dep in new_deps)
85
- cave_api_block = f'\n[project.optional-dependencies]\ncave_api = [\n{deps_str}\n]\n'
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
- if "[project.optional-dependencies]" in content.decode():
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'(\[project\.optional-dependencies\].*?cave_api\s*=\s*\[)[^\]]*(\])',
90
- f'\\1\n{deps_str}\n\\2',
91
- content.decode(),
123
+ r"(api\s*=\s*\[)[^\]]*(\])",
124
+ f"\\1\n{deps_str}\n\\2",
125
+ content,
92
126
  flags=re.DOTALL
93
- ).encode()
127
+ )
94
128
  else:
95
- content = content + cave_api_block.encode()
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
- with open(pyproject_path, "wb") as f:
98
- f.write(content)
137
+ with open(pyproject_path, "wb") as f:
138
+ f.write(content.encode())
99
139
 
100
140
  except Exception as e:
101
- logger.warning(f"Failed to update pyproject.toml: {e}")
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.warning("Expected cave_api/cave_api directory not found, skipping API migration steps.")
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.warning(f"Failed to migrate code in {py_file}: {e}")
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
- current_version = version_tuple(current_v)
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 current_version and target_version and current_version < (3, 6, 0) <= target_version:
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 LLM docs")
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 LLM docs")
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
- if matches_any(rel_path, name, clean_includes):
62
- continue
63
- if matches_any(rel_path, name, clean_excludes):
64
- ignored.add(name)
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 not (p / "manage.py").is_file() or not (p / "cave_core").is_dir():
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
- for err in errors:
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)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cave_cli
3
- Version: 3.6.0b1
3
+ Version: 3.6.0b2
4
4
  Summary: CLI for creating and managing Docker-based CAVE web applications.
5
5
  Author-email: MIT-CAVE <cave-contact@mit.edu>
6
6
  License: Apache-2.0
@@ -43,5 +43,6 @@ cave_cli/utils/version.py
43
43
  test/test_env.py
44
44
  test/test_net.py
45
45
  test/test_sync.py
46
+ test/test_upgrade.py
46
47
  test/test_validate.py
47
48
  utils/prettify.py
@@ -12,7 +12,7 @@ build-backend = "setuptools.build_meta"
12
12
 
13
13
  [project]
14
14
  name = "cave_cli"
15
- version = "3.6.0b1"
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