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
@@ -7,8 +7,11 @@ import pathlib
7
7
  import shutil
8
8
  import tempfile
9
9
  import typing
10
- from typing import IO, List, Optional
10
+ from typing import IO, Dict, List, Optional, Type
11
11
 
12
+ from pydantic import BaseModel
13
+
14
+ from rbx.grading import grading_context
12
15
  from rbx.grading.judge import digester, storage
13
16
 
14
17
  logger = logging.getLogger(__name__)
@@ -60,6 +63,7 @@ class FileCacher:
60
63
  self.backend = backend
61
64
  self.shared = shared
62
65
  self.folder = folder
66
+ self.existing = set()
63
67
 
64
68
  # First we create the config directories.
65
69
  if folder:
@@ -146,6 +150,11 @@ class FileCacher:
146
150
 
147
151
  logger.debug('File %s not in cache, downloading ' 'from database.', digest)
148
152
 
153
+ if (symlink := self.backend.path_for_symlink(digest)) is not None:
154
+ cache_file_path.unlink(missing_ok=True)
155
+ cache_file_path.symlink_to(symlink)
156
+ return cache_file_path.open('rb') if not cache_only else None
157
+
149
158
  ftmp_handle, temp_file_path = tempfile.mkstemp(dir=self.temp_dir, text=False)
150
159
  temp_file_path = pathlib.Path(temp_file_path)
151
160
  with open(ftmp_handle, 'wb') as ftmp, self.backend.get_file(digest) as fobj:
@@ -168,6 +177,22 @@ class FileCacher:
168
177
  if not cache_only:
169
178
  return fd
170
179
 
180
+ def exists(self, digest: str, cache_only: bool = False) -> bool:
181
+ """Check if a file exists in the cacher.
182
+
183
+ cache_only (bool): don't check the backend.
184
+
185
+ """
186
+ cache_file_path = self.file_dir / digest
187
+ if cache_file_path.exists() or digest in self.existing:
188
+ return True
189
+ if cache_only:
190
+ return False
191
+ exists = self.backend.exists(digest)
192
+ if exists:
193
+ self.existing.add(digest)
194
+ return exists
195
+
171
196
  def cache_file(self, digest: str):
172
197
  """Load a file into the cache.
173
198
 
@@ -219,9 +244,18 @@ class FileCacher:
219
244
  if digest == storage.TOMBSTONE:
220
245
  raise TombstoneError()
221
246
 
247
+ if grading_context.is_transient():
248
+ return None
249
+
222
250
  logger.debug('Getting symlink file path %s.', digest)
223
251
  return self.backend.path_for_symlink(digest)
224
252
 
253
+ def digest_from_symlink(self, link: pathlib.Path) -> Optional[str]:
254
+ if grading_context.is_transient():
255
+ return None
256
+
257
+ return self.backend.filename_from_symlink(link)
258
+
225
259
  def get_file_content(self, digest: str) -> bytes:
226
260
  """Retrieve a file from the storage.
227
261
 
@@ -280,7 +314,9 @@ class FileCacher:
280
314
  with dst_path.open('wb') as dst:
281
315
  storage.copyfileobj(src, dst, self.CHUNK_SIZE)
282
316
 
283
- def put_file_from_fobj(self, src: IO[bytes], desc: str = '') -> str:
317
+ def put_file_from_fobj(
318
+ self, src: IO[bytes], metadata: Optional[Dict[str, BaseModel]] = None
319
+ ) -> str:
284
320
  """Store a file in the storage.
285
321
 
286
322
  If it's already (for some reason...) in the cache send that
@@ -292,7 +328,7 @@ class FileCacher:
292
328
 
293
329
  src (fileobj): a readable binary file-like object from which
294
330
  to read the contents of the file.
295
- desc (unicode): the (optional) description to associate to the
331
+ metadata (Dict[str, BaseModel]): the (optional) metadata to associate to the
296
332
  file.
297
333
 
298
334
  return (unicode): the digest of the stored file.
@@ -334,36 +370,45 @@ class FileCacher:
334
370
  # We read from the temporary file before moving it to
335
371
  # cache_file_path because the latter might be deleted before
336
372
  # we get a chance to open it.
337
- with open(dst.name, 'rb') as src:
338
- pending_file = self.backend.create_file(digest)
339
- if pending_file is not None:
340
- storage.copyfileobj(src, pending_file.fd, self.CHUNK_SIZE)
341
- self.backend.commit_file(pending_file, desc)
373
+ #
374
+ # Only store file when not in transient mode.
375
+ if not grading_context.is_transient():
376
+ with open(dst.name, 'rb') as src:
377
+ pending_file = self.backend.create_file(digest)
378
+ if pending_file is not None:
379
+ storage.copyfileobj(src, pending_file.fd, self.CHUNK_SIZE)
380
+ self.backend.commit_file(pending_file, metadata)
342
381
 
343
382
  os.rename(dst.name, cache_file_path)
344
383
 
345
384
  return digest
346
385
 
347
- def put_file_content(self, content: bytes, desc: str = '') -> str:
386
+ def put_file_content(
387
+ self, content: bytes, metadata: Optional[Dict[str, BaseModel]] = None
388
+ ) -> str:
348
389
  """Store a file in the storage.
349
390
 
350
391
  See `put_file_from_fobj'. This method will read the content of
351
392
  the file from the given binary string.
352
393
 
353
394
  content (bytes): the content of the file to store.
354
- desc (unicode): the (optional) description to associate to the
395
+ metadata (Dict[str, BaseModel]): the (optional) metadata to associate to the
355
396
  file.
356
397
 
357
398
  return (unicode): the digest of the stored file.
358
399
 
359
400
  """
360
401
  with io.BytesIO(content) as src:
361
- return self.put_file_from_fobj(src, desc)
402
+ return self.put_file_from_fobj(src, metadata)
362
403
 
363
- def put_file_text(self, text: str, desc: str = '') -> str:
364
- return self.put_file_content(text.encode('utf-8'), desc)
404
+ def put_file_text(
405
+ self, text: str, metadata: Optional[Dict[str, BaseModel]] = None
406
+ ) -> str:
407
+ return self.put_file_content(text.encode('utf-8'), metadata)
365
408
 
366
- def put_file_from_path(self, src_path: pathlib.Path, desc: str = '') -> str:
409
+ def put_file_from_path(
410
+ self, src_path: pathlib.Path, metadata: Optional[Dict[str, BaseModel]] = None
411
+ ) -> str:
367
412
  """Store a file in the storage.
368
413
 
369
414
  See `put_file_from_fobj'. This method will read the content of
@@ -371,28 +416,53 @@ class FileCacher:
371
416
 
372
417
  src_path (Path): an accessible location on the file-system
373
418
  from which to read the contents of the file.
374
- desc (unicode): the (optional) description to associate to the
419
+ metadata (Dict[str, BaseModel]): the (optional) metadata to associate to the
375
420
  file.
376
421
 
377
422
  return (unicode): the digest of the stored file.
378
423
 
379
424
  """
380
425
  with src_path.open('rb') as src:
381
- return self.put_file_from_fobj(src, desc)
426
+ return self.put_file_from_fobj(src, metadata)
427
+
428
+ def set_metadata(self, digest: str, key: str, value: Optional[BaseModel]):
429
+ """Set the description of a file given its digest.
430
+
431
+ digest (unicode): the digest of the file to add the description.
432
+ key (str): the key of the metadata to add.
433
+ value (BaseModel): the value of the metadata to add.
434
+ """
435
+ if grading_context.is_transient():
436
+ return
437
+ self.backend.set_metadata(digest, key, value)
382
438
 
383
- def describe(self, digest: str) -> str:
439
+ def get_metadata(
440
+ self, digest: str, key: str, model_cls: Type[storage.BaseModelT]
441
+ ) -> Optional[storage.BaseModelT]:
384
442
  """Return the description of a file given its digest.
385
443
 
386
444
  digest (unicode): the digest of the file to describe.
387
-
388
- return (unicode): the description of the file.
445
+ key (str): the key of the metadata to get.
446
+ model_cls (Type[storage.BaseModelT]): the model class of the metadata.
447
+ return (BaseModel): the metadata of the file.
389
448
 
390
449
  raise (KeyError): if the file cannot be found.
391
450
 
392
451
  """
393
452
  if digest == storage.TOMBSTONE:
394
453
  raise TombstoneError()
395
- return self.backend.describe(digest)
454
+ return typing.cast(
455
+ Optional[storage.BaseModelT],
456
+ self.backend.get_metadata(digest, key, model_cls),
457
+ )
458
+
459
+ def list_metadata(self, filename: str) -> List[str]:
460
+ """List the metadata of a file given its filename.
461
+
462
+ filename (str): the filename of the file to list the metadata.
463
+ return (List[str]): the list of metadata keys.
464
+ """
465
+ return self.backend.list_metadata(filename)
396
466
 
397
467
  def get_size(self, digest: str) -> int:
398
468
  """Return the size of a file given its digest.
@@ -431,6 +501,7 @@ class FileCacher:
431
501
  return
432
502
  cache_file_path: pathlib.Path = self.file_dir / digest
433
503
  cache_file_path.unlink(missing_ok=True)
504
+ self.existing.discard(digest)
434
505
 
435
506
  def purge_cache(self):
436
507
  """Empty the local cache.
@@ -442,6 +513,7 @@ class FileCacher:
442
513
  self.file_dir.mkdir(parents=True, exist_ok=True)
443
514
  if self.folder is not None:
444
515
  self.folder.mkdir(parents=True, exist_ok=True)
516
+ self.existing.clear()
445
517
 
446
518
  def destroy_cache(self):
447
519
  """Completely remove and destroy the cache.
@@ -456,7 +528,7 @@ class FileCacher:
456
528
  raise Exception('You may not destroy a shared cache.')
457
529
  shutil.rmtree(str(self.file_dir))
458
530
 
459
- def list(self) -> List[storage.FileWithDescription]:
531
+ def list(self) -> List[storage.FileWithMetadata]:
460
532
  """List the files available in the storage.
461
533
 
462
534
  return ([(unicode, unicode)]): a list of pairs, each
@@ -16,6 +16,7 @@ from typing import IO, Any, Dict, List, Optional
16
16
 
17
17
  import pydantic
18
18
 
19
+ from rbx import utils
19
20
  from rbx.grading.judge import cacher, storage
20
21
 
21
22
  logger = logging.getLogger(__name__)
@@ -468,7 +469,7 @@ class SandboxBase(abc.ABC):
468
469
  if override:
469
470
  real_path.unlink(missing_ok=True)
470
471
  try:
471
- real_path.symlink_to(from_path.resolve())
472
+ real_path.symlink_to(utils.abspath(from_path))
472
473
  except NotImplementedError:
473
474
  return None
474
475
  return real_path
@@ -647,12 +648,15 @@ class SandboxBase(abc.ABC):
647
648
  return self.get_file_to_bytes(path, maxlen).decode('utf-8')
648
649
 
649
650
  def get_file_to_storage(
650
- self, path: pathlib.Path, description: str = '', trunc_len: Optional[int] = None
651
+ self,
652
+ path: pathlib.Path,
653
+ metadata: Optional[Dict[str, pydantic.BaseModel]] = None,
654
+ trunc_len: Optional[int] = None,
651
655
  ) -> str:
652
656
  """Put a sandbox file in FS and return its digest.
653
657
 
654
658
  path (Path): relative path of the file inside the sandbox.
655
- description (str): the description for FS.
659
+ metadata (Dict[str, pydantic.BaseModel]): the metadata for FS.
656
660
  trunc_len (int|None): if None, does nothing; otherwise, before
657
661
  returning truncate it at the specified length.
658
662
 
@@ -660,7 +664,7 @@ class SandboxBase(abc.ABC):
660
664
 
661
665
  """
662
666
  with self.get_file(path, trunc_len=trunc_len) as file_:
663
- return self.file_cacher.put_file_from_fobj(file_, description)
667
+ return self.file_cacher.put_file_from_fobj(file_, metadata)
664
668
 
665
669
  def stat_file(self, path: pathlib.Path) -> os.stat_result:
666
670
  """Return the stats of a file in the sandbox.
@@ -9,6 +9,7 @@ import subprocess
9
9
  import tempfile
10
10
  from typing import IO, Any, Dict, List, Optional
11
11
 
12
+ from rbx import utils
12
13
  from rbx.config import get_app_path
13
14
  from rbx.grading.judge.cacher import FileCacher
14
15
  from rbx.grading.judge.sandbox import (
@@ -180,10 +181,10 @@ class IsolateSandbox(SandboxBase):
180
181
  """
181
182
  outer_paths: List[pathlib.Path] = []
182
183
  for inner_path in inner_paths:
183
- abs_inner_path = (self._home_dest / inner_path).resolve()
184
+ abs_inner_path = utils.abspath(self._home_dest / inner_path)
184
185
  # If an inner path is absolute (e.g., /fifo0/u0_to_m) then
185
186
  # it may be outside home and we should ignore it.
186
- if not abs_inner_path.is_relative_to(self._home_dest.resolve()):
187
+ if not abs_inner_path.is_relative_to(utils.abspath(self._home_dest)):
187
188
  continue
188
189
  rel_inner_path = abs_inner_path.relative_to(self._home_dest)
189
190
  outer_path = self._home / rel_inner_path
@@ -11,6 +11,7 @@ import sys
11
11
  import tempfile
12
12
  from typing import Any, Dict, List, Optional
13
13
 
14
+ from rbx import utils
14
15
  from rbx.grading.judge.cacher import FileCacher
15
16
  from rbx.grading.judge.sandbox import (
16
17
  SandboxBase,
@@ -303,8 +304,8 @@ class StupidSandbox(SandboxBase):
303
304
  real_command = (
304
305
  [
305
306
  sys.executable,
306
- str(self.get_timeit_executable().resolve()),
307
- str(self.relative_path(self.get_current_log_name()).resolve()),
307
+ str(utils.abspath(self.get_timeit_executable())),
308
+ str(utils.abspath(self.relative_path(self.get_current_log_name()))),
308
309
  ]
309
310
  + self.get_timeit_args()
310
311
  + command
@@ -100,9 +100,9 @@ def create_tee(files, mode, buffer_size=4096, prefix=''):
100
100
  tee.file.write(tee.prefix)
101
101
  tee.file.write(bytes)
102
102
  tee.file.flush()
103
- new = bytes == b'\n'
104
103
  # TODO maybe add in fsync() here if the fileno() method
105
104
  # exists on file
105
+ new = bytes == b'\n'
106
106
  except Exception:
107
107
  pass
108
108
  finally:
@@ -3,13 +3,22 @@ import io
3
3
  import logging
4
4
  import pathlib
5
5
  import tempfile
6
+ import typing
6
7
  from abc import ABC, abstractmethod
7
- from typing import IO, AnyStr, List, Optional
8
+ from typing import IO, AnyStr, Dict, List, Optional, Type, TypeVar
9
+
10
+ import lz4.frame
11
+ from pydantic import BaseModel
12
+
13
+ from rbx import utils
14
+ from rbx.grading import grading_context
8
15
 
9
16
  logger = logging.getLogger(__name__)
10
17
 
11
18
  TOMBSTONE = 'x'
12
19
 
20
+ BaseModelT = TypeVar('BaseModelT', bound=BaseModel)
21
+
13
22
 
14
23
  def copyfileobj(
15
24
  source_fobj: IO[AnyStr],
@@ -43,16 +52,24 @@ def copyfileobj(
43
52
  maxlen -= written
44
53
 
45
54
 
55
+ COMPRESSION_LEVEL = 5
56
+
57
+
58
+ class CompressionMetadata(BaseModel):
59
+ compression_level: int
60
+
61
+
46
62
  @dataclasses.dataclass
47
63
  class PendingFile:
48
64
  fd: IO[bytes]
49
65
  filename: str
66
+ metadata: Dict[str, Optional[BaseModel]] = dataclasses.field(default_factory=dict)
50
67
 
51
68
 
52
69
  @dataclasses.dataclass
53
- class FileWithDescription:
70
+ class FileWithMetadata:
54
71
  filename: str
55
- description: str
72
+ metadata: List[str]
56
73
 
57
74
 
58
75
  class Storage(ABC):
@@ -81,13 +98,15 @@ class Storage(ABC):
81
98
  pass
82
99
 
83
100
  @abstractmethod
84
- def commit_file(self, file: PendingFile, desc: str = '') -> bool:
101
+ def commit_file(
102
+ self, file: PendingFile, metadata: Optional[Dict[str, BaseModel]] = None
103
+ ) -> bool:
85
104
  """Commit a file created by create_file() to be stored.
86
105
  Given a file object returned by create_file(), this function populates
87
106
  the database to record that this file now legitimately exists and can
88
107
  be used.
89
- fobj (fileobj): the object returned by create_file()
90
108
  file (PendingFile): the file to commit.
109
+ metadata (Dict[str, BaseModel]): the metadata of the file.
91
110
  return (bool): True if the file was committed successfully, False if
92
111
  there was already a file with the same filename in the database. This
93
112
  shouldn't make any difference to the caller, except for testing
@@ -96,19 +115,40 @@ class Storage(ABC):
96
115
  pass
97
116
 
98
117
  @abstractmethod
99
- def exists(self, filename: str) -> bool:
100
- """Check if a file exists in the storage."""
118
+ def set_metadata(self, filename: str, key: str, value: Optional[BaseModel]):
119
+ """Set the metadata of a file given its filename.
120
+ filename (unicode): the filename of the file to set the metadata.
121
+ key (unicode): the key of the metadata to set.
122
+ value (BaseModel): the value of the metadata to set.
123
+ """
101
124
  pass
102
125
 
103
126
  @abstractmethod
104
- def describe(self, filename: str) -> str:
105
- """Return the description of a file given its filename.
106
- filename (unicode): the filename of the file to describe.
107
- return (unicode): the description of the file.
127
+ def get_metadata(
128
+ self, filename: str, key: str, model_cls: Type[BaseModel]
129
+ ) -> Optional[BaseModel]:
130
+ """Get the metadata of a file given its filename and key.
131
+ filename (unicode): the filename of the file to get the metadata.
132
+ key (unicode): the key of the metadata to get.
133
+ model_cls (Type[BaseModel]): the model class of the metadata.
134
+ return (BaseModel): the value of the metadata.
108
135
  raise (KeyError): if the file cannot be found.
109
136
  """
110
137
  pass
111
138
 
139
+ @abstractmethod
140
+ def list_metadata(self, filename: str) -> List[str]:
141
+ """List the metadata of a file given its filename.
142
+ filename (unicode): the filename of the file to list the metadata.
143
+ return (List[str]): the list of metadata keys.
144
+ """
145
+ pass
146
+
147
+ @abstractmethod
148
+ def exists(self, filename: str) -> bool:
149
+ """Check if a file exists in the storage."""
150
+ pass
151
+
112
152
  @abstractmethod
113
153
  def get_size(self, filename: str) -> int:
114
154
  """Return the size of a file given its filename.
@@ -127,7 +167,7 @@ class Storage(ABC):
127
167
  pass
128
168
 
129
169
  @abstractmethod
130
- def list(self) -> List[FileWithDescription]:
170
+ def list(self) -> List[FileWithMetadata]:
131
171
  """List the files available in the storage.
132
172
  return ([(unicode, unicode)]): a list of pairs, each
133
173
  representing a file in the form (filename, description).
@@ -138,6 +178,10 @@ class Storage(ABC):
138
178
  def path_for_symlink(self, filename: str) -> Optional[pathlib.Path]:
139
179
  pass
140
180
 
181
+ @abstractmethod
182
+ def filename_from_symlink(self, link: pathlib.Path) -> Optional[str]:
183
+ pass
184
+
141
185
 
142
186
  class NullStorage(Storage):
143
187
  """This backend is always empty, it just drops each file that
@@ -153,41 +197,54 @@ class NullStorage(Storage):
153
197
  def create_file(self, digest: str) -> Optional[PendingFile]:
154
198
  return None
155
199
 
156
- def commit_file(self, file: PendingFile, desc: str = '') -> bool:
200
+ def commit_file(
201
+ self, file: PendingFile, metadata: Optional[Dict[str, BaseModel]] = None
202
+ ) -> bool:
157
203
  return False
158
204
 
159
- def exists(self, filename: str) -> bool:
160
- return False
205
+ def set_metadata(self, filename: str, key: str, value: Optional[BaseModel]):
206
+ pass
161
207
 
162
- def describe(self, digest: str) -> str:
208
+ def get_metadata(
209
+ self, filename: str, key: str, model_cls: Type[BaseModel]
210
+ ) -> Optional[BaseModel]:
163
211
  raise KeyError('File not found.')
164
212
 
213
+ def list_metadata(self, filename: str) -> List[str]:
214
+ return []
215
+
216
+ def exists(self, filename: str) -> bool:
217
+ return False
218
+
165
219
  def get_size(self, digest: str) -> int:
166
220
  raise KeyError('File not found.')
167
221
 
168
222
  def delete(self, digest: str):
169
223
  pass
170
224
 
171
- def list(self) -> List[FileWithDescription]:
225
+ def list(self) -> List[FileWithMetadata]:
172
226
  return list()
173
227
 
174
228
  def path_for_symlink(self, digest: str) -> Optional[pathlib.Path]:
175
229
  return None
176
230
 
231
+ def filename_from_symlink(self, link: pathlib.Path) -> Optional[str]:
232
+ return None
233
+
177
234
 
178
235
  class FilesystemStorage(Storage):
179
236
  """This class implements a backend for FileCacher that keeps all
180
237
  the files in a file system directory, named after their filename.
181
238
  """
182
239
 
183
- def __init__(self, path: pathlib.Path):
240
+ def __init__(self, path: pathlib.Path, compress: bool = False):
184
241
  """Initialize the backend.
185
242
  path (string): the base path for the storage.
186
243
  """
187
244
  self.path = path
188
-
245
+ self.compress = compress
189
246
  # Create the directory if it doesn't exist
190
- path.mkdir(parents=True, exist_ok=True)
247
+ (path / '.metadata').mkdir(parents=True, exist_ok=True)
191
248
 
192
249
  def get_file(self, filename: str) -> IO[bytes]:
193
250
  """See FileCacherBackend.get_file()."""
@@ -196,6 +253,18 @@ class FilesystemStorage(Storage):
196
253
  if not file_path.is_file():
197
254
  raise KeyError('File not found.')
198
255
 
256
+ compression_metadata = self.get_metadata(
257
+ filename, 'compression', CompressionMetadata
258
+ )
259
+ if compression_metadata is not None:
260
+ return typing.cast(
261
+ IO[bytes],
262
+ lz4.frame.open(
263
+ file_path,
264
+ mode='rb',
265
+ compression_level=compression_metadata.compression_level,
266
+ ),
267
+ )
199
268
  return file_path.open('rb')
200
269
 
201
270
  def create_file(self, filename: str) -> Optional[PendingFile]:
@@ -211,13 +280,39 @@ class FilesystemStorage(Storage):
211
280
  temp_file = tempfile.NamedTemporaryFile(
212
281
  'wb', delete=False, prefix='.tmp.', suffix=filename, dir=self.path
213
282
  )
214
- return PendingFile(fd=temp_file, filename=filename)
215
-
216
- def commit_file(self, file: PendingFile, desc: str = '') -> bool:
283
+ metadata: Dict[str, Optional[BaseModel]] = {'compression': None}
284
+ if self.compress or grading_context.should_compress():
285
+ fd_name = temp_file.name
286
+ level = grading_context.get_compression_level()
287
+ temp_file = typing.cast(
288
+ IO[bytes],
289
+ lz4.frame.open(
290
+ temp_file,
291
+ mode='wb',
292
+ compression_level=level,
293
+ ),
294
+ )
295
+ temp_file.name = fd_name # type: ignore
296
+ metadata['compression'] = CompressionMetadata(compression_level=level)
297
+
298
+ return PendingFile(fd=temp_file, filename=filename, metadata=metadata)
299
+
300
+ def commit_file(
301
+ self, file: PendingFile, metadata: Optional[Dict[str, BaseModel]] = None
302
+ ) -> bool:
217
303
  """See FileCacherBackend.commit_file()."""
218
304
  file.fd.close()
219
305
 
220
306
  file_path: pathlib.Path = self.path / file.filename
307
+ file_path.parent.mkdir(parents=True, exist_ok=True)
308
+
309
+ for key, value in file.metadata.items():
310
+ self._set_metadata(file.filename, key, value)
311
+
312
+ if metadata is not None:
313
+ for key, value in metadata.items():
314
+ self._set_metadata(file.filename, key, value)
315
+
221
316
  # Move it into place in the cache. Skip if it already exists, and
222
317
  # delete the temporary file instead.
223
318
  if not file_path.is_file():
@@ -231,21 +326,43 @@ class FilesystemStorage(Storage):
231
326
  pathlib.PosixPath(file.fd.name).unlink()
232
327
  return False
233
328
 
329
+ def _get_metadata_path(self, filename: str, key: str) -> pathlib.Path:
330
+ return self.path / '.metadata' / f'{filename}__{key}.json'
331
+
332
+ def _set_metadata(self, filename: str, key: str, value: Optional[BaseModel]):
333
+ if value is None:
334
+ self._get_metadata_path(filename, key).unlink(missing_ok=True)
335
+ else:
336
+ metadata_path = self._get_metadata_path(filename, key)
337
+ metadata_path.parent.mkdir(parents=True, exist_ok=True)
338
+ metadata_path.write_text(value.model_dump_json())
339
+
340
+ def set_metadata(self, filename: str, key: str, value: Optional[BaseModel]):
341
+ if not self.exists(filename):
342
+ raise KeyError('File not found.')
343
+
344
+ self._set_metadata(filename, key, value)
345
+
346
+ def get_metadata(
347
+ self, filename: str, key: str, model_cls: Type[BaseModelT]
348
+ ) -> Optional[BaseModelT]:
349
+ path = self._get_metadata_path(filename, key)
350
+ if not path.is_file():
351
+ return None
352
+ return model_cls.model_validate_json(path.read_text())
353
+
354
+ def list_metadata(self, filename: str) -> List[str]:
355
+ return [
356
+ path.stem.split('__')[1]
357
+ for path in (self.path / '.metadata').glob(f'{filename}__*.json')
358
+ ]
359
+
234
360
  def exists(self, filename: str) -> bool:
235
361
  """See FileCacherBackend.exists()."""
236
362
  file_path: pathlib.Path = self.path / filename
237
363
 
238
364
  return file_path.is_file()
239
365
 
240
- def describe(self, filename: str) -> str:
241
- """See FileCacherBackend.describe()."""
242
- file_path: pathlib.Path = self.path / filename
243
-
244
- if not file_path.is_file():
245
- raise KeyError('File not found.')
246
-
247
- return ''
248
-
249
366
  def get_size(self, filename: str) -> int:
250
367
  """See FileCacherBackend.get_size()."""
251
368
  file_path: pathlib.Path = self.path / filename
@@ -260,15 +377,19 @@ class FilesystemStorage(Storage):
260
377
  file_path: pathlib.Path = self.path / filename
261
378
 
262
379
  file_path.unlink(missing_ok=True)
380
+ for key in self.list_metadata(filename):
381
+ self._get_metadata_path(filename, key).unlink(missing_ok=True)
263
382
 
264
- def list(self) -> List[FileWithDescription]:
383
+ def list(self) -> List[FileWithMetadata]:
265
384
  """See FileCacherBackend.list()."""
266
385
  res = []
267
386
  for path in self.path.glob('*'):
268
387
  if path.is_file():
388
+ filename = str(path.relative_to(self.path))
269
389
  res.append(
270
- FileWithDescription(
271
- filename=str(path.relative_to(self.path)), description=''
390
+ FileWithMetadata(
391
+ filename=filename,
392
+ metadata=self.list_metadata(filename),
272
393
  )
273
394
  )
274
395
  return res
@@ -277,4 +398,18 @@ class FilesystemStorage(Storage):
277
398
  file_path = self.path / filename
278
399
  if not file_path.is_file():
279
400
  raise KeyError('File not found.')
401
+
402
+ compression_metadata = self.get_metadata(
403
+ filename, 'compression', CompressionMetadata
404
+ )
405
+ if compression_metadata is not None:
406
+ return None
280
407
  return file_path
408
+
409
+ def filename_from_symlink(self, link: pathlib.Path) -> Optional[str]:
410
+ if not link.is_symlink():
411
+ return None
412
+ filename = utils.abspath(link.readlink())
413
+ if not filename.is_file():
414
+ return None
415
+ return str(filename.relative_to(self.path))