rbx.cp 0.7.0__py3-none-any.whl → 0.9.0__py3-none-any.whl

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 (71) hide show
  1. rbx/box/cd.py +2 -2
  2. rbx/box/cli.py +87 -33
  3. rbx/box/code.py +133 -84
  4. rbx/box/contest/build_contest_statements.py +2 -2
  5. rbx/box/contest/contest_package.py +1 -1
  6. rbx/box/contest/main.py +29 -2
  7. rbx/box/environment.py +140 -80
  8. rbx/box/formatting.py +2 -1
  9. rbx/box/global_package.py +74 -0
  10. rbx/box/package.py +11 -24
  11. rbx/box/packaging/__init__.py +0 -0
  12. rbx/box/packaging/boca/__init__.py +0 -0
  13. rbx/box/packaging/polygon/packager.py +3 -3
  14. rbx/box/presets/__init__.py +369 -53
  15. rbx/box/presets/lock_schema.py +42 -2
  16. rbx/box/presets/schema.py +4 -0
  17. rbx/box/remote.py +21 -2
  18. rbx/box/retries.py +3 -2
  19. rbx/box/sanitizers/warning_stack.py +5 -5
  20. rbx/box/solutions.py +37 -25
  21. rbx/box/statements/build_statements.py +6 -6
  22. rbx/box/statements/builders.py +1 -1
  23. rbx/box/stats.py +10 -0
  24. rbx/box/stresses.py +47 -66
  25. rbx/box/stressing/finder_parser.py +11 -16
  26. rbx/box/tasks.py +33 -22
  27. rbx/box/testcase_utils.py +3 -3
  28. rbx/box/tooling/boca/scraper.py +1 -1
  29. rbx/grading/caching.py +98 -47
  30. rbx/grading/debug_context.py +31 -0
  31. rbx/grading/grading_context.py +96 -0
  32. rbx/grading/judge/cacher.py +93 -21
  33. rbx/grading/judge/sandbox.py +8 -4
  34. rbx/grading/judge/sandboxes/isolate.py +3 -2
  35. rbx/grading/judge/sandboxes/stupid_sandbox.py +3 -2
  36. rbx/grading/judge/sandboxes/timeit.py +1 -1
  37. rbx/grading/judge/storage.py +170 -35
  38. rbx/grading/profiling.py +126 -0
  39. rbx/grading/steps.py +46 -17
  40. rbx/grading/steps_with_caching.py +52 -26
  41. rbx/resources/envs/default.rbx.yml +2 -3
  42. rbx/resources/envs/isolate.rbx.yml +2 -3
  43. rbx/resources/presets/default/contest/.gitignore +6 -0
  44. rbx/resources/presets/default/contest/contest.rbx.yml +14 -1
  45. rbx/resources/presets/default/contest/statement/contest.rbx.tex +24 -86
  46. rbx/resources/presets/default/contest/statement/instructions.tex +40 -0
  47. rbx/resources/presets/default/contest/statement/logo.png +0 -0
  48. rbx/resources/presets/default/env.rbx.yml +67 -0
  49. rbx/resources/presets/default/preset.rbx.yml +6 -2
  50. rbx/resources/presets/default/problem/.gitignore +1 -1
  51. rbx/resources/presets/default/problem/problem.rbx.yml +12 -8
  52. rbx/resources/presets/default/shared/contest_template.rbx.tex +57 -0
  53. rbx/resources/presets/default/shared/icpc.sty +322 -0
  54. rbx/resources/presets/default/shared/problem_template.rbx.tex +57 -0
  55. rbx/submitors/codeforces.py +3 -2
  56. rbx/test.py +1 -1
  57. rbx/utils.py +6 -1
  58. {rbx_cp-0.7.0.dist-info → rbx_cp-0.9.0.dist-info}/METADATA +4 -1
  59. {rbx_cp-0.7.0.dist-info → rbx_cp-0.9.0.dist-info}/RECORD +67 -58
  60. rbx/resources/presets/default/contest/statement/olymp.sty +0 -250
  61. rbx/resources/presets/default/contest/statement/template.rbx.tex +0 -42
  62. rbx/resources/presets/default/problem/statement/olymp.sty +0 -250
  63. rbx/resources/presets/default/problem/statement/template.rbx.tex +0 -89
  64. /rbx/resources/presets/default/problem/{gen.cpp → gens/gen.cpp} +0 -0
  65. /rbx/resources/presets/default/problem/{tests → manual_tests}/samples/000.in +0 -0
  66. /rbx/resources/presets/default/problem/{tests → manual_tests}/samples/001.in +0 -0
  67. /rbx/resources/presets/default/problem/{random.py → testplan/random.py} +0 -0
  68. /rbx/resources/presets/default/problem/{random.txt → testplan/random.txt} +0 -0
  69. {rbx_cp-0.7.0.dist-info → rbx_cp-0.9.0.dist-info}/LICENSE +0 -0
  70. {rbx_cp-0.7.0.dist-info → rbx_cp-0.9.0.dist-info}/WHEEL +0 -0
  71. {rbx_cp-0.7.0.dist-info → rbx_cp-0.9.0.dist-info}/entry_points.txt +0 -0
@@ -1,14 +1,16 @@
1
+ import os
1
2
  import pathlib
2
3
  import shutil
3
4
  import tempfile
4
- from typing import Annotated, Iterable, List, Optional, Sequence, Union
5
+ from typing import Annotated, Iterable, List, Optional, Sequence, Set, Tuple, Union
5
6
 
7
+ import ruyaml
6
8
  import typer
7
9
 
8
10
  from rbx import console, utils
9
11
  from rbx.box import cd
10
12
  from rbx.box.presets.fetch import PresetFetchInfo, get_preset_fetch_info
11
- from rbx.box.presets.lock_schema import LockedAsset, PresetLock
13
+ from rbx.box.presets.lock_schema import LockedAsset, PresetLock, SymlinkInfo
12
14
  from rbx.box.presets.schema import Preset, TrackedAsset
13
15
  from rbx.config import get_default_app_path
14
16
  from rbx.grading.judge.digester import digest_cooperatively
@@ -34,7 +36,7 @@ def get_preset_yaml(root: pathlib.Path = pathlib.Path()) -> Preset:
34
36
 
35
37
 
36
38
  def _find_preset_lock(root: pathlib.Path = pathlib.Path()) -> Optional[pathlib.Path]:
37
- root = root.resolve()
39
+ root = utils.abspath(root)
38
40
  problem_yaml_path = root / '.preset-lock.yml'
39
41
  if not problem_yaml_path.is_file():
40
42
  return None
@@ -49,7 +51,7 @@ def _get_preset_lock(root: pathlib.Path = pathlib.Path()) -> Optional[PresetLock
49
51
 
50
52
 
51
53
  def _find_nested_preset(root: pathlib.Path) -> Optional[pathlib.Path]:
52
- root = root.resolve()
54
+ root = utils.abspath(root)
53
55
  problem_yaml_path = root / 'preset.rbx.yml'
54
56
  while root != pathlib.PosixPath('/') and not problem_yaml_path.is_file():
55
57
  root = root.parent
@@ -61,7 +63,7 @@ def _find_nested_preset(root: pathlib.Path) -> Optional[pathlib.Path]:
61
63
 
62
64
  def _find_local_preset(root: pathlib.Path) -> Optional[pathlib.Path]:
63
65
  original_root = root
64
- root = root.resolve()
66
+ root = utils.abspath(root)
65
67
  problem_yaml_path = root / '.local.rbx' / 'preset.rbx.yml'
66
68
  while root != pathlib.PosixPath('/') and not problem_yaml_path.is_file():
67
69
  root = root.parent
@@ -75,7 +77,7 @@ def _is_installed_preset(root: pathlib.Path = pathlib.Path()) -> bool:
75
77
  preset_path = _find_local_preset(root)
76
78
  if preset_path is None:
77
79
  return False
78
- resolved_path = preset_path.resolve()
80
+ resolved_path = utils.abspath(preset_path)
79
81
  return resolved_path.name == '.local.rbx'
80
82
 
81
83
 
@@ -103,23 +105,68 @@ def _check_is_valid_package(root: pathlib.Path = pathlib.Path()):
103
105
  raise typer.Exit(1)
104
106
 
105
107
 
108
+ def _glob_while_ignoring(
109
+ dir: pathlib.Path,
110
+ glb: str,
111
+ extra_gitignore: Optional[str] = '.box\nbuild\n',
112
+ recursive: bool = False,
113
+ ) -> Iterable[pathlib.Path]:
114
+ from gitignore_parser import parse_gitignore, parse_gitignore_str
115
+
116
+ ignore_matchers = []
117
+
118
+ if extra_gitignore is not None:
119
+ ignore_matchers.append(parse_gitignore_str(extra_gitignore, base_dir=dir))
120
+
121
+ for file in dir.rglob('.gitignore'):
122
+ if file.is_file():
123
+ ignore_matchers.append(parse_gitignore(file))
124
+
125
+ def should_ignore(path: pathlib.Path) -> bool:
126
+ return any(m(str(path)) for m in ignore_matchers)
127
+
128
+ for file in dir.rglob(glb) if recursive else dir.glob(glb):
129
+ if should_ignore(file):
130
+ continue
131
+ yield file
132
+
133
+
106
134
  def _process_globbing(
107
- assets: Iterable[TrackedAsset], preset_dir: pathlib.Path
135
+ assets: Iterable[TrackedAsset], preset_pkg_dir: pathlib.Path
108
136
  ) -> List[TrackedAsset]:
109
137
  res = []
110
138
  for asset in assets:
111
139
  if '*' in str(asset.path):
112
140
  glb = str(asset.path)
113
- files = preset_dir.glob(glb)
114
- relative_files = [file.relative_to(preset_dir) for file in files]
115
- res.extend([TrackedAsset(path=path) for path in relative_files])
141
+ files = _glob_while_ignoring(
142
+ preset_pkg_dir,
143
+ glb,
144
+ )
145
+ relative_files = [file.relative_to(preset_pkg_dir) for file in files]
146
+ res.extend(
147
+ [
148
+ TrackedAsset(path=path, symlink=asset.symlink)
149
+ for path in relative_files
150
+ ]
151
+ )
116
152
  continue
117
153
  res.append(asset)
118
154
  return res
119
155
 
120
156
 
157
+ def _dedup_tracked_assets(assets: List[TrackedAsset]) -> List[TrackedAsset]:
158
+ seen_paths = set()
159
+ res = []
160
+ for asset in assets:
161
+ if asset.path in seen_paths:
162
+ continue
163
+ seen_paths.add(asset.path)
164
+ res.append(asset)
165
+ return res
166
+
167
+
121
168
  def _get_preset_tracked_assets(
122
- root: pathlib.Path, is_contest: bool
169
+ root: pathlib.Path, is_contest: bool, add_symlinks: bool = False
123
170
  ) -> List[TrackedAsset]:
124
171
  preset = get_active_preset(root)
125
172
  preset_path = _find_local_preset(root)
@@ -129,12 +176,51 @@ def _get_preset_tracked_assets(
129
176
  assert (
130
177
  preset.contest is not None
131
178
  ), 'Preset does not have a contest package definition.'
132
- return _process_globbing(preset.tracking.contest, preset_path)
179
+ preset_pkg_path = preset_path / preset.contest
180
+ res = _process_globbing(preset.tracking.contest, preset_pkg_path)
181
+ else:
182
+ assert (
183
+ preset.problem is not None
184
+ ), 'Preset does not have a problem package definition,'
185
+ preset_pkg_path = preset_path / preset.problem
186
+ res = _process_globbing(preset.tracking.problem, preset_pkg_path)
187
+
188
+ if add_symlinks:
189
+ for file in _glob_while_ignoring(
190
+ preset_pkg_path,
191
+ '*',
192
+ recursive=True,
193
+ ):
194
+ if not file.is_symlink() or not file.is_file():
195
+ continue
196
+ res.append(
197
+ TrackedAsset(path=file.relative_to(preset_pkg_path), symlink=True)
198
+ )
133
199
 
134
- assert (
135
- preset.problem is not None
136
- ), 'Preset does not have a problem package definition,'
137
- return _process_globbing(preset.tracking.problem, preset_path)
200
+ return _dedup_tracked_assets(res)
201
+
202
+
203
+ def _get_tracked_assets_symlinks(
204
+ tracked_assets: List[TrackedAsset],
205
+ ) -> Set[pathlib.Path]:
206
+ res = set()
207
+ for asset in tracked_assets:
208
+ if asset.symlink:
209
+ res.add(asset.path)
210
+ return res
211
+
212
+
213
+ def _get_symlink_info(
214
+ tracked_asset: Union[TrackedAsset, LockedAsset], root: pathlib.Path
215
+ ) -> Optional[SymlinkInfo]:
216
+ asset_path = root / tracked_asset.path
217
+ if not asset_path.is_symlink():
218
+ return None
219
+ target = pathlib.Path(os.readlink(str(asset_path)))
220
+ absolute_target = utils.abspath(asset_path.parent / target)
221
+ is_broken = not absolute_target.exists()
222
+ is_outside = not absolute_target.is_relative_to(utils.abspath(root))
223
+ return SymlinkInfo(target=target, is_broken=is_broken, is_outside=is_outside)
138
224
 
139
225
 
140
226
  def _build_package_locked_assets(
@@ -145,10 +231,21 @@ def _build_package_locked_assets(
145
231
  for tracked_asset in tracked_assets:
146
232
  asset_path = root / tracked_asset.path
147
233
  if not asset_path.is_file():
234
+ res.append(
235
+ LockedAsset(
236
+ path=tracked_asset.path,
237
+ hash=None,
238
+ symlink_info=_get_symlink_info(tracked_asset, root),
239
+ )
240
+ )
148
241
  continue
149
242
  with asset_path.open('rb') as f:
150
243
  res.append(
151
- LockedAsset(path=tracked_asset.path, hash=digest_cooperatively(f))
244
+ LockedAsset(
245
+ path=tracked_asset.path,
246
+ hash=digest_cooperatively(f),
247
+ symlink_info=_get_symlink_info(tracked_asset, root),
248
+ )
152
249
  )
153
250
  return res
154
251
 
@@ -156,14 +253,18 @@ def _build_package_locked_assets(
156
253
  def _find_non_modified_assets(
157
254
  reference: List[LockedAsset], current: List[LockedAsset]
158
255
  ) -> List[LockedAsset]:
159
- current_by_path = {asset.path: asset for asset in current}
256
+ reference_by_path = {asset.path: asset for asset in reference}
160
257
 
161
258
  res = []
162
- for asset in reference:
163
- if (
164
- asset.path in current_by_path
165
- and current_by_path[asset.path].hash != asset.hash
166
- ):
259
+ for asset in current:
260
+ # File does not exist in the reference.
261
+ reference_asset = LockedAsset(path=asset.path, hash=None)
262
+
263
+ # File already exists.
264
+ if asset.path in reference_by_path:
265
+ reference_asset = reference_by_path[asset.path]
266
+
267
+ if asset.was_modified(reference_asset) and not asset.is_broken_symlink():
167
268
  # This is a file that was modified.
168
269
  continue
169
270
  res.append(asset)
@@ -173,46 +274,139 @@ def _find_non_modified_assets(
173
274
  def _find_modified_assets(
174
275
  reference: List[LockedAsset],
175
276
  current: List[LockedAsset],
277
+ seen_symlinks: Set[pathlib.Path],
176
278
  ):
177
- reference_by_path = {asset.path: asset for asset in reference}
279
+ current_by_path = {asset.path: asset for asset in current}
178
280
 
179
281
  res = []
180
- for asset in current:
181
- if (
182
- asset.path in reference_by_path
183
- and reference_by_path[asset.path].hash == asset.hash
184
- ):
185
- # This is a file that was not modified.
282
+ for asset in reference:
283
+ current_asset = LockedAsset(path=asset.path, hash=None)
284
+ if asset.path in current_by_path:
285
+ current_asset = current_by_path[asset.path]
286
+ if current_asset.path in seen_symlinks and not asset.is_symlink():
287
+ # TODO: improve this condition, it's almost always triggering
288
+ # Preset asset should be forced to be a symlink,
289
+ # but in the current package it is not.
290
+ res.append(asset)
186
291
  continue
187
- res.append(asset)
292
+ if current_asset.was_modified(asset, follow_symlinks=True):
293
+ # This is a file that was modified.
294
+ res.append(asset)
188
295
  return res
189
296
 
190
297
 
298
+ def _copy_preset_file(
299
+ src: pathlib.Path,
300
+ dst: pathlib.Path,
301
+ preset_package_path: pathlib.Path,
302
+ preset_path: pathlib.Path,
303
+ force_symlink: bool = False,
304
+ ):
305
+ if dst.is_file() or dst.is_symlink():
306
+ dst.unlink(missing_ok=True)
307
+ if not src.exists():
308
+ return
309
+ dst.parent.mkdir(parents=True, exist_ok=True)
310
+ if not src.is_symlink() and not force_symlink:
311
+ shutil.copyfile(str(src), str(dst))
312
+ return
313
+
314
+ # Ensure preset package path is inside the preset path.
315
+ absolute_preset_package_path = utils.abspath(preset_package_path)
316
+ absolute_preset_path = utils.abspath(preset_path)
317
+ assert absolute_preset_package_path.is_relative_to(absolute_preset_path)
318
+
319
+ # Get the symlink absolute path.
320
+ if src.is_symlink():
321
+ target_relative_path = pathlib.Path(os.readlink(str(src)))
322
+ target_absolute_path = utils.abspath(src.parent / target_relative_path)
323
+
324
+ if target_absolute_path.is_relative_to(absolute_preset_package_path):
325
+ # The symlink points inside the preset package path.
326
+ # Copy the symlink as is.
327
+ dst.symlink_to(target_relative_path)
328
+ return
329
+ else:
330
+ target_absolute_path = utils.abspath(src)
331
+
332
+ if not target_absolute_path.is_relative_to(absolute_preset_path):
333
+ console.console.print(
334
+ f'[error]Preset [item]{preset_path.name}[/item] has a symlink to [item]{target_absolute_path}[/item] which is outside the preset folder.[/error]'
335
+ )
336
+ raise typer.Exit(1)
337
+
338
+ # The symlink points somewhere inside the preset folder, fix the symlink.
339
+ dst_absolute_path = utils.abspath(dst)
340
+ fixed_target_relative_path = target_absolute_path.relative_to(
341
+ dst_absolute_path.parent,
342
+ walk_up=True,
343
+ )
344
+ dst.symlink_to(fixed_target_relative_path)
345
+
346
+
191
347
  def _copy_updated_assets(
192
348
  preset_lock: PresetLock,
193
349
  is_contest: bool,
194
350
  root: pathlib.Path = pathlib.Path(),
351
+ force: bool = False,
352
+ symlinks: bool = False,
195
353
  ):
196
- current_package_snapshot = _build_package_locked_assets(preset_lock.assets)
197
- non_modified_assets = _find_non_modified_assets(
198
- preset_lock.assets, current_package_snapshot
199
- )
200
-
354
+ # Build preset package snapshot.
201
355
  preset = get_active_preset(root)
356
+ preset_path = get_active_preset_path(root)
202
357
  preset_package_path = _get_active_preset_package_path(root, is_contest)
203
358
 
204
359
  preset_tracked_assets = _get_preset_tracked_assets(
205
- preset_package_path, is_contest=is_contest
360
+ preset_package_path, is_contest=is_contest, add_symlinks=symlinks
206
361
  )
207
362
  current_preset_snapshot = _build_package_locked_assets(
208
363
  preset_tracked_assets, preset_package_path
209
364
  )
210
- assets_to_copy = _find_modified_assets(non_modified_assets, current_preset_snapshot)
365
+
366
+ # Build current package snapshot based on the current preset snapshot.
367
+ current_package_snapshot = _build_package_locked_assets(current_preset_snapshot)
368
+
369
+ non_modified_assets = current_package_snapshot
370
+ if not force:
371
+ non_modified_assets = _find_non_modified_assets(
372
+ preset_lock.assets, current_package_snapshot
373
+ )
374
+
375
+ console.console.print('Tracking the following assets from preset:')
376
+ for asset in current_preset_snapshot:
377
+ console.console.print(f' - [item]{asset}[/item]')
378
+ console.console.print()
379
+
380
+ console.console.print('Current package snapshot:')
381
+ for asset in current_package_snapshot:
382
+ console.console.print(f' - [item]{asset}[/item]')
383
+ console.console.print()
384
+
385
+ seen_symlinks = _get_tracked_assets_symlinks(preset_tracked_assets)
386
+
387
+ assets_to_copy = _find_modified_assets(
388
+ non_modified_assets, current_preset_snapshot, seen_symlinks
389
+ )
390
+
391
+ # console.console.log(current_package_snapshot)
392
+ # console.console.log(current_preset_snapshot)
393
+
394
+ if not assets_to_copy:
395
+ console.console.print('[warning]No assets to update.[/warning]')
396
+ return
397
+
398
+ # console.console.log(assets_to_copy)
211
399
 
212
400
  for asset in assets_to_copy:
213
401
  src_path = preset_package_path / asset.path
214
402
  dst_path = root / asset.path
215
- shutil.copyfile(str(src_path), str(dst_path))
403
+ _copy_preset_file(
404
+ src_path,
405
+ dst_path,
406
+ preset_package_path,
407
+ preset_path,
408
+ force_symlink=asset.path in seen_symlinks,
409
+ )
216
410
  console.console.print(
217
411
  f'Updated [item]{asset.path}[/item] from preset [item]{preset.name}[/item].'
218
412
  )
@@ -400,7 +594,7 @@ def _install_preset_from_local_dir(
400
594
  dest,
401
595
  ensure_contest,
402
596
  ensure_problem,
403
- override_uri=str(pd.resolve()),
597
+ override_uri=str(utils.abspath(pd)),
404
598
  update=update,
405
599
  )
406
600
 
@@ -426,7 +620,7 @@ def _install_preset_from_resources(
426
620
  dest,
427
621
  ensure_contest,
428
622
  ensure_problem,
429
- override_uri=str(rsrc_preset_path.resolve()),
623
+ override_uri=str(utils.abspath(rsrc_preset_path)),
430
624
  update=update,
431
625
  )
432
626
  return True
@@ -475,6 +669,45 @@ def install_preset_at_package(fetch_info: PresetFetchInfo, dest_pkg: pathlib.Pat
475
669
  _install_preset_from_fetch_info(fetch_info, dest_pkg / '.local.rbx')
476
670
 
477
671
 
672
+ def _install_package_from_preset(
673
+ preset_path: pathlib.Path,
674
+ preset_package_inner_path: pathlib.Path,
675
+ dest_pkg: pathlib.Path,
676
+ tracked_assets: List[TrackedAsset],
677
+ ):
678
+ preset_package_path = preset_path / preset_package_inner_path
679
+ if not preset_package_path.is_dir():
680
+ console.console.print(
681
+ f'[error]Preset [item]{preset_path.name}[/item] does not have a [item]{preset_package_inner_path}[/item] package definition.[/error]'
682
+ )
683
+ raise typer.Exit(1)
684
+
685
+ for file in _glob_while_ignoring(
686
+ preset_package_path,
687
+ '*',
688
+ recursive=True,
689
+ ):
690
+ if not file.is_file():
691
+ continue
692
+ _copy_preset_file(
693
+ file,
694
+ dest_pkg / file.relative_to(preset_package_path),
695
+ preset_package_path,
696
+ preset_path,
697
+ )
698
+
699
+ for asset in tracked_assets:
700
+ if not asset.symlink:
701
+ continue
702
+ _copy_preset_file(
703
+ preset_package_path / asset.path,
704
+ dest_pkg / asset.path,
705
+ preset_package_path,
706
+ preset_path,
707
+ force_symlink=True,
708
+ )
709
+
710
+
478
711
  def install_contest(
479
712
  dest_pkg: pathlib.Path, fetch_info: Optional[PresetFetchInfo] = None
480
713
  ):
@@ -496,10 +729,8 @@ def install_contest(
496
729
  console.console.print(
497
730
  f'Installing contest from [item]{preset_path / preset.contest}[/item] to [item]{dest_pkg}[/item]...'
498
731
  )
499
- shutil.copytree(
500
- str(preset_path / preset.contest),
501
- str(dest_pkg),
502
- dirs_exist_ok=True,
732
+ _install_package_from_preset(
733
+ preset_path, preset.contest, dest_pkg, preset.tracking.contest
503
734
  )
504
735
  _clean_copied_contest_dir(dest_pkg, delete_local_rbx=False)
505
736
 
@@ -525,14 +756,36 @@ def install_problem(
525
756
  console.console.print(
526
757
  f'Installing problem from [item]{preset_path / preset.problem}[/item] to [item]{dest_pkg}[/item]...'
527
758
  )
528
- shutil.copytree(
529
- str(preset_path / preset.problem),
530
- str(dest_pkg),
531
- dirs_exist_ok=True,
759
+ _install_package_from_preset(
760
+ preset_path, preset.problem, dest_pkg, preset.tracking.problem
532
761
  )
533
762
  _clean_copied_problem_dir(dest_pkg)
534
763
 
535
764
 
765
+ def install_preset(
766
+ dest_pkg: pathlib.Path, fetch_info: Optional[PresetFetchInfo] = None
767
+ ):
768
+ if fetch_info is None and get_active_preset_or_null() is None:
769
+ console.console.print(
770
+ '[error]No preset found to initialize the new preset from.[/error]'
771
+ )
772
+ raise typer.Exit(1)
773
+ if fetch_info is None:
774
+ _install_preset_from_dir(get_active_preset_path(), dest_pkg)
775
+ else:
776
+ _install_preset_from_fetch_info(fetch_info, dest_pkg)
777
+
778
+
779
+ def get_ruyaml(root: pathlib.Path = pathlib.Path()) -> Tuple[ruyaml.YAML, ruyaml.Any]:
780
+ if not (root / 'preset.rbx.yml').is_file():
781
+ console.console.print(
782
+ f'[error]Preset at [item]{root}[/item] does not have a [item]preset.rbx.yml[/item] file.[/error]'
783
+ )
784
+ raise typer.Exit(1)
785
+ res = ruyaml.YAML()
786
+ return res, res.load(root / 'preset.rbx.yml')
787
+
788
+
536
789
  def generate_lock(root: pathlib.Path = pathlib.Path()):
537
790
  preset = get_active_preset(root)
538
791
 
@@ -548,7 +801,7 @@ def generate_lock(root: pathlib.Path = pathlib.Path()):
548
801
  )
549
802
 
550
803
 
551
- def _sync(try_update: bool = False):
804
+ def _sync(try_update: bool = False, force: bool = False, symlinks: bool = False):
552
805
  preset_lock = _get_preset_lock()
553
806
  if preset_lock is None:
554
807
  console.console.print(
@@ -565,6 +818,8 @@ def _sync(try_update: bool = False):
565
818
  _copy_updated_assets(
566
819
  preset_lock,
567
820
  is_contest=_is_contest(),
821
+ force=force,
822
+ symlinks=symlinks,
568
823
  )
569
824
  generate_lock()
570
825
 
@@ -621,7 +876,9 @@ def copy_local_preset(
621
876
  if not add_submodule:
622
877
  return
623
878
 
624
- dest_path_rel = dest_path.resolve().relative_to(pathlib.Path.cwd().resolve())
879
+ dest_path_rel = utils.abspath(dest_path).relative_to(
880
+ utils.abspath(pathlib.Path.cwd())
881
+ )
625
882
  path_str = str(dest_path_rel / '.local.rbx')
626
883
  try:
627
884
  current_repo.git.submodule('add', preset_remote_uri, path_str)
@@ -637,6 +894,47 @@ def copy_local_preset(
637
894
  )
638
895
 
639
896
 
897
+ @app.command('create', help='Create a new preset.')
898
+ def create(
899
+ name: Annotated[
900
+ str,
901
+ typer.Option(
902
+ help='The name of the preset to create. This will also be the name of the folder.',
903
+ prompt='What is the name of your new preset?',
904
+ ),
905
+ ],
906
+ uri: Annotated[
907
+ str,
908
+ typer.Option(
909
+ help='The URI of the new preset.',
910
+ prompt='What is the URI of your new preset? (ex: rsalesc/rbx-preset for a GitHub repository)',
911
+ ),
912
+ ],
913
+ from_preset: Annotated[
914
+ Optional[str],
915
+ typer.Option(
916
+ '--preset', '-p', help='The URI of the preset to init the new preset from.'
917
+ ),
918
+ ] = None,
919
+ ):
920
+ console.console.print(f'Creating new preset [item]{name}[/item]...')
921
+
922
+ fetch_info = get_preset_fetch_info_with_fallback(from_preset)
923
+ dest_path = pathlib.Path(name)
924
+ if dest_path.exists():
925
+ console.console.print(
926
+ f'[error]Directory [item]{dest_path}[/item] already exists.[/error]'
927
+ )
928
+ raise typer.Exit(1)
929
+
930
+ install_preset(dest_path, fetch_info)
931
+
932
+ ru, preset = get_ruyaml(dest_path)
933
+ preset['name'] = name
934
+ preset['uri'] = uri
935
+ utils.save_ruyaml(dest_path / 'preset.rbx.yml', ru, preset)
936
+
937
+
640
938
  @app.command('update', help='Update preset of current package')
641
939
  def update():
642
940
  preset = get_active_preset()
@@ -689,13 +987,31 @@ def sync(
689
987
  help='Whether to fetch an up-to-date version of the installed preset from remote, if available.',
690
988
  ),
691
989
  ] = False,
990
+ force: Annotated[
991
+ bool,
992
+ typer.Option(
993
+ '--force',
994
+ '-f',
995
+ help='Whether to forcefully overwrite the local assets with the preset assets, even if they have been modified.',
996
+ ),
997
+ ] = False,
998
+ symlinks: Annotated[
999
+ bool,
1000
+ typer.Option(
1001
+ '--symlinks',
1002
+ '-s',
1003
+ help='Whether to update all symlinks in the preset to point to their right targets.',
1004
+ ),
1005
+ ] = False,
692
1006
  ):
693
1007
  _check_is_valid_package()
694
- _sync(try_update=update)
1008
+ _sync(try_update=update, force=force, symlinks=symlinks)
695
1009
 
696
1010
 
697
1011
  @app.command(
698
- 'lock', help='Generate a lock for this package, based on a existing preset.'
1012
+ 'lock',
1013
+ help='Generate a lock for this package, based on a existing preset.',
1014
+ hidden=True,
699
1015
  )
700
1016
  @cd.within_closest_package
701
1017
  def lock():
@@ -1,12 +1,52 @@
1
- from typing import List
1
+ import pathlib
2
+ from typing import List, Optional
2
3
 
3
4
  from pydantic import BaseModel
4
5
 
5
6
  from rbx.box.presets.schema import TrackedAsset
6
7
 
7
8
 
9
+ class SymlinkInfo(BaseModel):
10
+ target: pathlib.Path
11
+ is_broken: bool
12
+ is_outside: bool
13
+
14
+
8
15
  class LockedAsset(TrackedAsset):
9
- hash: str
16
+ hash: Optional[str] = None
17
+ symlink_info: Optional[SymlinkInfo] = None
18
+
19
+ def is_symlink(self) -> bool:
20
+ return self.symlink_info is not None or self.symlink
21
+
22
+ def is_broken_symlink(self) -> bool:
23
+ return self.symlink_info is not None and self.symlink_info.is_broken
24
+
25
+ def was_modified(self, base: 'LockedAsset', follow_symlinks: bool = False) -> bool:
26
+ if self.is_symlink() != base.is_symlink():
27
+ return True
28
+ if self.hash != base.hash and (follow_symlinks or self.symlink_info is None):
29
+ return True
30
+ if self.symlink_info is not None and self.symlink_info.is_broken:
31
+ return True
32
+ if (
33
+ self.symlink_info is not None
34
+ and base.symlink_info is not None
35
+ and self.symlink_info.target != base.symlink_info.target
36
+ and not follow_symlinks
37
+ ):
38
+ return True
39
+ return False
40
+
41
+ def __str__(self) -> str:
42
+ if self.symlink_info is not None:
43
+ res = f'{self.path} -> {self.symlink_info.target}'
44
+ if self.symlink_info.is_broken:
45
+ res += ' (broken)'
46
+ if self.symlink_info.is_outside:
47
+ res += ' (outside)'
48
+ return res
49
+ return f'{self.path} ({self.hash})'
10
50
 
11
51
 
12
52
  class PresetLock(BaseModel):
rbx/box/presets/schema.py CHANGED
@@ -19,6 +19,10 @@ class TrackedAsset(BaseModel):
19
19
  # be tracked. Can also be a glob, when specified in the preset config.
20
20
  path: pathlib.Path
21
21
 
22
+ # Whether the asset should be symlinked to the local preset directory,
23
+ # instead of being copied.
24
+ symlink: bool = False
25
+
22
26
 
23
27
  class Tracking(BaseModel):
24
28
  # Problem assets that should be tracked and updated by rbx