autograder.fileop

Fileops (File Operations) are simple operations on files intended to be easy to implement on any platform.

See:

  1"""
  2Fileops (File Operations) are simple operations on files
  3intended to be easy to implement on any platform.
  4
  5See:
  6 - https://github.com/edulinq/autograder-server/blob/main/docs/types.md#file-operation-fileop
  7 - https://github.com/edulinq/autograder-server/blob/main/internal/util/fileop.go
  8"""
  9
 10import fnmatch
 11import glob
 12import os
 13import re
 14import typing
 15
 16import edq.util.dirent
 17
 18import autograder.util.path
 19
 20FILE_OP_LONG_COPY: str = "copy"
 21FILE_OP_SHORT_COPY: str = "cp"
 22
 23FILE_OP_LONG_MOVE: str = "move"
 24FILE_OP_SHORT_MOVE: str = "mv"
 25
 26FILE_OP_LONG_MKDIR: str = "make-dir"
 27FILE_OP_SHORT_MKDIR: str = "mkdir"
 28
 29FILE_OP_LONG_REMOVE: str = "remove"
 30FILE_OP_SHORT_REMOVE: str = "rm"
 31
 32fileop_normal_name: typing.Dict[str, str] = {
 33    FILE_OP_SHORT_COPY: FILE_OP_LONG_COPY,
 34    FILE_OP_LONG_COPY: FILE_OP_LONG_COPY,
 35
 36    FILE_OP_SHORT_MOVE: FILE_OP_LONG_MOVE,
 37    FILE_OP_LONG_MOVE: FILE_OP_LONG_MOVE,
 38
 39    FILE_OP_SHORT_MKDIR: FILE_OP_LONG_MKDIR,
 40    FILE_OP_LONG_MKDIR: FILE_OP_LONG_MKDIR,
 41
 42    FILE_OP_SHORT_REMOVE: FILE_OP_LONG_REMOVE,
 43    FILE_OP_LONG_REMOVE: FILE_OP_LONG_REMOVE,
 44}
 45""" Map operations to their long/canonical name. """
 46
 47fileop_num_args: typing.Dict[str, int] = {
 48    FILE_OP_LONG_COPY: 2,
 49    FILE_OP_LONG_MOVE: 2,
 50    FILE_OP_LONG_MKDIR: 1,
 51    FILE_OP_LONG_REMOVE: 1,
 52}
 53""" The number of operations for each file operation. """
 54
 55FileOp = typing.List[str]
 56""" Alias file operations until they are formalized in a more robust class. """
 57
 58def validate(operation: typing.Union[None, typing.List[str], FileOp]) -> FileOp:
 59    """
 60    Validate a fileop.
 61    Raise on failure or invalid operation.
 62    Otherwise, return the same list that has been validated, cleaned, and typed.
 63    """
 64
 65    if (operation is None):
 66        raise ValueError("File operation is None.")
 67
 68    if (len(operation) == 0):
 69        raise ValueError("File operation is empty.")
 70
 71    command = fileop_normal_name.get(operation[0].lower(), None)
 72    if (command is None):
 73        raise ValueError(f"Unknown file operation: '{operation[0]}'.")
 74
 75    operation[0] = command
 76
 77    num_args = (len(operation) - 1)
 78    expected_num_args = fileop_num_args[command]
 79    if (expected_num_args != num_args):
 80        raise ValueError(f"Incorrect number of arguments for '{command}' file operation."
 81            + f" Expected {expected_num_args}, found {num_args}.")
 82
 83    # Check all path arguments.
 84    for i in range(1, len(operation)):
 85        path = operation[i]
 86
 87        if ('\\' in path):
 88            raise ValueError(f"Argument at index {i} ('{operation[i]}')"
 89                + " contains a backslash ('\\') or is not a POSIX path.")
 90
 91        path = os.path.normpath(path)
 92
 93        if (os.path.isabs(path)):
 94            raise ValueError(f"Argument at index {i} ('{operation[i]}') is an absolute path."
 95                + " Only relative paths are allowed.")
 96
 97        if (not autograder.util.path.is_local(path)):
 98            raise ValueError(f"Argument at index {i} ('{operation[i]}')"
 99                + " points outside of the its base directory."
100                + " File operation paths can not reference parent directories.")
101
102        if (path == "."):
103            raise ValueError(f"Argument at index {i} ('{operation[i]}')"
104                + " cannot point just to the current directory."
105                + " File operation paths must point to a"
106                + " dirent inside the current directory tree.")
107
108        try:
109            glob_pattern = fnmatch.translate(path)
110            re.compile(glob_pattern)
111        except Exception as error:
112            raise ValueError(f"Argument at index {i} ('{operation[i]}') is an invalid glob pattern: {error}.")  # pylint: disable=raise-missing-from
113
114        operation[i] = path
115
116    return operation
117
118def execute(operation: FileOp, base_dir: str) -> None:
119    """ Execute operation operation in the given directory. """
120
121    validate(operation)
122
123    command = operation[0]
124
125    if (command == FILE_OP_LONG_COPY):
126        source_path = _resolve_path(operation[1], base_dir)
127        dest_path = _resolve_path(operation[2], base_dir)
128
129        _handle_glob_file_operation(
130            source_path, dest_path, edq.util.dirent.copy,
131        )
132    elif (command == FILE_OP_LONG_MOVE):
133        source_path = _resolve_path(operation[1], base_dir)
134        dest_path = _resolve_path(operation[2], base_dir)
135
136        _handle_glob_file_operation(source_path, dest_path, edq.util.dirent.move)
137    elif (command == FILE_OP_LONG_MKDIR):
138        path = _resolve_path(operation[1], base_dir)
139
140        edq.util.dirent.mkdir(path)
141    elif (command == FILE_OP_LONG_REMOVE):
142        path_glob = _resolve_path(operation[1], base_dir)
143
144        _handle_glob_remove(path_glob)
145    else:
146        raise ValueError(f"Unknown file operation: '{command}'.")
147
148def validate_file_operations(operations: typing.List[typing.Union[None, typing.List[str]]]) -> typing.List[FileOp]:
149    """ Validate multiple file operations. """
150
151    return [validate(operation) for operation in operations]
152
153def exec_file_operations(operations: typing.List[FileOp], base_dir: str) -> None:
154    """ Execute multiple file operations in the given directory. """
155
156    for operation in operations:
157        execute(operation, base_dir)
158
159def _resolve_path(path: str, base_dir: str) -> str:
160    """ Resolve a path (which may be relative) in the given base directory. """
161
162    if (os.path.isabs(path)):
163        return os.path.normpath(path)
164
165    return os.path.normpath(os.path.join(base_dir, path))
166
167def _handle_glob_file_operation(source_path_glob: str, dest_path: str, operation: typing.Callable, **kwargs: typing.Any) -> None:
168    """ Resolve a path that may contain globs, and perform the given file system operation. """
169
170    source_paths = _prep_for_globs(source_path_glob, dest_path)
171
172    for source_path in source_paths:
173        if (source_path == dest_path):
174            continue
175
176        resolved_dest_path = dest_path
177
178        # Check for an operation into a dir.
179        if (os.path.isdir(dest_path)):
180            resolved_dest_path = os.path.join(dest_path, os.path.basename(source_path))
181
182        operation(source_path, resolved_dest_path, **kwargs)
183
184def _handle_glob_remove(path_glob: str) -> None:
185    """ Resolve a path that may contain a glob and remove the resolved paths. """
186
187    paths = glob.glob(path_glob)
188
189    for path in paths:
190        edq.util.dirent.remove(path)
191
192def _prep_for_globs(source_path_glob: str, dest_path: str) -> typing.List[str]:
193    """
194    Prepare for executing an operation in the presence of globs.
195
196    Do the following actions:
197    1) Resolve the source paths for globs.
198    2) Ensure that the source path matches at least one existing path.
199    3) If multiple source paths match, ensure that the dest exists and is a dir.
200    4) Return the resolved source paths.
201    """
202
203    source_paths = glob.glob(source_path_glob)
204
205    if (len(source_paths) == 0):
206        raise FileNotFoundError(f"No such file or directory: '{source_path_glob}'.")
207
208    if (len(source_paths) > 1):
209        edq.util.dirent.mkdir(dest_path)
210
211    return source_paths
FILE_OP_LONG_COPY: str = 'copy'
FILE_OP_SHORT_COPY: str = 'cp'
FILE_OP_LONG_MOVE: str = 'move'
FILE_OP_SHORT_MOVE: str = 'mv'
FILE_OP_LONG_MKDIR: str = 'make-dir'
FILE_OP_SHORT_MKDIR: str = 'mkdir'
FILE_OP_LONG_REMOVE: str = 'remove'
FILE_OP_SHORT_REMOVE: str = 'rm'
fileop_normal_name: Dict[str, str] = {'cp': 'copy', 'copy': 'copy', 'mv': 'move', 'move': 'move', 'mkdir': 'make-dir', 'make-dir': 'make-dir', 'rm': 'remove', 'remove': 'remove'}

Map operations to their long/canonical name.

fileop_num_args: Dict[str, int] = {'copy': 2, 'move': 2, 'make-dir': 1, 'remove': 1}

The number of operations for each file operation.

FileOp = typing.List[str]

Alias file operations until they are formalized in a more robust class.

def validate(operation: Optional[List[str]]) -> List[str]:
 59def validate(operation: typing.Union[None, typing.List[str], FileOp]) -> FileOp:
 60    """
 61    Validate a fileop.
 62    Raise on failure or invalid operation.
 63    Otherwise, return the same list that has been validated, cleaned, and typed.
 64    """
 65
 66    if (operation is None):
 67        raise ValueError("File operation is None.")
 68
 69    if (len(operation) == 0):
 70        raise ValueError("File operation is empty.")
 71
 72    command = fileop_normal_name.get(operation[0].lower(), None)
 73    if (command is None):
 74        raise ValueError(f"Unknown file operation: '{operation[0]}'.")
 75
 76    operation[0] = command
 77
 78    num_args = (len(operation) - 1)
 79    expected_num_args = fileop_num_args[command]
 80    if (expected_num_args != num_args):
 81        raise ValueError(f"Incorrect number of arguments for '{command}' file operation."
 82            + f" Expected {expected_num_args}, found {num_args}.")
 83
 84    # Check all path arguments.
 85    for i in range(1, len(operation)):
 86        path = operation[i]
 87
 88        if ('\\' in path):
 89            raise ValueError(f"Argument at index {i} ('{operation[i]}')"
 90                + " contains a backslash ('\\') or is not a POSIX path.")
 91
 92        path = os.path.normpath(path)
 93
 94        if (os.path.isabs(path)):
 95            raise ValueError(f"Argument at index {i} ('{operation[i]}') is an absolute path."
 96                + " Only relative paths are allowed.")
 97
 98        if (not autograder.util.path.is_local(path)):
 99            raise ValueError(f"Argument at index {i} ('{operation[i]}')"
100                + " points outside of the its base directory."
101                + " File operation paths can not reference parent directories.")
102
103        if (path == "."):
104            raise ValueError(f"Argument at index {i} ('{operation[i]}')"
105                + " cannot point just to the current directory."
106                + " File operation paths must point to a"
107                + " dirent inside the current directory tree.")
108
109        try:
110            glob_pattern = fnmatch.translate(path)
111            re.compile(glob_pattern)
112        except Exception as error:
113            raise ValueError(f"Argument at index {i} ('{operation[i]}') is an invalid glob pattern: {error}.")  # pylint: disable=raise-missing-from
114
115        operation[i] = path
116
117    return operation

Validate a fileop. Raise on failure or invalid operation. Otherwise, return the same list that has been validated, cleaned, and typed.

def execute(operation: List[str], base_dir: str) -> None:
119def execute(operation: FileOp, base_dir: str) -> None:
120    """ Execute operation operation in the given directory. """
121
122    validate(operation)
123
124    command = operation[0]
125
126    if (command == FILE_OP_LONG_COPY):
127        source_path = _resolve_path(operation[1], base_dir)
128        dest_path = _resolve_path(operation[2], base_dir)
129
130        _handle_glob_file_operation(
131            source_path, dest_path, edq.util.dirent.copy,
132        )
133    elif (command == FILE_OP_LONG_MOVE):
134        source_path = _resolve_path(operation[1], base_dir)
135        dest_path = _resolve_path(operation[2], base_dir)
136
137        _handle_glob_file_operation(source_path, dest_path, edq.util.dirent.move)
138    elif (command == FILE_OP_LONG_MKDIR):
139        path = _resolve_path(operation[1], base_dir)
140
141        edq.util.dirent.mkdir(path)
142    elif (command == FILE_OP_LONG_REMOVE):
143        path_glob = _resolve_path(operation[1], base_dir)
144
145        _handle_glob_remove(path_glob)
146    else:
147        raise ValueError(f"Unknown file operation: '{command}'.")

Execute operation operation in the given directory.

def validate_file_operations(operations: List[Optional[List[str]]]) -> List[List[str]]:
149def validate_file_operations(operations: typing.List[typing.Union[None, typing.List[str]]]) -> typing.List[FileOp]:
150    """ Validate multiple file operations. """
151
152    return [validate(operation) for operation in operations]

Validate multiple file operations.

def exec_file_operations(operations: List[List[str]], base_dir: str) -> None:
154def exec_file_operations(operations: typing.List[FileOp], base_dir: str) -> None:
155    """ Execute multiple file operations in the given directory. """
156
157    for operation in operations:
158        execute(operation, base_dir)

Execute multiple file operations in the given directory.