shell_adventure.api

The shell_adventure.api package contains the classes and methods needed to make puzzle templates and autograders

 1"""
 2The shell_adventure.api package contains the classes and methods needed to make puzzle templates and autograders
 3"""
 4
 5from __future__ import annotations
 6from shell_adventure.shared.puzzle import Puzzle, PuzzleTemplate, AutoGrader
 7from .file import File
 8from .permissions import (change_user, user_exists, Permissions, LinkedPermissions,
 9                          PermissionsGroup, LinkedPermissionsGroup)
10from .random_helper import RandomHelper, RandomHelperException
11
12from pathlib import Path as _Path
13
14PKG_PATH = _Path(__path__[0]).resolve() # type: ignore  # mypy issue #1422
15
16# Unfortunately we need some package level variables to allow File methods to access
17# the RandomHelper and student home. They will be set when the tutorial is created.
18_home: File = None
19""" Global that is set to the home of the student. """
20
21_rand: RandomHelper = None
22"""
23Global that is set to the `RandomHelper` of the tutorial, so that `rand()` and `File` methods can access it.
24"""
25
26# After restart and pickling the lambdas we don't restore the RandomHelper state so we don't want you to be able to
27# call it in autograder functions. So we make rand a function so that it doesn't get captured directly, that way we can
28# set tutorial.rand to None and trying to use rand() will fail.
29# TutorialDocker will set _rand when it runs.
30def rand() -> RandomHelper:
31    """
32    Returns the `RandomHelper` which should be used when creating random files and folders.
33    """
34    if not _rand:
35        raise RandomHelperException("You can only use randomization in Puzzle templates, not autograders")
36    return _rand
37
38__all__ = [
39    "Puzzle",
40    "PuzzleTemplate",
41    "AutoGrader",
42    "File",
43    "change_user",
44    "user_exists",
45    "Permissions",
46    "LinkedPermissions",
47    "PermissionsGroup",
48    "LinkedPermissionsGroup",
49    "RandomHelper",
50    "RandomHelperException",
51    "rand",
52]
class Puzzle:
 8class Puzzle:
 9    """ Represents a single puzzle in the tutorial. """
10
11    question: str
12    """ The question string that will be shown to the student. """
13
14    score: int
15    """ The score that will be given when the puzzle is solved. """
16
17    checker: AutoGrader
18    """
19    The function that will be used to autograde the puzzle.
20    """
21
22    _allowed_checker_args: ClassVar[List[str]] = ["cwd", "flag"]
23    """ A set of the checker args that are recognized. """
24
25    def __init__(self, question: str, checker: AutoGrader, score: int = 1):
26        """
27        Construct a `Puzzle` object.
28
29        Parameters:
30
31        question:
32            The question to be asked.
33        checker:
34            The function that will grade whether the puzzle was completed correctly or not.
35            The checker function can take the following parameters. All parameters are optional, and order does not matter,
36            but the parameters must have the same name as listed here:
37                flag: str
38                    If the flag parameter is present, an input dialog will be shown to the student when sumbitting a puzzle,
39                    and their input will be passed to this parameter.
40                cwd: File
41                    The path to the students current directory
42
43            The checker function should return a string or a boolean. If it returns True, the puzzle is solved. If it returns
44            False or a string, the puzzle was not solved. Returning a string will show the string as feedback to the student.
45        score:
46            The score given on success. Defaults to 1.
47        """
48        if not isinstance(question, str): raise TypeError("Puzzle.question should be a string.")
49        if not callable(checker): raise TypeError("Puzzle.checker should be a Callable.")
50        if not isinstance(score, int): raise TypeError("Puzzle.score should be an int.")
51
52        self.question = question
53        self.score = score
54        self.checker = checker # type: ignore # MyPy fusses about "Cannot assign to a method"
55
56        extra_params = extra_func_params(self.checker, Puzzle._allowed_checker_args)
57        if extra_params:
58            raise UnrecognizedParamsError(
59                f'Unrecognized param(s) {sentence_list(extra_params, quote = True)} in checker function.' +
60                f' Expected {sentence_list(Puzzle._allowed_checker_args, last_sep = " and/or ", quote = True)}.',
61                extra_params = extra_params
62            )

Represents a single puzzle in the tutorial.

Puzzle( question: str, checker: Callable[..., Union[str, bool]], score: int = 1)
25    def __init__(self, question: str, checker: AutoGrader, score: int = 1):
26        """
27        Construct a `Puzzle` object.
28
29        Parameters:
30
31        question:
32            The question to be asked.
33        checker:
34            The function that will grade whether the puzzle was completed correctly or not.
35            The checker function can take the following parameters. All parameters are optional, and order does not matter,
36            but the parameters must have the same name as listed here:
37                flag: str
38                    If the flag parameter is present, an input dialog will be shown to the student when sumbitting a puzzle,
39                    and their input will be passed to this parameter.
40                cwd: File
41                    The path to the students current directory
42
43            The checker function should return a string or a boolean. If it returns True, the puzzle is solved. If it returns
44            False or a string, the puzzle was not solved. Returning a string will show the string as feedback to the student.
45        score:
46            The score given on success. Defaults to 1.
47        """
48        if not isinstance(question, str): raise TypeError("Puzzle.question should be a string.")
49        if not callable(checker): raise TypeError("Puzzle.checker should be a Callable.")
50        if not isinstance(score, int): raise TypeError("Puzzle.score should be an int.")
51
52        self.question = question
53        self.score = score
54        self.checker = checker # type: ignore # MyPy fusses about "Cannot assign to a method"
55
56        extra_params = extra_func_params(self.checker, Puzzle._allowed_checker_args)
57        if extra_params:
58            raise UnrecognizedParamsError(
59                f'Unrecognized param(s) {sentence_list(extra_params, quote = True)} in checker function.' +
60                f' Expected {sentence_list(Puzzle._allowed_checker_args, last_sep = " and/or ", quote = True)}.',
61                extra_params = extra_params
62            )

Construct a Puzzle object.

Parameters:

question: The question to be asked. checker: The function that will grade whether the puzzle was completed correctly or not. The checker function can take the following parameters. All parameters are optional, and order does not matter, but the parameters must have the same name as listed here: flag: str If the flag parameter is present, an input dialog will be shown to the student when sumbitting a puzzle, and their input will be passed to this parameter. cwd: File The path to the students current directory

The checker function should return a string or a boolean. If it returns True, the puzzle is solved. If it returns
False or a string, the puzzle was not solved. Returning a string will show the string as feedback to the student.

score: The score given on success. Defaults to 1.

question: str

The question string that will be shown to the student.

score: int

The score that will be given when the puzzle is solved.

checker: Callable[..., Union[str, bool]]

The function that will be used to autograde the puzzle.

PuzzleTemplate = typing.Callable[..., shell_adventure.api.Puzzle]
AutoGrader = typing.Callable[..., typing.Union[str, bool]]
class File(pathlib.PosixPath):
  9class File(PosixPath):
 10    """
 11    File is an extention of pathlib.PosixPath, with a few convenience methods added.
 12    Refer to pathlib documentation at [python.org](https://docs.python.org/3/library/pathlib.html)
 13    """
 14
 15    @classmethod
 16    def home(cls) -> File:
 17        """ Return the home directory of the student. Equivalent to the "home" parameter in puzzle templates. """
 18        if shell_adventure.api._home: # Get home directory from tutorial
 19            return shell_adventure.api._home
 20        else: # Default to PosixPath
 21            return File(PosixPath.home())
 22
 23
 24    # === Convenience Methods ===
 25
 26    @property
 27    def children(self) -> List[File]:
 28        """
 29        A property that returns a list of directory's contents. Raises `NotADirectoryError` if not a directory.
 30        Basically an alias of `Path.iterdir()` but returns a list instead of a generator.
 31        """
 32        return list(self.iterdir()) # type: ignore
 33
 34    @property
 35    def path(self) -> str:
 36        """ A property that returns the absolute path to this file as a string. """
 37        return str(self.resolve())
 38
 39    def create(self, *, mode: int = None, exist_ok = True, recursive = True, content: str = None) -> File:
 40        """
 41        An combined version of `Path.mkdir()`, `Path.touch()`, `Path.chmod()` and `Path.write_text()`. It
 42        will `mkdir` missing dirs in the path if recursive is True (the default). New directories will use
 43        the default mode regardless of the `mode` parameter to match POSIX `mkdir -p` behavior. You can also
 44        specify a content string which will be written to the file.
 45
 46        Returns self.
 47        """
 48        if recursive:
 49            self.parent.mkdir(parents = True, exist_ok = True) # mkdir is already recursive
 50        self.touch(exist_ok = exist_ok)
 51        if mode != None:
 52            self.chmod(mode) # touch(mode=) combines with umask first which results in odd behavior
 53        if content != None:
 54            self.write_text(content)
 55
 56        return self
 57
 58    # === Permissions ===
 59
 60    def chown(self, owner: Union[str, int] = None, group: Union[str, int] = None):
 61        """
 62        Change owner and/or group of the given path. Automatically runs as root, you do not have to use `change_user()`
 63        before using `chown()`. user can be a system user name or a uid; the same applies to group. At least one
 64        argument is required. See also `os.chown()`, the underlying function.
 65        """
 66        with change_user("root"): # Automatically set privilege to root.
 67            shutil.chown(self, owner, group)
 68
 69    def chmod(self, mode: Union[str, int]):
 70        """
 71        Overrides `Path.chmod()`. Automatically runs as root, you do not have to `change_user()` before using
 72        `chmod()`. You can pass it a mode as an int, ie. `0o777` like `Path.chmod()`, or you can pass it a string
 73        that the unix `chmod` command would recognize such as `"u+x"`. See the [chmod man page](https://linux.die.net/man/1/chmod)
 74        """
 75        with change_user("root"): # Automatically set privilege to root.
 76            if isinstance(mode, str): # Just call chmod directly instead of trying to parse the mode string ourselves.
 77                if not self.exists(): raise FileNotFoundError
 78                process = subprocess.run(["chmod", mode, self.path])
 79                if process.returncode != 0:
 80                    raise ValueError(f'Invalid mode "{mode}"')
 81            else:
 82                super().chmod(mode)
 83
 84    @property
 85    def permissions(self) -> LinkedPermissions:
 86        """
 87        A property that returns a `Permissions` object representing the permissions of this file.
 88        Eg.
 89        >>> File("A.py").permissions.user.execute
 90        True
 91        """
 92        return LinkedPermissions(self)
 93
 94    @permissions.setter
 95    def permissions(self, val: Union[int, Permissions]):
 96        """ Set this `File`'s permissions. """
 97        if isinstance(val, Permissions):
 98            val = int(val)
 99        self.chmod(val)
100
101
102    # === Randomization ===
103
104    def random_file(self, ext = None) -> File:
105        """
106        Creates a `File` with a random name under self. The source for random names comes from the `name_dictionary` option
107        in the Tutorial config. The file is not created on disk and is not marked as shared. To create the file on disk
108        call `File.create()` or `mkdir()` on the file returned by `random_file()`. You can pass an extension which will be added
109        to the random name. Will not create a file with a name that already exists.
110        """
111        return shell_adventure.api.rand()._file(self, ext = ext)
112
113    def random_shared_folder(self, depth: Union[int, Tuple[int, int]] = (1, 3), create_new_chance: float = 0.5) -> File:
114        """
115        Makes a `File` to a random folder under this file. The folder may be several levels deep, and its parents may or may
116        not exist. Its does not create the file or any parents on disk.
117
118        The returned `File` can include new folders in the path with random names, and it can include existing folders that are
119        "shared". Folders are only "shared" if they were created via `random_shared_folder()` or explicitly marked shared via
120        `mark_shared()`.
121
122        Folders created by `random_shared_folder()` can be "reused" in other calls to `folder()`, so you should not modify
123        the folders in puzzles. This way, folders created by puzzles won't interfere with one another, but multiple puzzles can
124        still be created in the same directory. If you need a folder at a random location that won't have any other puzzles put
125        in it you should explicitly create a folder under the one returned by `random_shared_folder()` with something like
126        >>> home.random_shared_folder().random_file().mkdir()
127
128        depth: Either an int or a (min, max) tuple. Specifies the depth under parent of the returned file. If a tuple is given
129               a random depth in rand [min, max] will be used.
130        create_new_chance: float in [0, 1]. The percentage chance that a new folder will be created even if shared folders are
131                           available. 0 means it will only choose existing folders unless there are none, 1 means it will only
132                           create new folders.
133
134        >>> home = File("/home/student")
135        >>> home.random_shared_folder()
136        File("/home/student/random/nested/folder")
137        >>> home.random_shared_folder()
138        File("/home/student/apple/banana")
139        >>> folder.mkdir(parents = True) # random_shared_folder() doesn't create the file on disk. Use mkdir() with parents = True to make the folder.
140        >>> # Make a random nested folder, but make the last folder not "shared" so we can safely rm it
141        >>> home.random_shared_folder().random_file().mkdir()
142        File("/home/student/orange/lime/tomato")
143        >>> home.random_shared_folder(create_new_chance = 0) # Will choose an existing "shared" folder
144        File("/home/student/orange/lime")
145        >>> File("/").random_shared_folder(depth = [5, 6]) # Create a folder 5 or 6 levels under root
146        File("/blueberry/lemon/watermellon/kiwi/strawberry")
147        """
148        return shell_adventure.api.rand()._folder(self, depth, create_new_chance)
149
150    def mark_shared(self):
151        """ Marks the a `File` as shared. `File` should be a directory, though it does not have to exist yet. """
152        shell_adventure.api.rand()._mark_shared(self)

File is an extention of pathlib.PosixPath, with a few convenience methods added. Refer to pathlib documentation at python.org

@classmethod
def home(cls) -> shell_adventure.api.File:
15    @classmethod
16    def home(cls) -> File:
17        """ Return the home directory of the student. Equivalent to the "home" parameter in puzzle templates. """
18        if shell_adventure.api._home: # Get home directory from tutorial
19            return shell_adventure.api._home
20        else: # Default to PosixPath
21            return File(PosixPath.home())

Return the home directory of the student. Equivalent to the "home" parameter in puzzle templates.

children: List[shell_adventure.api.File]

A property that returns a list of directory's contents. Raises NotADirectoryError if not a directory. Basically an alias of Path.iterdir() but returns a list instead of a generator.

path: str

A property that returns the absolute path to this file as a string.

def create( self, *, mode: int = None, exist_ok=True, recursive=True, content: str = None) -> shell_adventure.api.File:
39    def create(self, *, mode: int = None, exist_ok = True, recursive = True, content: str = None) -> File:
40        """
41        An combined version of `Path.mkdir()`, `Path.touch()`, `Path.chmod()` and `Path.write_text()`. It
42        will `mkdir` missing dirs in the path if recursive is True (the default). New directories will use
43        the default mode regardless of the `mode` parameter to match POSIX `mkdir -p` behavior. You can also
44        specify a content string which will be written to the file.
45
46        Returns self.
47        """
48        if recursive:
49            self.parent.mkdir(parents = True, exist_ok = True) # mkdir is already recursive
50        self.touch(exist_ok = exist_ok)
51        if mode != None:
52            self.chmod(mode) # touch(mode=) combines with umask first which results in odd behavior
53        if content != None:
54            self.write_text(content)
55
56        return self

An combined version of Path.mkdir(), Path.touch(), Path.chmod() and Path.write_text(). It will mkdir missing dirs in the path if recursive is True (the default). New directories will use the default mode regardless of the mode parameter to match POSIX mkdir -p behavior. You can also specify a content string which will be written to the file.

Returns self.

def chown(self, owner: Union[str, int] = None, group: Union[str, int] = None):
60    def chown(self, owner: Union[str, int] = None, group: Union[str, int] = None):
61        """
62        Change owner and/or group of the given path. Automatically runs as root, you do not have to use `change_user()`
63        before using `chown()`. user can be a system user name or a uid; the same applies to group. At least one
64        argument is required. See also `os.chown()`, the underlying function.
65        """
66        with change_user("root"): # Automatically set privilege to root.
67            shutil.chown(self, owner, group)

Change owner and/or group of the given path. Automatically runs as root, you do not have to use change_user() before using chown(). user can be a system user name or a uid; the same applies to group. At least one argument is required. See also os.chown(), the underlying function.

def chmod(self, mode: Union[str, int]):
69    def chmod(self, mode: Union[str, int]):
70        """
71        Overrides `Path.chmod()`. Automatically runs as root, you do not have to `change_user()` before using
72        `chmod()`. You can pass it a mode as an int, ie. `0o777` like `Path.chmod()`, or you can pass it a string
73        that the unix `chmod` command would recognize such as `"u+x"`. See the [chmod man page](https://linux.die.net/man/1/chmod)
74        """
75        with change_user("root"): # Automatically set privilege to root.
76            if isinstance(mode, str): # Just call chmod directly instead of trying to parse the mode string ourselves.
77                if not self.exists(): raise FileNotFoundError
78                process = subprocess.run(["chmod", mode, self.path])
79                if process.returncode != 0:
80                    raise ValueError(f'Invalid mode "{mode}"')
81            else:
82                super().chmod(mode)

Overrides Path.chmod(). Automatically runs as root, you do not have to change_user() before using chmod(). You can pass it a mode as an int, ie. 0o777 like Path.chmod(), or you can pass it a string that the unix chmod command would recognize such as "u+x". See the chmod man page

A property that returns a Permissions object representing the permissions of this file. Eg.

>>> File("A.py").permissions.user.execute
True
def random_file(self, ext=None) -> shell_adventure.api.File:
104    def random_file(self, ext = None) -> File:
105        """
106        Creates a `File` with a random name under self. The source for random names comes from the `name_dictionary` option
107        in the Tutorial config. The file is not created on disk and is not marked as shared. To create the file on disk
108        call `File.create()` or `mkdir()` on the file returned by `random_file()`. You can pass an extension which will be added
109        to the random name. Will not create a file with a name that already exists.
110        """
111        return shell_adventure.api.rand()._file(self, ext = ext)

Creates a File with a random name under self. The source for random names comes from the name_dictionary option in the Tutorial config. The file is not created on disk and is not marked as shared. To create the file on disk call File.create() or mkdir() on the file returned by random_file(). You can pass an extension which will be added to the random name. Will not create a file with a name that already exists.

def random_shared_folder( self, depth: Union[int, Tuple[int, int]] = (1, 3), create_new_chance: float = 0.5) -> shell_adventure.api.File:
113    def random_shared_folder(self, depth: Union[int, Tuple[int, int]] = (1, 3), create_new_chance: float = 0.5) -> File:
114        """
115        Makes a `File` to a random folder under this file. The folder may be several levels deep, and its parents may or may
116        not exist. Its does not create the file or any parents on disk.
117
118        The returned `File` can include new folders in the path with random names, and it can include existing folders that are
119        "shared". Folders are only "shared" if they were created via `random_shared_folder()` or explicitly marked shared via
120        `mark_shared()`.
121
122        Folders created by `random_shared_folder()` can be "reused" in other calls to `folder()`, so you should not modify
123        the folders in puzzles. This way, folders created by puzzles won't interfere with one another, but multiple puzzles can
124        still be created in the same directory. If you need a folder at a random location that won't have any other puzzles put
125        in it you should explicitly create a folder under the one returned by `random_shared_folder()` with something like
126        >>> home.random_shared_folder().random_file().mkdir()
127
128        depth: Either an int or a (min, max) tuple. Specifies the depth under parent of the returned file. If a tuple is given
129               a random depth in rand [min, max] will be used.
130        create_new_chance: float in [0, 1]. The percentage chance that a new folder will be created even if shared folders are
131                           available. 0 means it will only choose existing folders unless there are none, 1 means it will only
132                           create new folders.
133
134        >>> home = File("/home/student")
135        >>> home.random_shared_folder()
136        File("/home/student/random/nested/folder")
137        >>> home.random_shared_folder()
138        File("/home/student/apple/banana")
139        >>> folder.mkdir(parents = True) # random_shared_folder() doesn't create the file on disk. Use mkdir() with parents = True to make the folder.
140        >>> # Make a random nested folder, but make the last folder not "shared" so we can safely rm it
141        >>> home.random_shared_folder().random_file().mkdir()
142        File("/home/student/orange/lime/tomato")
143        >>> home.random_shared_folder(create_new_chance = 0) # Will choose an existing "shared" folder
144        File("/home/student/orange/lime")
145        >>> File("/").random_shared_folder(depth = [5, 6]) # Create a folder 5 or 6 levels under root
146        File("/blueberry/lemon/watermellon/kiwi/strawberry")
147        """
148        return shell_adventure.api.rand()._folder(self, depth, create_new_chance)

Makes a File to a random folder under this file. The folder may be several levels deep, and its parents may or may not exist. Its does not create the file or any parents on disk.

The returned File can include new folders in the path with random names, and it can include existing folders that are "shared". Folders are only "shared" if they were created via random_shared_folder() or explicitly marked shared via mark_shared().

Folders created by random_shared_folder() can be "reused" in other calls to folder(), so you should not modify the folders in puzzles. This way, folders created by puzzles won't interfere with one another, but multiple puzzles can still be created in the same directory. If you need a folder at a random location that won't have any other puzzles put in it you should explicitly create a folder under the one returned by random_shared_folder() with something like

>>> home.random_shared_folder().random_file().mkdir()

depth: Either an int or a (min, max) tuple. Specifies the depth under parent of the returned file. If a tuple is given a random depth in rand [min, max] will be used. create_new_chance: float in [0, 1]. The percentage chance that a new folder will be created even if shared folders are available. 0 means it will only choose existing folders unless there are none, 1 means it will only create new folders.

>>> home = File("/home/student")
>>> home.random_shared_folder()
File("/home/student/random/nested/folder")
>>> home.random_shared_folder()
File("/home/student/apple/banana")
>>> folder.mkdir(parents = True) # random_shared_folder() doesn't create the file on disk. Use mkdir() with parents = True to make the folder.
>>> # Make a random nested folder, but make the last folder not "shared" so we can safely rm it
>>> home.random_shared_folder().random_file().mkdir()
File("/home/student/orange/lime/tomato")
>>> home.random_shared_folder(create_new_chance = 0) # Will choose an existing "shared" folder
File("/home/student/orange/lime")
>>> File("/").random_shared_folder(depth = [5, 6]) # Create a folder 5 or 6 levels under root
File("/blueberry/lemon/watermellon/kiwi/strawberry")
def mark_shared(self):
150    def mark_shared(self):
151        """ Marks the a `File` as shared. `File` should be a directory, though it does not have to exist yet. """
152        shell_adventure.api.rand()._mark_shared(self)

Marks the a File as shared. File should be a directory, though it does not have to exist yet.

Inherited Members
pathlib.Path
cwd
samefile
iterdir
glob
rglob
absolute
resolve
stat
owner
group
open
read_bytes
read_text
write_bytes
write_text
touch
mkdir
lchmod
rmdir
lstat
rename
replace
exists
is_dir
is_file
is_mount
is_block_device
is_char_device
is_fifo
is_socket
expanduser
pathlib.PurePath
as_posix
as_uri
drive
root
anchor
name
suffix
suffixes
stem
with_name
with_stem
with_suffix
relative_to
is_relative_to
parts
joinpath
parent
parents
is_absolute
is_reserved
match
@contextmanager
def change_user(user: str, group: str = None):
 8@contextmanager
 9def change_user(user: str, group: str = None):
10    """
11    `change_user()` is a context manager which changes the effective user of the process to user (by name), and changes
12    it back when the context manager exits. Group will default to the group with the same name as user.
13
14    Note that `os.system()` and the like will run as root regardles of `change_user` since it starts a new process.
15
16    Example:
17    >>> with change_user("root"):
18    ...     File("root_file").create() # root will own this file
19    >>> File("student_file").create() # We are back to default user, student will own this file.
20    """
21    group = group if group else user
22    prev_user = os.geteuid()
23    prev_group = os.getegid()
24
25    uid = pwd.getpwnam(user).pw_uid
26    gid = grp.getgrnam(group).gr_gid
27    os.setegid(gid)
28    os.seteuid(uid)
29
30    try:
31        yield (uid, gid)
32    finally: # change back to original user.
33        os.setegid(prev_user)
34        os.seteuid(prev_group)

change_user() is a context manager which changes the effective user of the process to user (by name), and changes it back when the context manager exits. Group will default to the group with the same name as user.

Note that os.system() and the like will run as root regardles of change_user since it starts a new process.

Example:

>>> with change_user("root"):
...     File("root_file").create() # root will own this file
>>> File("student_file").create() # We are back to default user, student will own this file.
def user_exists(user: str) -> bool:
36def user_exists(user: str) -> bool:
37    """ Returns True if the user exists (by their username) """
38    try:
39        pwd.getpwnam(user)
40        return True
41    except KeyError:
42        return False

Returns True if the user exists (by their username)

class Permissions:
44class Permissions:
45    """
46    Plain old data structure that represents basic Linux permissions, with user, group, and others sections.
47    Currently doesn't include special permission bits such as the sticky bit.
48    """
49
50    user: PermissionsGroup
51    """ User permission bits """
52    group: PermissionsGroup
53    """ Group permission bits """
54    others: PermissionsGroup
55    """ Others permission bits """
56
57    def __init__(self, mode: int = None, *, user: str = "", group: str = "", others: str = ""):
58        """
59        Create a `Permissions` object. You can create one from an octal `int`, or by specifying user, group, and others
60        with strings containing some combination of "rwx".
61        Eg.
62        >>> Permissions(0o777)
63        >>> Permissions(user = "rw", group = "r", others = "")
64        """
65        if mode != None:
66            self.user = PermissionsGroup.from_int(mode >> 6)
67            self.group = PermissionsGroup.from_int(mode >> 3)
68            self.others = PermissionsGroup.from_int(mode >> 0)
69        else:
70            self.user = PermissionsGroup.from_str(user)
71            self.group = PermissionsGroup.from_str(group)
72            self.others = PermissionsGroup.from_str(others)
73
74    def __eq__(self, other) -> bool:
75        """ Compare `Permissions` objects. You can also compare a `Permissions` object with its octal representation. """
76        if isinstance(other, Permissions) or isinstance(other, int):
77            return int(self) == int(other)
78        else:
79            raise NotImplementedError("You can only compare Permissions with other Permissions or with ints")
80
81    def __int__(self):
82        """ Returns the integer representation of the permissions. Ie. 0o777 """
83        return (int(self.user) << 6) | (int(self.group) << 3) | (int(self.others))
84
85    def __str__(self):
86        """
87        Returns the string representation of the permissions, as ls -l would.
88        >>> str(Permissions(0o764))
89        "rwx-rw-r--"
90        """
91        return str(self.user) + str(self.group) + str(self.others)
92
93    def __repr__(self) -> str:
94        return f"Permissions({oct(int(self))})"

Plain old data structure that represents basic Linux permissions, with user, group, and others sections. Currently doesn't include special permission bits such as the sticky bit.

Permissions( mode: int = None, *, user: str = '', group: str = '', others: str = '')
57    def __init__(self, mode: int = None, *, user: str = "", group: str = "", others: str = ""):
58        """
59        Create a `Permissions` object. You can create one from an octal `int`, or by specifying user, group, and others
60        with strings containing some combination of "rwx".
61        Eg.
62        >>> Permissions(0o777)
63        >>> Permissions(user = "rw", group = "r", others = "")
64        """
65        if mode != None:
66            self.user = PermissionsGroup.from_int(mode >> 6)
67            self.group = PermissionsGroup.from_int(mode >> 3)
68            self.others = PermissionsGroup.from_int(mode >> 0)
69        else:
70            self.user = PermissionsGroup.from_str(user)
71            self.group = PermissionsGroup.from_str(group)
72            self.others = PermissionsGroup.from_str(others)

Create a Permissions object. You can create one from an octal int, or by specifying user, group, and others with strings containing some combination of "rwx". Eg.

>>> Permissions(0o777)
>>> Permissions(user = "rw", group = "r", others = "")

User permission bits

Group permission bits

Others permission bits

class LinkedPermissions(shell_adventure.api.Permissions):
 96class LinkedPermissions(Permissions):
 97    """
 98    `LinkedPermissions` is linked to an actual file. Modifying the `LinkedPermissions` object will modify the permissions of
 99    the actual file on disk.
100
101    You can access and modify `File` permissions via the `File.permissions` property, which offers a more convenient API to manipulate
102    UNIX file permissions than `os` and `stat` modules.
103    """
104
105    def __init__(self, file: file.File):
106        self._file = file
107        self.user = LinkedPermissionsGroup(file, 6)
108        self.group = LinkedPermissionsGroup(file, 3)
109        self.others = LinkedPermissionsGroup(file, 0)

LinkedPermissions is linked to an actual file. Modifying the LinkedPermissions object will modify the permissions of the actual file on disk.

You can access and modify File permissions via the File.permissions property, which offers a more convenient API to manipulate UNIX file permissions than os and stat modules.

LinkedPermissions(file: shell_adventure.api.File)
105    def __init__(self, file: file.File):
106        self._file = file
107        self.user = LinkedPermissionsGroup(file, 6)
108        self.group = LinkedPermissionsGroup(file, 3)
109        self.others = LinkedPermissionsGroup(file, 0)

Create a Permissions object. You can create one from an octal int, or by specifying user, group, and others with strings containing some combination of "rwx". Eg.

>>> Permissions(0o777)
>>> Permissions(user = "rw", group = "r", others = "")
user

User permission bits

group

Group permission bits

others

Others permission bits

class PermissionsGroup:
112class PermissionsGroup:
113    """
114    Represents the read, write, and execute bits for either "user", "group", or "other"
115    """
116
117    read: bool
118    """ The read permission bit """
119    write: bool
120    """ The write permission bit """
121    execute: bool
122    """ The execute permission bit """
123
124    def __init__(self, read: bool, write: bool, execute: bool):
125        self.read = read
126        self.write = write
127        self.execute = execute
128
129    @staticmethod
130    def from_str(mode: str) -> PermissionsGroup:
131        """ Takes a string in "rwx" format and returns a `PermissionsGroup` """
132        mode_set = set(mode)
133        if len(mode_set) == len(mode) and mode_set.issubset({'r', 'w', 'x'}): # only contains rwx, and only one of each
134            read = "r" in mode_set
135            write = "w" in mode_set
136            execute = "x" in mode_set
137        else:
138            raise ValueError(f'Invalid string "{mode}" for permissions, must only contain "r", "w", and/or "x"')
139
140        return PermissionsGroup(read, write, execute)
141
142    @staticmethod
143    def from_int(mode: int) -> PermissionsGroup:
144        """ Takes an int and assigns lowermost 3 bits to read/write/execute. Returns a `PermissionGroup` """
145        read = bool(mode & 0o4)
146        write = bool(mode & 0o2)
147        execute = bool(mode & 0o1)
148        return PermissionsGroup(read, write, execute)
149
150    def __eq__(self, other) -> bool:
151        """ Compare `PermissionGroup` objects. You can also compare a `PermissionGroup` with its octal representation. """
152        if isinstance(other, PermissionsGroup) or isinstance(other, int):
153            return int(self) == int(other)
154        else:
155            raise NotImplementedError("You can only compare Permissions with other Permissions or with ints")
156
157    def __int__(self) -> int:
158        """ Convert the `PermissionGroup` to an int by treating read/write/execute as bits. """
159        return (self.read << 2) | (self.write << 1) | (self.execute)
160
161    def __str__(self) -> str:
162        """ Convert the `PermissionGroup` to an str in "rwx" format, eg. "rw-" or "r--" """
163        return ("r" if self.read else "-") + ("w" if self.write else "-") + ("x" if self.execute else "-")

Represents the read, write, and execute bits for either "user", "group", or "other"

PermissionsGroup(read: bool, write: bool, execute: bool)
124    def __init__(self, read: bool, write: bool, execute: bool):
125        self.read = read
126        self.write = write
127        self.execute = execute
read: bool

The read permission bit

write: bool

The write permission bit

execute: bool

The execute permission bit

@staticmethod
def from_str(mode: str) -> shell_adventure.api.PermissionsGroup:
129    @staticmethod
130    def from_str(mode: str) -> PermissionsGroup:
131        """ Takes a string in "rwx" format and returns a `PermissionsGroup` """
132        mode_set = set(mode)
133        if len(mode_set) == len(mode) and mode_set.issubset({'r', 'w', 'x'}): # only contains rwx, and only one of each
134            read = "r" in mode_set
135            write = "w" in mode_set
136            execute = "x" in mode_set
137        else:
138            raise ValueError(f'Invalid string "{mode}" for permissions, must only contain "r", "w", and/or "x"')
139
140        return PermissionsGroup(read, write, execute)

Takes a string in "rwx" format and returns a PermissionsGroup

@staticmethod
def from_int(mode: int) -> shell_adventure.api.PermissionsGroup:
142    @staticmethod
143    def from_int(mode: int) -> PermissionsGroup:
144        """ Takes an int and assigns lowermost 3 bits to read/write/execute. Returns a `PermissionGroup` """
145        read = bool(mode & 0o4)
146        write = bool(mode & 0o2)
147        execute = bool(mode & 0o1)
148        return PermissionsGroup(read, write, execute)

Takes an int and assigns lowermost 3 bits to read/write/execute. Returns a PermissionGroup

class LinkedPermissionsGroup(shell_adventure.api.PermissionsGroup):
165class LinkedPermissionsGroup(PermissionsGroup):
166    """
167    Represents the read, write, and execute bits for either "user", "group", or "other". Is linked to an actual file
168    on disk. Modifying the `LinkedPermissionsGroup` will modify the file's permissions.
169    """
170
171    def _get_bit(self, mask: int) -> bool:
172        """ Gets the read (0o4), write (0o2), or execute (0o1) bit. """
173        mode = stat.S_IMODE(self._file.stat().st_mode)
174        return bool((mode >> self._bit_shift) & mask)
175
176    def _set_bit(self, mask: int, val: bool):
177        """ Gets the read (0o4), write (0o2), or execute (0o1) bit. """
178        mode = stat.S_IMODE(self._file.stat().st_mode)
179        if val: # set bit
180            mode = mode | (mask << self._bit_shift)
181        else: # clear bit at mask
182            mode = mode & ~(mask << self._bit_shift)
183        self._file.chmod(mode)
184
185    # MyPy fusses at overriding field with property. See https://github.com/python/mypy/issues/4125
186    read = property(lambda self: self._get_bit(0o4), lambda self, val: self._set_bit(0o4, val)) #type: ignore
187    """ The read permission bit """
188    write = property(lambda self: self._get_bit(0o2), lambda self, val: self._set_bit(0o2, val)) #type: ignore
189    """ The write permission bit """
190    execute = property(lambda self: self._get_bit(0o1), lambda self, val: self._set_bit(0o1, val)) #type: ignore
191    """ The execute permission bit """
192
193    def __init__(self, file: file.File, bit_shift: int):
194        """
195        bit_shift is the number of bits to shift to the right to get the rwx bits in the lowest position
196        user: 6, group: 3, others: 0
197        """
198        self._file = file
199        self._bit_shift = bit_shift

Represents the read, write, and execute bits for either "user", "group", or "other". Is linked to an actual file on disk. Modifying the LinkedPermissionsGroup will modify the file's permissions.

LinkedPermissionsGroup(file: shell_adventure.api.File, bit_shift: int)
193    def __init__(self, file: file.File, bit_shift: int):
194        """
195        bit_shift is the number of bits to shift to the right to get the rwx bits in the lowest position
196        user: 6, group: 3, others: 0
197        """
198        self._file = file
199        self._bit_shift = bit_shift

bit_shift is the number of bits to shift to the right to get the rwx bits in the lowest position user: 6, group: 3, others: 0

read

The read permission bit

write

The write permission bit

execute

The execute permission bit

Inherited Members
PermissionsGroup
from_str
from_int
class RandomHelper:
  8class RandomHelper:
  9    """
 10    RandomHelper is a class that generates random names, contents, and file paths.
 11    You can access an instance of `RandomHelper` in puzzle modules via the `shell_adventure.api.rand()` function.
 12    """
 13
 14    def __init__(self, name_dictionary: str, content_sources: List[str] = []):
 15        """
 16        Creates a `RandomHelper`.
 17        name_dictionary is a string containing words, each on its own line.
 18        """
 19        names = set(name_dictionary.splitlines())
 20        names.discard("") # remove empty entries
 21
 22        # A list of strings that will be used to generate random names.
 23        self._name_dictionary: List[str] = list(names) # choice() only works on list.
 24
 25        def clean_paragraph(paragraph: str) -> str:
 26            # paragraph = re.sub(r"\s*\n\s*", "", paragraph) # unwrap
 27            paragraph = paragraph.rstrip()
 28            paragraph = "\n".join([l for l in paragraph.split("\n") if l.strip() != ""]) # remove blank lines.
 29            return paragraph
 30
 31        # The sources that will be used to generate random content. List of files, each file is a list of paragraphs.
 32        self._content_sources: List[List[str]] = []
 33        for source in content_sources:
 34            paragraphs = re.split(r"\s*\n\s*\n", source) # split into paragraphs
 35            paragraphs = [clean_paragraph(para) for para in paragraphs if para.strip() != ""]
 36            # # split paragraphs into lists of sentences
 37            # para_sentences = [re.findall(r".*?\.\s+", para, flags = re.DOTALL) for para in paragraphs]
 38            self._content_sources.append(paragraphs)
 39
 40        # A set of shared folders. random._folder() can use existing folders if they are shared.
 41        self._shared_folders: Set[File] = set()
 42
 43
 44    def name(self) -> str:
 45        """ Returns a random word that can be used as a file name. The name is taken from the name_dictionary. """
 46        if len(self._name_dictionary) == 0:
 47            raise RandomHelperException("Out of unique names.")
 48        choice = random.choice(self._name_dictionary)
 49        self._name_dictionary.remove(choice) # We can't choose the same name again.
 50        return choice
 51
 52    def paragraphs(self, count: Union[int, Tuple[int, int]] = (1, 3)) -> str:
 53        """
 54        Return a random sequence of paragraphs from the content_sources.
 55        If no content sources are provided or there isn't enough content to provide the size it will default to a lorem ipsum generator.
 56
 57        paramaters:
 58            count: Either an int or a (min, max) tuple. If a tuple is given, will return a random number of paragraphs
 59                   in the range, inclusive.
 60        """
 61        if isinstance(count, tuple): count = random.randint(count[0], count[1])
 62        # filter sources too small for chosen size
 63        sources = [source for source in self._content_sources if count <= len(source)]
 64
 65        if sources: # If we have source
 66            # Weight the files so all paragraphs are equally likely regardless of source
 67            weights = [len(source) - count + 1 for source in sources]
 68            [source] = random.choices(sources, k = 1, weights = weights)
 69
 70            index = random.randint(0, len(source) - count)
 71            return "\n\n".join(source[index:index+count]) + "\n"
 72        else:
 73            return lorem.get_paragraph(count = count, sep = "\n\n") + "\n"
 74
 75    # === Files ===
 76
 77    def _file(self, parent: PathLike, ext = None) -> File:
 78        """ Creates a `File` with a random name. You should use `File.rand_file()` instead of calling this method directly. """
 79        parent = File(parent).resolve()
 80        ext = "" if ext == None else f".{ext}"
 81        new_file = File("/") # garanteed to exist.
 82
 83        # check if file already exists. This can happen if a hardcoded name happens to match the random one.
 84        while new_file.exists():
 85            new_file = parent / f"{self.name()}{ext}"
 86
 87        return new_file
 88
 89    def _folder(self, parent: PathLike, depth: Union[int, Tuple[int, int]] = (1, 3), create_new_chance: float = 0.5) -> File:
 90        """ Makes a `File` to a random folder under parent. You should use `File.random_shared_folder()` instead of calling this method directly. """
 91
 92        if isinstance(depth, tuple): depth = random.randint(depth[0], depth[1])
 93        folder = File(parent).resolve()
 94
 95        for i in range(depth):
 96            if folder.exists():
 97                choices = [d for d in folder.iterdir() if d.is_dir() and d in self._shared_folders]
 98            else:
 99                choices = []
100            # Create new shared folder if no choices or random chance succeeds.
101            # Add check for 1 since uniform() is an inclusive range
102            roll = random.uniform(0, 1)
103            if len(choices) == 0 or create_new_chance == 1 or roll < create_new_chance:
104                folder = self._file(folder) # create random file under folder
105                self._mark_shared(folder)
106            else:
107                folder = File(random.choice(choices))
108
109        return folder
110
111    def _mark_shared(self, folder: PathLike):
112        """ Marks a folder as shared. You should use `File.mark_shared()` instead of calling this method directly. """
113        folder = File(folder)
114        if folder.exists() and not folder.is_dir():
115            raise RandomHelperException(f"Can't mark {folder} as shared, it already exists as a f. Can only mark folders as shared.")
116        self._shared_folders.add(folder.resolve())

RandomHelper is a class that generates random names, contents, and file paths. You can access an instance of RandomHelper in puzzle modules via the shell_adventure.api.rand() function.

RandomHelper(name_dictionary: str, content_sources: List[str] = [])
14    def __init__(self, name_dictionary: str, content_sources: List[str] = []):
15        """
16        Creates a `RandomHelper`.
17        name_dictionary is a string containing words, each on its own line.
18        """
19        names = set(name_dictionary.splitlines())
20        names.discard("") # remove empty entries
21
22        # A list of strings that will be used to generate random names.
23        self._name_dictionary: List[str] = list(names) # choice() only works on list.
24
25        def clean_paragraph(paragraph: str) -> str:
26            # paragraph = re.sub(r"\s*\n\s*", "", paragraph) # unwrap
27            paragraph = paragraph.rstrip()
28            paragraph = "\n".join([l for l in paragraph.split("\n") if l.strip() != ""]) # remove blank lines.
29            return paragraph
30
31        # The sources that will be used to generate random content. List of files, each file is a list of paragraphs.
32        self._content_sources: List[List[str]] = []
33        for source in content_sources:
34            paragraphs = re.split(r"\s*\n\s*\n", source) # split into paragraphs
35            paragraphs = [clean_paragraph(para) for para in paragraphs if para.strip() != ""]
36            # # split paragraphs into lists of sentences
37            # para_sentences = [re.findall(r".*?\.\s+", para, flags = re.DOTALL) for para in paragraphs]
38            self._content_sources.append(paragraphs)
39
40        # A set of shared folders. random._folder() can use existing folders if they are shared.
41        self._shared_folders: Set[File] = set()

Creates a RandomHelper. name_dictionary is a string containing words, each on its own line.

def name(self) -> str:
44    def name(self) -> str:
45        """ Returns a random word that can be used as a file name. The name is taken from the name_dictionary. """
46        if len(self._name_dictionary) == 0:
47            raise RandomHelperException("Out of unique names.")
48        choice = random.choice(self._name_dictionary)
49        self._name_dictionary.remove(choice) # We can't choose the same name again.
50        return choice

Returns a random word that can be used as a file name. The name is taken from the name_dictionary.

def paragraphs(self, count: Union[int, Tuple[int, int]] = (1, 3)) -> str:
52    def paragraphs(self, count: Union[int, Tuple[int, int]] = (1, 3)) -> str:
53        """
54        Return a random sequence of paragraphs from the content_sources.
55        If no content sources are provided or there isn't enough content to provide the size it will default to a lorem ipsum generator.
56
57        paramaters:
58            count: Either an int or a (min, max) tuple. If a tuple is given, will return a random number of paragraphs
59                   in the range, inclusive.
60        """
61        if isinstance(count, tuple): count = random.randint(count[0], count[1])
62        # filter sources too small for chosen size
63        sources = [source for source in self._content_sources if count <= len(source)]
64
65        if sources: # If we have source
66            # Weight the files so all paragraphs are equally likely regardless of source
67            weights = [len(source) - count + 1 for source in sources]
68            [source] = random.choices(sources, k = 1, weights = weights)
69
70            index = random.randint(0, len(source) - count)
71            return "\n\n".join(source[index:index+count]) + "\n"
72        else:
73            return lorem.get_paragraph(count = count, sep = "\n\n") + "\n"

Return a random sequence of paragraphs from the content_sources. If no content sources are provided or there isn't enough content to provide the size it will default to a lorem ipsum generator.

paramaters: count: Either an int or a (min, max) tuple. If a tuple is given, will return a random number of paragraphs in the range, inclusive.

class RandomHelperException(builtins.Exception):
118class RandomHelperException(Exception):
119    """ Error for when the `RandomHelper` fails. """

Error for when the RandomHelper fails.

Inherited Members
builtins.Exception
Exception
builtins.BaseException
with_traceback
args
def rand() -> shell_adventure.api.RandomHelper:
31def rand() -> RandomHelper:
32    """
33    Returns the `RandomHelper` which should be used when creating random files and folders.
34    """
35    if not _rand:
36        raise RandomHelperException("You can only use randomization in Puzzle templates, not autograders")
37    return _rand

Returns the RandomHelper which should be used when creating random files and folders.