sprawdzai-cli 0.2.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.
@@ -0,0 +1,54 @@
1
+ import typer
2
+ from typing import Optional
3
+ from pathlib import Path
4
+ from .utils import Datasets
5
+ from ...utils.error_handler import handle_response_error
6
+
7
+ def rename(
8
+ name_or_id: str = typer.Argument(
9
+ ...,
10
+ help=(
11
+ "Name or id of dataset to be renamed"
12
+ )
13
+ ),
14
+ new_name: str = typer.Argument(
15
+ ...,
16
+ help=(
17
+ "New name of the dataset"
18
+ )
19
+ ),
20
+ path: Path = typer.Option(
21
+ Path.cwd(),
22
+ "--location",
23
+ "-l",
24
+ help="Path where the task is located (defaults to current directory)."
25
+ ),
26
+ backend: Optional[str] = typer.Option(
27
+ None,
28
+ "--backend",
29
+ "-b",
30
+ help=(
31
+ "Use a different backend: "
32
+ "local (l, l1, l2, ...), "
33
+ "remote (r, r1, r2, ...) "
34
+ "or full URL (https://example.com)"
35
+ )
36
+ )
37
+ ):
38
+ """
39
+ Rename a dataset.
40
+ """
41
+
42
+ dss = Datasets(path, backend)
43
+ ds = dss.find(name_or_id)
44
+
45
+ data = {"type_": "dataset", "title": f"{dss.task['slug']} - {new_name}"}
46
+ resp = dss.client.patch(f'/files/{ds.id}', data=data)
47
+
48
+ if resp.status_code not in [200, 201]:
49
+ handle_response_error(resp)
50
+
51
+ if ds.local:
52
+ ds.path.rename(ds.path.with_name(new_name))
53
+
54
+ Datasets(path, backend).show_warnings()
@@ -0,0 +1,161 @@
1
+ import typer
2
+ from typing import Optional
3
+ from pathlib import Path
4
+ from .utils import Datasets, Dataset
5
+ import zipfile
6
+ from ...utils.error_handler import write_error, warn, handle_response_error
7
+ import tempfile
8
+ from tqdm.auto import tqdm
9
+
10
+ def format_mem(sz):
11
+ sz = int(sz)
12
+ if sz <= 10240:
13
+ return f'{sz}B'
14
+ sz = sz / 1024
15
+ if sz <= 10240:
16
+ return f'{sz:.2f}KB'
17
+ sz = sz / 1024
18
+ if sz <= 10240:
19
+ return f'{sz:.2f}MB'
20
+ sz = sz / 1024
21
+ return f'{sz:.2f}GB'
22
+
23
+ def send(
24
+ name_or_id: Optional[str] = typer.Argument(
25
+ None,
26
+ help=(
27
+ "Send dataset with specific name or id. "
28
+ "If this argument is not specified, it will send all datasets you have locally."
29
+ )
30
+ ),
31
+ no_zip: bool = typer.Option(
32
+ False,
33
+ "--no-zip",
34
+ "-n",
35
+ help=(
36
+ "If there is just one file in the dataset, with this flag you can make a dataset to be this one file "
37
+ "instead of being a zip containing a single file. "
38
+ "It will throw an error if there is not exactly one file in a dataset folder. "
39
+ )
40
+ ),
41
+ detach: bool = typer.Option(
42
+ False,
43
+ "--detach",
44
+ "-d",
45
+ help=(
46
+ "Instead of writing to the current dataset on backend, "
47
+ "it will create a new dataset and write to it, leaving the current dataset unchanged on backend. "
48
+ "Please do not use unless having a specific reason, "
49
+ "because it can make mess in datasets and lose a lot of disk space since it will create a new dataset during each call with this flag."
50
+ )
51
+ ),
52
+ zip_method: str = typer.Option(
53
+ "deflated",
54
+ "--zip-method",
55
+ "-z",
56
+ help="Compression method to use when sending datasets (`stored` - no compression, `deflated`, `bz2`, `lzma`)"
57
+ ),
58
+ path: Path = typer.Option(
59
+ Path.cwd(),
60
+ "--location",
61
+ "-l",
62
+ help="Path where the task is located (defaults to current directory)."
63
+ ),
64
+ backend: Optional[str] = typer.Option(
65
+ None,
66
+ "--backend",
67
+ "-b",
68
+ help=(
69
+ "Use a different backend: "
70
+ "local (l, l1, l2, ...), "
71
+ "remote (r, r1, r2, ...) "
72
+ "or full URL (https://example.com)"
73
+ )
74
+ )
75
+ ):
76
+ """
77
+ Send a dataset or all datasets you have pulled locally.
78
+ """
79
+
80
+ dss = Datasets(path, backend)
81
+
82
+ zip_method_dict = {
83
+ 'stored': zipfile.ZIP_STORED,
84
+ 'deflated': zipfile.ZIP_DEFLATED,
85
+ 'bz2': zipfile.ZIP_BZIP2,
86
+ 'lzma': zipfile.ZIP_LZMA
87
+ }
88
+ if zip_method not in zip_method_dict:
89
+ write_error(f'Got unexpected zip compression method {zip_method}, expected one of: {", ".join(list(zip_method_dict.keys()))}')
90
+ zip_method_int = zip_method_dict[zip_method]
91
+
92
+ tsend: list[Dataset] = []
93
+ if name_or_id is None:
94
+ if len(dss.dss) == 0:
95
+ warn('There are no datasets linked to this task, so no dataset can be sent.')
96
+ else:
97
+ for ds in dss.dss.values():
98
+ if ds.local:
99
+ tsend.append(ds)
100
+ if len(dss.dss) == 0:
101
+ warn('You do not have any datasets pulled locally, so no dataset can be sent.')
102
+ else:
103
+ tsend = [dss.find(name_or_id)]
104
+
105
+ if len(tsend) >= 2:
106
+ typer.echo(f'Sending {len(tsend)} datasets...')
107
+
108
+ for ds in tsend:
109
+ typer.echo(f'{" - " if len(tsend) >= 2 else ""}Sending {str(ds)}...')
110
+
111
+ dir_path = path / ds.name
112
+ filename = f'{ds.name}.zip'
113
+ files = list(dir_path.rglob("*"))
114
+
115
+ if no_zip:
116
+ if len(files) == 0:
117
+ write_error(f'Error: --no-zip flag was passed, but {str(ds)} dataset has zero files (expected exactly one)')
118
+ if len(files) > 1:
119
+ write_error(f'Error: --no-zip flag was passed, but {str(ds)} dataset has more than one file (expected exactly one)')
120
+ file = files[0]
121
+ filename = file.name
122
+ to_send = file
123
+ else:
124
+ tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".zip")
125
+ to_send = Path(tmp.name)
126
+ with zipfile.ZipFile(to_send, 'w', zip_method_int) as zipf:
127
+ if len(files) == 0:
128
+ typer.echo('Dataset is empty.')
129
+ else:
130
+ for file_path in tqdm(files, desc=f"Zipping"):
131
+ zipf.write(file_path, arcname=file_path.relative_to(dir_path))
132
+ tmp.close()
133
+
134
+ try:
135
+ size = to_send.stat().st_size
136
+ typer.echo(f'Uploading ({format_mem(size)})...')
137
+ with open(to_send, "rb") as f:
138
+ files_req = {"file": (filename, f)}
139
+
140
+ if detach:
141
+ data = {"type_": "dataset", "title": ds.orig_name}
142
+ resp = dss.client.post('/files', data=data, files=files_req)
143
+ else:
144
+ data = {"type_": "dataset", "title": ds.orig_name}
145
+ resp = dss.client.patch(f'/files/{ds.id}', data=data, files=files_req)
146
+
147
+ if resp.status_code not in [200, 201]:
148
+ typer.echo(f'Error while sending {str(ds)}:')
149
+ handle_response_error(resp)
150
+
151
+ res = resp.json()
152
+
153
+ if detach:
154
+ ds.id = res['id']
155
+ typer.echo(f'Detached to id {ds.id}')
156
+ dss.update(dss.dss.values())
157
+ finally:
158
+ if to_send.exists() and not no_zip:
159
+ to_send.unlink()
160
+
161
+ Datasets(path, backend).show_warnings()
@@ -0,0 +1,132 @@
1
+ import typer
2
+ from typing import Optional
3
+ from pathlib import Path
4
+ from .utils import Datasets
5
+ from ...utils.error_handler import write_error
6
+
7
+ def unlink(
8
+ name_or_id: str = typer.Argument(
9
+ ...,
10
+ help=(
11
+ "Name or id of dataset to be unlinked"
12
+ )
13
+ ),
14
+ valid: bool = typer.Option(
15
+ False,
16
+ "--valid",
17
+ "-v",
18
+ ),
19
+ test: bool = typer.Option(
20
+ False,
21
+ "--test",
22
+ "-t",
23
+ ),
24
+ nb: bool = typer.Option(
25
+ False,
26
+ "--notebook",
27
+ "-n",
28
+ ),
29
+ chk: bool = typer.Option(
30
+ False,
31
+ "--checker",
32
+ "-c",
33
+ ),
34
+ path: Path = typer.Option(
35
+ Path.cwd(),
36
+ "--location",
37
+ "-l",
38
+ help="Path where the task is located (defaults to current directory)."
39
+ ),
40
+ backend: Optional[str] = typer.Option(
41
+ None,
42
+ "--backend",
43
+ "-b",
44
+ help=(
45
+ "Use a different backend: "
46
+ "local (l, l1, l2, ...), "
47
+ "remote (r, r1, r2, ...) "
48
+ "or full URL (https://example.com)"
49
+ )
50
+ )
51
+ ):
52
+ """
53
+ Unlink a dataset to from a task or a stage of this task.
54
+
55
+ sai ds unlink name/id -> completely unlink the dataset from this task
56
+ sai ds unlink name/id --notebook/--checker/--valid/--test or -n/-c/-v/-t -> unlink dataset from 2 corresponding stages
57
+
58
+ When passing two flags it will unlink dataset from this one stage, e.g.:
59
+ sai ds unlink name/id --notebook --test -> unlink dataset from test notebook stage
60
+ """
61
+
62
+ dss = Datasets(path, backend)
63
+
64
+ ds = dss.find(name_or_id)
65
+
66
+ def unlinkk(s1: str, s2: str):
67
+ if not ds.stages[s1][s2]:
68
+ typer.secho(f'Already unlinked from {s2} {"notebook" if s1 == "nb" else "checker"}.', fg='yellow')
69
+ else:
70
+ typer.secho(f'Unlinked from {s2} {"notebook" if s1 == "nb" else "checker"}!', fg='green')
71
+ ds.stages[s1][s2] = False
72
+
73
+ if nb and chk:
74
+ write_error('Expected one or none of flags --notebook and --checker, but got both.')
75
+ if valid and test:
76
+ write_error('Expected one or none of flags --valid and --test, but got both.')
77
+
78
+ flags = []
79
+ if nb:
80
+ flags.append('nb')
81
+ if chk:
82
+ flags.append('chk')
83
+ if valid:
84
+ flags.append('valid')
85
+ if test:
86
+ flags.append('test')
87
+
88
+ if len(flags) == 0:
89
+ unlinkk('nb', 'valid')
90
+ unlinkk('nb', 'test')
91
+ unlinkk('chk', 'valid')
92
+ unlinkk('chk', 'test')
93
+ elif len(flags) == 1:
94
+ if flags[0] in ['nb', 'chk']:
95
+ unlinkk(flags[0], 'valid')
96
+ unlinkk(flags[0], 'test')
97
+ else:
98
+ unlinkk('nb', flags[0])
99
+ unlinkk('chk', flags[0])
100
+ else:
101
+ if nb and valid:
102
+ unlinkk('nb', 'valid')
103
+ elif nb and test:
104
+ unlinkk('nb', 'test')
105
+ elif chk and valid:
106
+ unlinkk('chk', 'valid')
107
+ elif chk and test:
108
+ unlinkk('chk', 'test')
109
+
110
+ any_link = False
111
+ for s1 in ['nb', 'chk']:
112
+ for s2 in ['valid', 'test']:
113
+ if ds.stages[s1][s2]:
114
+ any_link = True
115
+
116
+ if not any_link:
117
+ typer.secho('Warning:', fg='red')
118
+ typer.echo('This operation will leed the dataset to be completely unlinked from the task.')
119
+ typer.echo('Sai does not track datasets that are not linked to any stage of the task. After running this operation, the dataset folder will be kept as is, but sai will treat it as any normal directory and not a dataset.')
120
+ typer.echo(f'You will still be able to relink the dataset just like any normal external dataset with `sai link {ds.id}`')
121
+ res = typer.prompt(f'Do you wish to continue? [y]es/[n]o')
122
+ if type(res) != str or (res.lower() not in ['y', 'yes']):
123
+ typer.secho("Operation aborted", fg='red')
124
+ return
125
+
126
+ dss.update(dss.dss.values())
127
+
128
+ if not any_link:
129
+ typer.secho(f'./{ds.name} folder is no longer tracked and considered as a dataset by sai. You can remove it if you want.', fg='green')
130
+
131
+ Datasets(path, backend).show_warnings()
132
+
@@ -0,0 +1,137 @@
1
+ from ...state import resolve_backend
2
+ from typing import Optional
3
+ from ...utils.error_handler import warn, handle_response_error, write_error
4
+ from ...utils.client import APIClient
5
+ from ..sync import read_dotsai
6
+ from pathlib import Path
7
+ import json
8
+
9
+ class Dataset:
10
+ def __init__(self, ds, path: Path):
11
+ self.id: str = ds['id']
12
+ self.author: str = ds['author']
13
+ self.url: str = ds['direct_url']
14
+ self.filename: str = ds['name']
15
+ self.orig_name: str = ds['title']
16
+ self.hash: str = ds['hash_md5']
17
+
18
+ self.stages = {
19
+ 'nb': {'valid': False, 'test': False},
20
+ 'chk': {'valid': False, 'test': False}
21
+ }
22
+ self.apply_stage(ds['type_'])
23
+
24
+ self.name = self.orig_name.split(" - ", 1)[1] if " - " in self.orig_name else self.orig_name
25
+ self.path = path / self.name
26
+ if self.path.is_file():
27
+ write_error(f'Dataset ./{self.name} ({self.url}) exists locally, but is a file and not a folder! Please do something with the file, or rename the dataset manually on the website.')
28
+ self.local = self.path.exists()
29
+
30
+ def apply_stage(self, type_: str):
31
+ if type_ == 'notebook_nonfinal':
32
+ self.stages['nb']['valid'] = True
33
+ if type_ == 'notebook_final':
34
+ self.stages['nb']['test'] = True
35
+ if type_ == 'checker_nonfinal':
36
+ self.stages['chk']['valid'] = True
37
+ if type_ == 'checker_final':
38
+ self.stages['chk']['test'] = True
39
+
40
+ def __str__(self):
41
+ return f'{self.name} ({self.url})'
42
+
43
+ class Datasets:
44
+ def __init__(self, path: Path, backend: Optional[str]):
45
+ self.backend = resolve_backend(backend)
46
+ self.client = APIClient(self.backend)
47
+ self.path = path
48
+
49
+ self.dotsai = read_dotsai(path)
50
+ self.id = self.dotsai['id']
51
+
52
+ task_resp = self.client.get(f'/tasks/{self.id}')
53
+ if task_resp.status_code != 200:
54
+ handle_response_error(task_resp)
55
+ self.task = task_resp.json()
56
+
57
+ self.dss: dict[str, Dataset] = {}
58
+ for ds_dict in self.task['datasets']:
59
+ ds = Dataset(ds_dict, path)
60
+ if ds.name in self.dss:
61
+ if ds.id == self.dss[ds.name].id:
62
+ self.dss[ds.name].apply_stage(ds_dict['type_'])
63
+ else:
64
+ write_error(f'Datasets {ds.id} and {self.dss[ds.name].id} were assigned the same name by sai ({ds.name}). Sai does not support two datasets having the same name. Please edit the names manually on the website.')
65
+ else:
66
+ self.dss[ds.name] = ds
67
+
68
+ self.missing = [ds for ds in self.dss.values() if not ds.local]
69
+
70
+ def show_warnings(self):
71
+ if len(self.missing) == 1:
72
+ warn(f'Found a missing dataset {str(self.missing[0])} which is linked to this task, but is not pulled locally.')
73
+ warn(f'Use `sai ds pull --missing` to pull this dataset.')
74
+ elif len(self.missing) > 1:
75
+ warn(f'Found {len(self.missing)} missing datasets which are linked to this task, but not pulled locally:')
76
+ for ds in self.missing:
77
+ warn(str(ds))
78
+ warn(f'Use `sai ds pull --missing` to pull all these datasets, or `sai ds pull name/id` to pull one of them.')
79
+
80
+ def find(self, name_or_id: str, raise_not_found=True):
81
+ if name_or_id in self.dss:
82
+ return self.dss[name_or_id]
83
+
84
+ for ds in self.dss.values():
85
+ if ds.id == name_or_id:
86
+ return ds
87
+
88
+ if raise_not_found:
89
+ write_error('Could not find dataset with specified name or id.')
90
+ return None
91
+
92
+ def update(self, dss: list[Dataset]):
93
+ task_resp = self.client.get(f'/tasks/{self.id}')
94
+ if task_resp.status_code != 200:
95
+ handle_response_error(task_resp)
96
+ task = task_resp.json()
97
+
98
+ new_dss = []
99
+ for ds in dss:
100
+ for s1, n1 in [('nb', 'notebook'), ('chk', 'checker')]:
101
+ for s2, n2 in [('valid', 'nonfinal'), ('test', 'final')]:
102
+ if ds.stages[s1][s2]:
103
+ new_dss.append({
104
+ 'file_id': ds.id,
105
+ 'type': f'{n1}_{n2}'
106
+ })
107
+
108
+ resp = self.client.put(f'/tasks/{self.id}', data={
109
+ 'title': task['title'],
110
+ 'short_description': task['short_description'],
111
+ 'slug': task['slug'],
112
+ 'tags_ids': [tag['id'] for tag in task['tags']],
113
+ 'difficulty': task['difficulty'],
114
+
115
+ 'notebook_config': json.dumps(task['notebook_config']),
116
+ 'checker_config': json.dumps(task['checker_config']),
117
+
118
+ 'compute': task['compute'],
119
+ 'submission_files': json.dumps(task['submission_files']),
120
+
121
+ 'nonfinal_submissions_per_day': task['nonfinal_submissions_per_day'],
122
+ 'nonfinal_notes_char_limit': task['nonfinal_notes_char_limit'],
123
+ 'nonfinal_stdout_char_limit': task['nonfinal_stdout_char_limit'],
124
+ 'nonfinal_stderr_char_limit': task['nonfinal_stderr_char_limit'],
125
+ 'final_submissions_per_day': task['final_submissions_per_day'],
126
+ 'final_notes_char_limit': task['final_notes_char_limit'],
127
+ 'final_stdout_char_limit': task['final_stdout_char_limit'],
128
+ 'final_stderr_char_limit': task['final_stderr_char_limit'],
129
+
130
+ 'display_ranking': task['display_ranking'],
131
+ 'score_mode': task['score_mode'],
132
+ 'baseline_score': task['baseline_score'],
133
+
134
+ 'datasets': json.dumps(new_dss)
135
+ })
136
+ if resp.status_code != 200:
137
+ handle_response_error(resp)
sai/commands/login.py ADDED
@@ -0,0 +1,44 @@
1
+ import typer
2
+ from ..state import resolve_backend, set_token
3
+ from ..utils.error_handler import handle_response_error
4
+ import requests
5
+ from typing import Optional
6
+ from ..utils.client import APIClient
7
+
8
+ def login(
9
+ backend: Optional[str] = typer.Option(
10
+ None,
11
+ "--backend",
12
+ "-b",
13
+ help=(
14
+ "Use a different backend: "
15
+ "local (l, l1, l2, ...), "
16
+ "remote (r, r1, r2, ...) "
17
+ "or full URL (https://example.com)"
18
+ )
19
+ )
20
+ ):
21
+ """
22
+ Login on the current backend.
23
+
24
+ Note that backends like https://sprawdzai.org, remote, r, r1, r2 (which all point to the same address)
25
+ will store separate authentication tokens.
26
+ This lets you login on different accounts on r1, r2, ... or l1, l2, ... for convenience.
27
+
28
+ Default backend is remote.
29
+ """
30
+ backend = resolve_backend(backend)
31
+ client = APIClient(backend or None)
32
+
33
+ username = typer.prompt("Username")
34
+ password = typer.prompt("Password", hide_input=True)
35
+
36
+ url = f"{client.base_url}/auth/login"
37
+ resp = requests.post(url, json={"username": username, "password": password})
38
+
39
+ if resp.status_code == 200:
40
+ data = resp.json()
41
+ set_token(backend, data["access_token"], data["expires_in"])
42
+ typer.secho("Login successful.", fg=typer.colors.GREEN)
43
+ else:
44
+ handle_response_error(resp)
sai/commands/logout.py ADDED
@@ -0,0 +1,37 @@
1
+ import typer
2
+ from ..state import resolve_backend, clear_token, clear_all_tokens
3
+ from typing import Optional
4
+
5
+ def logout(
6
+ backend: Optional[str] = typer.Option(
7
+ None,
8
+ "--backend",
9
+ "-b",
10
+ help=(
11
+ "Use a different backend: "
12
+ "local (l, l1, l2, ...), "
13
+ "remote (r, r1, r2, ...) "
14
+ "or full URL (https://example.com)"
15
+ )
16
+ ),
17
+ all: bool = typer.Option(
18
+ False,
19
+ "--all",
20
+ help="Logout from all backends (clears all stored tokens)"
21
+ )
22
+ ):
23
+ """
24
+ Logout from the current backend or all backends.
25
+
26
+ Use --all to logout from all backends.
27
+ """
28
+
29
+ backend = resolve_backend(backend)
30
+
31
+ if all:
32
+ clear_all_tokens()
33
+ typer.echo("Logged out from all backends.")
34
+ return
35
+
36
+ clear_token(resolve_backend(backend))
37
+ typer.echo(f"Logged out from backend {backend}.")
sai/commands/me.py ADDED
@@ -0,0 +1,48 @@
1
+ import typer
2
+ from ..state import resolve_backend
3
+ from typing import Optional
4
+ from ..utils.error_handler import handle_response_error
5
+ from ..utils.client import APIClient
6
+
7
+ def me(
8
+ backend: Optional[str] = typer.Option(
9
+ None,
10
+ "--backend",
11
+ "-b",
12
+ help=(
13
+ "Use a different backend: "
14
+ "local (l, l1, l2, ...), "
15
+ "remote (r, r1, r2, ...) "
16
+ "or full URL (https://example.com)"
17
+ )
18
+ ),
19
+ all: bool = typer.Option(
20
+ False,
21
+ "--all",
22
+ help="Show all information"
23
+ )
24
+ ):
25
+ """
26
+ Show account information.
27
+
28
+ Use --all to show all available information.
29
+ """
30
+
31
+ backend = resolve_backend(backend)
32
+ client = APIClient(backend)
33
+
34
+ resp = client.get("/auth/me")
35
+
36
+ if resp.status_code == 200:
37
+ res = resp.json()
38
+ if all:
39
+ typer.echo('\n'.join(f'{k}: {v}' for k, v in res.items()))
40
+ else:
41
+ typer.echo(f'Username: {res["username"]} (id={res["id"]})')
42
+ typer.echo(f'Discord username: {res["discord_username"]} (id={res["discord_id"]})')
43
+ typer.echo(f'Admin: {"yes" if res["is_admin"] else "no"}')
44
+ typer.echo(f'Verified: {"yes" if res["verified"] else "no"}')
45
+ typer.echo(f'Can add datasets: {"yes" if res["can_add_datasets"] else "no"}')
46
+ typer.echo(f'Can see admin panel: {"yes" if res["can_see_admin_panel"] else "no"}')
47
+ else:
48
+ handle_response_error(resp)
sai/commands/open.py ADDED
@@ -0,0 +1 @@
1
+ # opens task in browser and shows path with id