accli 0.2.2__tar.gz → 0.4__tar.gz
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.
- {accli-0.2.2/accli.egg-info → accli-0.4}/PKG-INFO +3 -2
- {accli-0.2.2 → accli-0.4}/README.md +2 -1
- {accli-0.2.2 → accli-0.4}/accli/AcceleratorJobProjectService.py +80 -3
- accli-0.4/accli/AcceleratorTaskDispatcher.py +218 -0
- {accli-0.2.2 → accli-0.4}/accli/AcceleratorTerminalCliProjectService.py +42 -2
- accli-0.4/accli/CsvRegionalTimeseriesValidator.py +230 -0
- {accli-0.2.2 → accli-0.4}/accli/__init__.py +3 -1
- {accli-0.2.2 → accli-0.4}/accli/_version.py +1 -1
- {accli-0.2.2 → accli-0.4}/accli/cli.py +81 -61
- accli-0.4/accli/token.py +77 -0
- {accli-0.2.2 → accli-0.4/accli.egg-info}/PKG-INFO +3 -2
- {accli-0.2.2 → accli-0.4}/accli.egg-info/SOURCES.txt +3 -0
- {accli-0.2.2 → accli-0.4}/accli.egg-info/requires.txt +2 -0
- {accli-0.2.2 → accli-0.4}/pyproject.toml +1 -1
- accli-0.4/requirements.txt +8 -0
- accli-0.2.2/requirements.txt +0 -5
- {accli-0.2.2 → accli-0.4}/.gitignore +0 -0
- {accli-0.2.2 → accli-0.4}/Dockerfile.dev +0 -0
- {accli-0.2.2 → accli-0.4}/LICENSE +0 -0
- {accli-0.2.2 → accli-0.4}/accli/__main__.py +0 -0
- {accli-0.2.2 → accli-0.4}/accli.egg-info/dependency_links.txt +0 -0
- {accli-0.2.2 → accli-0.4}/accli.egg-info/entry_points.txt +0 -0
- {accli-0.2.2 → accli-0.4}/accli.egg-info/top_level.txt +0 -0
- {accli-0.2.2 → accli-0.4}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: accli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4
|
|
4
4
|
Summary: IIASA Accelerator Client
|
|
5
5
|
Author-email: Wrufesh S <wrufesh@gmail.com>
|
|
6
6
|
License: The MIT License (MIT)
|
|
@@ -81,6 +81,7 @@ Please follow [this link.](https://packaging.python.org/en/latest/tutorials/pack
|
|
|
81
81
|
**Release process**
|
|
82
82
|
1. Commit with right version on accli/_version.py
|
|
83
83
|
2. Run 'python scripts/tag.py'
|
|
84
|
-
3. python -m build
|
|
84
|
+
3. `python -m build`
|
|
85
|
+
4. `twine upload -r pypi -u __token__ -p <password-or-token> ./dist/*`
|
|
85
86
|
|
|
86
87
|
|
|
@@ -46,6 +46,7 @@ Please follow [this link.](https://packaging.python.org/en/latest/tutorials/pack
|
|
|
46
46
|
**Release process**
|
|
47
47
|
1. Commit with right version on accli/_version.py
|
|
48
48
|
2. Run 'python scripts/tag.py'
|
|
49
|
-
3. python -m build
|
|
49
|
+
3. `python -m build`
|
|
50
|
+
4. `twine upload -r pypi -u __token__ -p <password-or-token> ./dist/*`
|
|
50
51
|
|
|
51
52
|
|
|
@@ -1,7 +1,11 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import io
|
|
3
|
+
import typing
|
|
1
4
|
import requests
|
|
2
5
|
import json
|
|
3
6
|
import base64
|
|
4
7
|
import urllib3
|
|
8
|
+
from pathlib import Path
|
|
5
9
|
from typing import List, Tuple
|
|
6
10
|
|
|
7
11
|
class AccAPIError(Exception):
|
|
@@ -16,7 +20,7 @@ class AcceleratorJobProjectService:
|
|
|
16
20
|
def __init__(
|
|
17
21
|
self,
|
|
18
22
|
user_token,
|
|
19
|
-
|
|
23
|
+
server_url='http://accelerator-api.iiasa.ac.at',
|
|
20
24
|
verify_cert=True
|
|
21
25
|
):
|
|
22
26
|
|
|
@@ -29,7 +33,7 @@ class AcceleratorJobProjectService:
|
|
|
29
33
|
else:
|
|
30
34
|
self.http_client = urllib3.poolmanager.PoolManager(cert_reqs="CERT_NONE", num_pools=1, retries=retries)
|
|
31
35
|
|
|
32
|
-
self.cli_base_url =
|
|
36
|
+
self.cli_base_url = f"{server_url}/v1/ajob-cli"
|
|
33
37
|
self.common_request_headers = {
|
|
34
38
|
'x-authorization': user_token
|
|
35
39
|
}
|
|
@@ -92,6 +96,36 @@ class AcceleratorJobProjectService:
|
|
|
92
96
|
if url:
|
|
93
97
|
resp = self.http_client_request("GET", url, preload_content=False)
|
|
94
98
|
return resp
|
|
99
|
+
|
|
100
|
+
def add_log_file(self, data: bytes, filename):
|
|
101
|
+
res = self.http_client_request(
|
|
102
|
+
"GET",
|
|
103
|
+
f"{self.cli_base_url}/presigned-upload-url/?filename={filename}",
|
|
104
|
+
headers=self.common_request_headers
|
|
105
|
+
).json()
|
|
106
|
+
|
|
107
|
+
upload_url = res['upload_url']
|
|
108
|
+
app_bucket_id = res['app_bucket_id']
|
|
109
|
+
res_filename = res['filename']
|
|
110
|
+
|
|
111
|
+
requests.put(
|
|
112
|
+
upload_url,
|
|
113
|
+
data=data,
|
|
114
|
+
# headers=headers,
|
|
115
|
+
verify=False,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
self.http_client_request(
|
|
119
|
+
"POST",
|
|
120
|
+
f"{self.cli_base_url}/register-log-file/",
|
|
121
|
+
json=dict(
|
|
122
|
+
filename=res_filename,
|
|
123
|
+
app_bucket_id=app_bucket_id
|
|
124
|
+
),
|
|
125
|
+
headers=self.common_request_headers
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
95
129
|
|
|
96
130
|
def get_multipart_put_create_signed_url(
|
|
97
131
|
self,
|
|
@@ -475,4 +509,47 @@ class AcceleratorJobProjectService:
|
|
|
475
509
|
)
|
|
476
510
|
),
|
|
477
511
|
headers=headers
|
|
478
|
-
)
|
|
512
|
+
)
|
|
513
|
+
|
|
514
|
+
class Fs:
|
|
515
|
+
@staticmethod
|
|
516
|
+
def write_stream_remote(filestream, dest_filepath, user_token, server_url):
|
|
517
|
+
|
|
518
|
+
accelerator_job_service = AcceleratorJobProjectService(
|
|
519
|
+
user_token,
|
|
520
|
+
server_url=server_url,
|
|
521
|
+
verify_cert=True
|
|
522
|
+
)
|
|
523
|
+
|
|
524
|
+
accelerator_job_service.add_filestream_as_job_output(
|
|
525
|
+
dest_filepath,
|
|
526
|
+
filestream
|
|
527
|
+
)
|
|
528
|
+
|
|
529
|
+
@staticmethod
|
|
530
|
+
def write_stream_local(filestream, dest_filepath):
|
|
531
|
+
with open(dest_filepath, 'wb') as file:
|
|
532
|
+
file.write(filestream.getvalue())
|
|
533
|
+
|
|
534
|
+
@staticmethod
|
|
535
|
+
def write_file(source: typing.Union[str, io.BytesIO], dest_filepath):
|
|
536
|
+
user_token = os.environ.get("ACC_JOB_JOB_TOKEN", None)
|
|
537
|
+
server_url = os.environ.get("ACC_JOB_GATEWAY_SERVER", None)
|
|
538
|
+
|
|
539
|
+
if isinstance(source, io.BytesIO):
|
|
540
|
+
if (user_token and server_url):
|
|
541
|
+
Fs.write_stream_remote(source, dest_filepath, user_token, server_url)
|
|
542
|
+
else:
|
|
543
|
+
Fs.write_stream_local(source, dest_filepath)
|
|
544
|
+
else:
|
|
545
|
+
if type(source) != str:
|
|
546
|
+
raise ValueError("Unknown source argument")
|
|
547
|
+
source_path = Path(source)
|
|
548
|
+
if not source_path.exists():
|
|
549
|
+
raise ValueError("Source path does not exist")
|
|
550
|
+
|
|
551
|
+
with open(source, 'wb') as fstream:
|
|
552
|
+
if (user_token and server_url):
|
|
553
|
+
Fs.write_stream_remote(fstream, dest_filepath, user_token, server_url)
|
|
554
|
+
else:
|
|
555
|
+
Fs.write_stream_local(fstream, dest_filepath)
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import git
|
|
2
|
+
import os
|
|
3
|
+
import hashlib
|
|
4
|
+
import shutil
|
|
5
|
+
import tempfile
|
|
6
|
+
from typing import Optional, List, Dict
|
|
7
|
+
from pydantic import BaseModel, root_validator
|
|
8
|
+
|
|
9
|
+
from accli.token import get_github_app_token
|
|
10
|
+
|
|
11
|
+
FOLDER_JOB_REPO_URL = 'https://github.com/IIASA-Accelerator/wkube-job.git'
|
|
12
|
+
|
|
13
|
+
def hash_folder(folder_path):
|
|
14
|
+
md5_hash = hashlib.md5()
|
|
15
|
+
sha256_hash = hashlib.sha256()
|
|
16
|
+
|
|
17
|
+
for root, _, files in os.walk(folder_path):
|
|
18
|
+
for file_name in files:
|
|
19
|
+
file_path = os.path.join(root, file_name)
|
|
20
|
+
try:
|
|
21
|
+
with open(file_path, 'rb') as f:
|
|
22
|
+
# Read and update MD5 and SHA256 hash object with file content
|
|
23
|
+
for chunk in iter(lambda: f.read(4096), b""):
|
|
24
|
+
md5_hash.update(chunk)
|
|
25
|
+
sha256_hash.update(chunk)
|
|
26
|
+
except IOError:
|
|
27
|
+
# Handle read error (if any)
|
|
28
|
+
print(f"Error reading file: {file_path}")
|
|
29
|
+
|
|
30
|
+
# Get hexadecimal digest of hashes
|
|
31
|
+
md5_digest = md5_hash.hexdigest()
|
|
32
|
+
# sha256_digest = sha256_hash.hexdigest()
|
|
33
|
+
|
|
34
|
+
return md5_digest
|
|
35
|
+
|
|
36
|
+
def copy_tree(src, dst):
|
|
37
|
+
"""Recursively copy from src to dst, excluding .git folders."""
|
|
38
|
+
for item in os.listdir(src):
|
|
39
|
+
src_path = os.path.join(src, item)
|
|
40
|
+
dst_path = os.path.join(dst, item)
|
|
41
|
+
|
|
42
|
+
if os.path.isdir(src_path):
|
|
43
|
+
if item == '.git':
|
|
44
|
+
continue
|
|
45
|
+
os.makedirs(dst_path, exist_ok=True)
|
|
46
|
+
copy_tree(src_path, dst_path)
|
|
47
|
+
else:
|
|
48
|
+
shutil.copy2(src_path, dst_path)
|
|
49
|
+
|
|
50
|
+
def push_folder_job(dir):
|
|
51
|
+
|
|
52
|
+
repo_dir = tempfile.mkdtemp()
|
|
53
|
+
|
|
54
|
+
copy_tree(dir, repo_dir)
|
|
55
|
+
|
|
56
|
+
branch_name = hash_folder(repo_dir)
|
|
57
|
+
|
|
58
|
+
repo = git.Repo.init(repo_dir)
|
|
59
|
+
|
|
60
|
+
git_token = get_github_app_token()
|
|
61
|
+
|
|
62
|
+
remote_url = f'https://x-access-token:{git_token}@{FOLDER_JOB_REPO_URL.split("https://")[1]}'
|
|
63
|
+
remote = repo.create_remote("accelerator_job_repo", remote_url)
|
|
64
|
+
|
|
65
|
+
remote.pull('master')
|
|
66
|
+
|
|
67
|
+
try:
|
|
68
|
+
repo.git.checkout(branch_name)
|
|
69
|
+
except git.exc.GitCommandError:
|
|
70
|
+
repo.git.checkout('-b', branch_name)
|
|
71
|
+
|
|
72
|
+
repo.git.add('.')
|
|
73
|
+
|
|
74
|
+
repo.index.commit(branch_name)
|
|
75
|
+
|
|
76
|
+
# TODO see what happens with the same checksum
|
|
77
|
+
remote.push(branch_name)
|
|
78
|
+
shutil.rmtree(repo_dir)
|
|
79
|
+
|
|
80
|
+
return FOLDER_JOB_REPO_URL, branch_name
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class JobDispatchModel(BaseModel):
|
|
84
|
+
is_holder_job: bool = True
|
|
85
|
+
|
|
86
|
+
execute_cluster: str
|
|
87
|
+
job_location: str
|
|
88
|
+
job_args: List
|
|
89
|
+
job_kwargs: Dict
|
|
90
|
+
|
|
91
|
+
required_cores: Optional[float]
|
|
92
|
+
required_ram: Optional[float]
|
|
93
|
+
required_storage_local: Optional[float]
|
|
94
|
+
|
|
95
|
+
# is ignored if it is a callback and child of non free node jobs
|
|
96
|
+
required_storage_workflow: Optional[float]
|
|
97
|
+
|
|
98
|
+
job_secrets: Optional[dict] = {}
|
|
99
|
+
|
|
100
|
+
timeout: Optional[int]
|
|
101
|
+
pvc_id: Optional[str]
|
|
102
|
+
node_id: Optional[str]
|
|
103
|
+
|
|
104
|
+
ignore_duplicate_job: bool = False
|
|
105
|
+
free_node: bool = False # Only applies to immediate children jobs
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
children: List['JobDispatchModel'] = []
|
|
110
|
+
callback: Optional['JobDispatchModel']
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class WKubeTaskMeta(BaseModel):
|
|
114
|
+
required_cores: float
|
|
115
|
+
required_ram: float
|
|
116
|
+
required_storage_local: float
|
|
117
|
+
|
|
118
|
+
# is ignored if it is a callback and child of non free node jobs
|
|
119
|
+
required_storage_workflow: float
|
|
120
|
+
|
|
121
|
+
job_secrets: Optional[dict] = {}
|
|
122
|
+
|
|
123
|
+
timeout:int
|
|
124
|
+
|
|
125
|
+
class WKubeTaskKwargs(BaseModel):
|
|
126
|
+
docker_image: Optional[str]
|
|
127
|
+
|
|
128
|
+
job_folder: Optional[str]
|
|
129
|
+
|
|
130
|
+
repo_url: Optional[str] # required when docker image is not present
|
|
131
|
+
repo_branch: Optional[str] # required when docker image is not present
|
|
132
|
+
|
|
133
|
+
docker_filename: Optional[str] # when not docker image;
|
|
134
|
+
base_stack: Optional[str] # when not github docker file #TODO add enum class of available base stack
|
|
135
|
+
|
|
136
|
+
force_build: bool = False
|
|
137
|
+
|
|
138
|
+
command: str # may not be present with docker_image # TODO wrap a command in custom script to implement timeout or possibly log ingestion if required.
|
|
139
|
+
|
|
140
|
+
conf: Dict[str,str] = {}
|
|
141
|
+
|
|
142
|
+
def dict(self, *args, **kwargs):
|
|
143
|
+
result = super().dict(*args, **kwargs)
|
|
144
|
+
if 'job_folder' in result:
|
|
145
|
+
del result['job_folder']
|
|
146
|
+
return result
|
|
147
|
+
|
|
148
|
+
@root_validator(pre=True)
|
|
149
|
+
def validate_root(cls, values):
|
|
150
|
+
if not values.get('docker_image'):
|
|
151
|
+
|
|
152
|
+
job_folder = values.get('job_folder')
|
|
153
|
+
|
|
154
|
+
if not job_folder:
|
|
155
|
+
|
|
156
|
+
if not values.get('repo_url'):
|
|
157
|
+
# TODO if has no repo url just set it
|
|
158
|
+
raise ValueError("repo_url is required")
|
|
159
|
+
|
|
160
|
+
if not values.get('repo_branch'):
|
|
161
|
+
raise ValueError('repo_branch is required')
|
|
162
|
+
else:
|
|
163
|
+
remote_url, branch_name = push_folder_job(job_folder)
|
|
164
|
+
values['repo_url'] = remote_url
|
|
165
|
+
values['repo_branch'] = branch_name
|
|
166
|
+
|
|
167
|
+
if not values.get('docker_filename'):
|
|
168
|
+
if not values.get("base_stack"):
|
|
169
|
+
raise ValueError("base_stack is required when dockerfile is not defined")
|
|
170
|
+
return values
|
|
171
|
+
|
|
172
|
+
class WKubeTaskPydantic(WKubeTaskMeta, WKubeTaskKwargs):
|
|
173
|
+
pass
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class GenericTask:
|
|
177
|
+
def __init__(self, *args, **kwargs):
|
|
178
|
+
self.dispatch_model_task: JobDispatchModel
|
|
179
|
+
|
|
180
|
+
def add_child(self, task):
|
|
181
|
+
if self.__class__ != task.__class__:
|
|
182
|
+
raise ValueError(f"task should of {self.__class__} class")
|
|
183
|
+
self.dispatch_model_task.children.append(task.dispatch_model_task)
|
|
184
|
+
|
|
185
|
+
def add_callback(self, task):
|
|
186
|
+
|
|
187
|
+
if self.__class__ != task.__class__:
|
|
188
|
+
raise ValueError(f"task should of {self.__class__} class")
|
|
189
|
+
self.dispatch_model_task.callback = task.dispatch_model_task
|
|
190
|
+
|
|
191
|
+
@property
|
|
192
|
+
def description(self):
|
|
193
|
+
return self.dispatch_model_task.dict()
|
|
194
|
+
|
|
195
|
+
class WKubeTask(GenericTask):
|
|
196
|
+
def __init__(self, *t_args, **t_kwargs):
|
|
197
|
+
|
|
198
|
+
wkube_task_kwargs = None
|
|
199
|
+
wkube_task_meta = dict()
|
|
200
|
+
|
|
201
|
+
if (t_args or t_kwargs):
|
|
202
|
+
|
|
203
|
+
WKubeTaskPydantic(*t_args, **t_kwargs)
|
|
204
|
+
wkube_task_kwargs = WKubeTaskKwargs(*t_args, **t_kwargs)
|
|
205
|
+
wkube_task_meta.update(WKubeTaskMeta(*t_args, **t_kwargs).dict())
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
self.dispatch_model_task = JobDispatchModel(
|
|
209
|
+
is_holder_job=not (t_args or t_kwargs),
|
|
210
|
+
execute_cluster='WKUBE',
|
|
211
|
+
job_location='acc_native_jobs.dispatch_wkube_task',
|
|
212
|
+
job_args=[],
|
|
213
|
+
job_kwargs= wkube_task_kwargs.dict() if wkube_task_kwargs else dict(),
|
|
214
|
+
**wkube_task_meta
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
|
|
@@ -22,7 +22,7 @@ class AcceleratorTerminalCliProjectService:
|
|
|
22
22
|
def __init__(
|
|
23
23
|
self,
|
|
24
24
|
user_token,
|
|
25
|
-
|
|
25
|
+
server_url,
|
|
26
26
|
verify_cert=True
|
|
27
27
|
):
|
|
28
28
|
|
|
@@ -35,7 +35,8 @@ class AcceleratorTerminalCliProjectService:
|
|
|
35
35
|
else:
|
|
36
36
|
self.http_client = urllib3.poolmanager.PoolManager(cert_reqs="CERT_NONE", num_pools=1, retries=retries)
|
|
37
37
|
|
|
38
|
-
self.
|
|
38
|
+
self.server_url = server_url
|
|
39
|
+
self.cli_base_url = f"{self.server_url}/v1/aterm-cli"
|
|
39
40
|
self.common_request_headers = {
|
|
40
41
|
'x-authorization': user_token
|
|
41
42
|
}
|
|
@@ -68,6 +69,45 @@ class AcceleratorTerminalCliProjectService:
|
|
|
68
69
|
raise err
|
|
69
70
|
|
|
70
71
|
return res.json()
|
|
72
|
+
|
|
73
|
+
def get_github_app_token(self, project_slug):
|
|
74
|
+
|
|
75
|
+
res = self.http_client_request(
|
|
76
|
+
"GET",
|
|
77
|
+
f"{self.cli_base_url}/{project_slug}/github-app-token/",
|
|
78
|
+
headers=self.common_request_headers
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
return res.json()
|
|
82
|
+
|
|
83
|
+
def dispatch(self, project_slug, job_description):
|
|
84
|
+
|
|
85
|
+
try:
|
|
86
|
+
res = self.http_client_request(
|
|
87
|
+
"POST",
|
|
88
|
+
f"{self.cli_base_url}/{project_slug}/jobs/dispatch/",
|
|
89
|
+
json=job_description,
|
|
90
|
+
headers=self.common_request_headers
|
|
91
|
+
)
|
|
92
|
+
except AccAPIError as err:
|
|
93
|
+
raise err
|
|
94
|
+
|
|
95
|
+
return res.json()['job_id']
|
|
96
|
+
|
|
97
|
+
def get_dataset_template_details(self, project_slug, template_slug):
|
|
98
|
+
|
|
99
|
+
try:
|
|
100
|
+
res = self.http_client_request(
|
|
101
|
+
"GET",
|
|
102
|
+
f"{self.server_url}/v1/projects/{project_slug}/dataset-templates/{template_slug}/by-slug/",
|
|
103
|
+
)
|
|
104
|
+
except AccAPIError as err:
|
|
105
|
+
if err.status_code == 404:
|
|
106
|
+
return None
|
|
107
|
+
else:
|
|
108
|
+
raise err
|
|
109
|
+
|
|
110
|
+
return res.json()
|
|
71
111
|
|
|
72
112
|
|
|
73
113
|
def get_multipart_put_create_signed_url(
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import typing
|
|
2
|
+
import typer
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import subprocess
|
|
6
|
+
import csv
|
|
7
|
+
import uuid
|
|
8
|
+
import itertools
|
|
9
|
+
from typing import Optional
|
|
10
|
+
from rich.progress import Progress, SpinnerColumn, TextColumn
|
|
11
|
+
from rich import print
|
|
12
|
+
|
|
13
|
+
from jsonschema import validate as jsonschema_validate
|
|
14
|
+
from jsonschema.exceptions import ValidationError, SchemaError
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
if typing.TYPE_CHECKING:
|
|
18
|
+
from accli.AcceleratorTerminalCliProjectService import AcceleratorTerminalCliProjectService
|
|
19
|
+
|
|
20
|
+
def lower_rows(iterator):
|
|
21
|
+
# return itertools.chain([next(iterator).lower()], iterator)
|
|
22
|
+
for item in iterator:
|
|
23
|
+
yield item.lower()
|
|
24
|
+
|
|
25
|
+
class CsvRegionalTimeseriesValidator():
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
*,
|
|
29
|
+
project_slug,
|
|
30
|
+
dataset_template_slug,
|
|
31
|
+
input_filepath,
|
|
32
|
+
project_service: 'AcceleratorTerminalCliProjectService',
|
|
33
|
+
csv_fieldnames: Optional[typing.List[str]]=None,
|
|
34
|
+
|
|
35
|
+
):
|
|
36
|
+
self.project_slug = project_slug
|
|
37
|
+
self.project_service = project_service
|
|
38
|
+
|
|
39
|
+
self.dataset_template_slug = dataset_template_slug
|
|
40
|
+
|
|
41
|
+
self.input_filepath = input_filepath
|
|
42
|
+
|
|
43
|
+
self.csv_fieldnames = csv_fieldnames
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
self.errors = dict()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def get_map_documents(self, field_name):
|
|
51
|
+
map_documents = self.rules.get(f'map_{field_name}')
|
|
52
|
+
return map_documents
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def init_validation_metadata(self):
|
|
56
|
+
self.validation_metadata = {
|
|
57
|
+
self.time_dimension.lower(): {
|
|
58
|
+
"min_value": float('+inf'),
|
|
59
|
+
"max_value": float('-inf')
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def set_csv_regional_validation_rules(self):
|
|
65
|
+
|
|
66
|
+
dataset_template_details = self.project_service.get_dataset_template_details(
|
|
67
|
+
self.project_slug,
|
|
68
|
+
self.dataset_template_slug
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
self.rules = dataset_template_details.get('rules')
|
|
72
|
+
|
|
73
|
+
assert self.rules, \
|
|
74
|
+
f"No dataset template rules found for dataset_template id: \
|
|
75
|
+
{self.dataset_template_id}"
|
|
76
|
+
|
|
77
|
+
self.time_dimension = self.rules['root_schema_declarations']['time_dimension']
|
|
78
|
+
self.value_dimension = self.rules['root_schema_declarations']['value_dimension']
|
|
79
|
+
|
|
80
|
+
self.region_dimension = self.rules['root_schema_declarations']['region_dimension']
|
|
81
|
+
|
|
82
|
+
# specific to regional timeseries
|
|
83
|
+
self.region_layer_dimension = self.rules['root_schema_declarations']['region_layer_map_key']
|
|
84
|
+
|
|
85
|
+
def validate_row_data(self, row):
|
|
86
|
+
try:
|
|
87
|
+
jsonschema_validate(
|
|
88
|
+
self.rules.get('root'),
|
|
89
|
+
row
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
except SchemaError as schema_error:
|
|
93
|
+
|
|
94
|
+
raise ValueError(
|
|
95
|
+
f"Schema itself is not valid with template id. Template id: {self.dataset_template_id}. Original exception: {str(schema_error)}"
|
|
96
|
+
)
|
|
97
|
+
except ValidationError as validation_error:
|
|
98
|
+
raise ValueError(
|
|
99
|
+
f"Invalid data. Template id: {self.dataset_template_id}. Data: {str(validation_error)}. Original exception: {str(validation_error)}"
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
for key in self.rules['root']['properties']:
|
|
104
|
+
|
|
105
|
+
if key == self.time_dimension.lower():
|
|
106
|
+
if float(row[key]) < self.validation_metadata[
|
|
107
|
+
self.time_dimension.lower()
|
|
108
|
+
]["min_value"]:
|
|
109
|
+
self.validation_metadata[
|
|
110
|
+
self.time_dimension.lower()
|
|
111
|
+
]["min_value"] = float(row[key])
|
|
112
|
+
|
|
113
|
+
if float(row[key]) > self.validation_metadata[
|
|
114
|
+
self.time_dimension.lower()
|
|
115
|
+
]["max_value"]:
|
|
116
|
+
self.validation_metadata[
|
|
117
|
+
self.time_dimension.lower()
|
|
118
|
+
]["max_value"] = float(row[key])
|
|
119
|
+
|
|
120
|
+
continue
|
|
121
|
+
|
|
122
|
+
if key == self.value_dimension:
|
|
123
|
+
continue
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
map_documents = self.get_map_documents(key)
|
|
127
|
+
|
|
128
|
+
if map_documents:
|
|
129
|
+
if row[key] not in map_documents:
|
|
130
|
+
raise ValueError(f"'{row[key]}' must be one of {map_documents.keys()}" )
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
if self.validation_metadata.get(key):
|
|
134
|
+
if len(self.validation_metadata[key]) <= 200: #limit harvest
|
|
135
|
+
self.validation_metadata[key].add(row[key])
|
|
136
|
+
|
|
137
|
+
else:
|
|
138
|
+
self.validation_metadata[key] = set([row[key]])
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
extra_template_validators = self.rules.get('template_validators')
|
|
142
|
+
|
|
143
|
+
if extra_template_validators and extra_template_validators != 'not defined':
|
|
144
|
+
|
|
145
|
+
for row_key in extra_template_validators.keys():
|
|
146
|
+
lhs = row[row_key]
|
|
147
|
+
|
|
148
|
+
condition_object = extra_template_validators[row_key]
|
|
149
|
+
|
|
150
|
+
for condition in condition_object.keys():
|
|
151
|
+
|
|
152
|
+
rhs_value_pointer = condition_object[condition]
|
|
153
|
+
|
|
154
|
+
rhs = None
|
|
155
|
+
for pointer in rhs_value_pointer:
|
|
156
|
+
if pointer.startswith('&'):
|
|
157
|
+
rhs = self.rules[pointer[1:]]
|
|
158
|
+
elif pointer.startswith('{') and pointer.endswith('}'):
|
|
159
|
+
rhs = rhs[row[pointer[1:-1]]]
|
|
160
|
+
|
|
161
|
+
else:
|
|
162
|
+
rhs = rhs[pointer]
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
if condition == 'value_equals':
|
|
166
|
+
if lhs.lower() != rhs.lower():
|
|
167
|
+
raise ValueError(
|
|
168
|
+
f'{lhs} in {row_key} column must be equal to {rhs}.'
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
if condition == 'is_subset_of_map':
|
|
172
|
+
if not lhs.lower() in rhs.lower():
|
|
173
|
+
raise ValueError(
|
|
174
|
+
f'{lhs} in {row_key} column must be member of {rhs}.'
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
return row
|
|
178
|
+
def get_validated_rows(self, filepath):
|
|
179
|
+
with open(filepath) as csvfile:
|
|
180
|
+
# with open(self.temp_downloaded_filepath) as csvfile:
|
|
181
|
+
reader = csv.DictReader(
|
|
182
|
+
lower_rows(csvfile),
|
|
183
|
+
fieldnames=self.csv_fieldnames,
|
|
184
|
+
restkey='restkeys',
|
|
185
|
+
restval='restvals'
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
for row in reader:
|
|
189
|
+
row.pop('restkeys', None)
|
|
190
|
+
row.pop('restvals', None)
|
|
191
|
+
|
|
192
|
+
try:
|
|
193
|
+
row = self.validate_row_data(row)
|
|
194
|
+
row[self.region_layer_dimension] = self.rules[f'map_{self.region_dimension}'][row[self.region_dimension]]['region_layer']
|
|
195
|
+
except Exception as err:
|
|
196
|
+
if len(self.errors) <= 50:
|
|
197
|
+
self.errors[str(err)] = str(row)
|
|
198
|
+
|
|
199
|
+
yield row
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def __call__(self):
|
|
203
|
+
|
|
204
|
+
self.set_csv_regional_validation_rules()
|
|
205
|
+
self.init_validation_metadata()
|
|
206
|
+
|
|
207
|
+
with Progress(
|
|
208
|
+
SpinnerColumn(),
|
|
209
|
+
TextColumn("[progress.description]{task.description}"),
|
|
210
|
+
transient=True,
|
|
211
|
+
) as progress:
|
|
212
|
+
progress.add_task(description="[bold cyan]Validating dataset. Please wait...", total=None)
|
|
213
|
+
|
|
214
|
+
for row in self.get_validated_rows(self.input_filepath):
|
|
215
|
+
pass
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
if self.errors:
|
|
219
|
+
for key in self.errors:
|
|
220
|
+
print(f"[bold red]Invalid data:[/bold red] {self.errors[key]}")
|
|
221
|
+
print(f"[bold red]Error:[/bold red] {key}")
|
|
222
|
+
print("[bold red]Data is not valid against selected template. ⚠️⚠️")
|
|
223
|
+
typer.Exit(1)
|
|
224
|
+
else:
|
|
225
|
+
print("[bold green]Data validated against selected template.[/bold green] 🎉🎉")
|
|
226
|
+
typer.Exit(0)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
|
|
@@ -2,7 +2,9 @@ from accli._version import VERSION
|
|
|
2
2
|
|
|
3
3
|
from accli.cli import app
|
|
4
4
|
|
|
5
|
-
from accli.
|
|
5
|
+
from accli.AcceleratorTaskDispatcher import *
|
|
6
|
+
|
|
7
|
+
from accli.AcceleratorJobProjectService import AcceleratorJobProjectService, Fs
|
|
6
8
|
|
|
7
9
|
AjobCliService = AcceleratorJobProjectService
|
|
8
10
|
|
|
@@ -5,11 +5,14 @@ import os
|
|
|
5
5
|
import typer
|
|
6
6
|
import requests
|
|
7
7
|
import warnings
|
|
8
|
+
import importlib.util
|
|
8
9
|
from rich import print
|
|
9
10
|
from typing_extensions import Annotated
|
|
10
|
-
from tinydb import TinyDB, Query
|
|
11
|
-
from ._version import VERSION
|
|
12
11
|
|
|
12
|
+
from accli.token import save_token_details, get_token, get_server_url, set_github_app_token
|
|
13
|
+
|
|
14
|
+
from accli.CsvRegionalTimeseriesValidator import CsvRegionalTimeseriesValidator
|
|
15
|
+
from ._version import VERSION
|
|
13
16
|
|
|
14
17
|
from accli.AcceleratorTerminalCliProjectService import AcceleratorTerminalCliProjectService
|
|
15
18
|
|
|
@@ -17,69 +20,12 @@ from rich.progress import Progress, SpinnerColumn, TextColumn, ProgressColumn, B
|
|
|
17
20
|
|
|
18
21
|
warnings.filterwarnings('ignore')
|
|
19
22
|
|
|
20
|
-
|
|
21
23
|
app = typer.Typer(
|
|
22
24
|
add_completion=False,
|
|
23
25
|
pretty_exceptions_show_locals=False,
|
|
24
26
|
no_args_is_help=True
|
|
25
27
|
)
|
|
26
28
|
|
|
27
|
-
def get_db_path():
|
|
28
|
-
|
|
29
|
-
home = os.path.expanduser("~")
|
|
30
|
-
token_directory = f"{home}/.accli"
|
|
31
|
-
|
|
32
|
-
if not os.path.exists(token_directory):
|
|
33
|
-
os.makedirs(token_directory)
|
|
34
|
-
|
|
35
|
-
return f"{token_directory}/data.json"
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
def save_token_details(token, server_url, webcli_url):
|
|
39
|
-
|
|
40
|
-
db_path = get_db_path()
|
|
41
|
-
|
|
42
|
-
try:
|
|
43
|
-
os.remove(db_path)
|
|
44
|
-
except OSError:
|
|
45
|
-
pass
|
|
46
|
-
|
|
47
|
-
db = TinyDB(db_path)
|
|
48
|
-
db.insert({
|
|
49
|
-
'token': token,
|
|
50
|
-
'server_url': server_url,
|
|
51
|
-
'webcli_url': webcli_url
|
|
52
|
-
})
|
|
53
|
-
|
|
54
|
-
def get_token():
|
|
55
|
-
db_path = get_db_path()
|
|
56
|
-
|
|
57
|
-
db = TinyDB(db_path)
|
|
58
|
-
|
|
59
|
-
for item in db:
|
|
60
|
-
token = item.get('token')
|
|
61
|
-
if token:
|
|
62
|
-
break
|
|
63
|
-
|
|
64
|
-
if not token:
|
|
65
|
-
print("Token does not exists. Please login.")
|
|
66
|
-
return token
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
def get_server_url():
|
|
70
|
-
db_path = get_db_path()
|
|
71
|
-
|
|
72
|
-
db = TinyDB(db_path)
|
|
73
|
-
|
|
74
|
-
for item in db:
|
|
75
|
-
server_url = item.get('server_url')
|
|
76
|
-
if server_url:
|
|
77
|
-
break
|
|
78
|
-
|
|
79
|
-
if not server_url:
|
|
80
|
-
print("Server url does not exists. Please login.")
|
|
81
|
-
return server_url
|
|
82
|
-
|
|
83
29
|
|
|
84
30
|
def get_size(path):
|
|
85
31
|
size = 0
|
|
@@ -136,7 +82,7 @@ def upload_file(project_slug, accelerator_filename, local_filepath, progress, ta
|
|
|
136
82
|
|
|
137
83
|
term_cli_project_service = AcceleratorTerminalCliProjectService(
|
|
138
84
|
user_token=access_token,
|
|
139
|
-
|
|
85
|
+
server_url=server_url,
|
|
140
86
|
verify_cert=False
|
|
141
87
|
)
|
|
142
88
|
|
|
@@ -156,8 +102,11 @@ def upload(
|
|
|
156
102
|
folder_name: Annotated[str, typer.Argument(help="Name of the folder to be made in Accelerator project space.")],
|
|
157
103
|
max_workers: Annotated[int, typer.Option(help="Maximum worker pool for multipart upload.")] = os.cpu_count()
|
|
158
104
|
):
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
#TODO make user free to put anywere except for reserved folder
|
|
159
108
|
|
|
160
|
-
if not re.fullmatch(r'
|
|
109
|
+
if not re.fullmatch(r'[a-zA-Z0-9\-\_]+', folder_name):
|
|
161
110
|
raise ValueError("Folder name is invalid.")
|
|
162
111
|
|
|
163
112
|
|
|
@@ -200,3 +149,74 @@ def upload(
|
|
|
200
149
|
print("ERROR: No such file or directory.")
|
|
201
150
|
typer.Exit(1)
|
|
202
151
|
|
|
152
|
+
@app.command()
|
|
153
|
+
def validate(
|
|
154
|
+
project_slug: Annotated[str, typer.Argument(help="Unique Accelerator project slug.")],
|
|
155
|
+
template_slug: Annotated[str, typer.Argument(help="Unique project template slug")],
|
|
156
|
+
filepath: Annotated[str, typer.Argument(help="Path of the file to validate")],
|
|
157
|
+
server: Annotated[str, typer.Option(help="Accelerator server url.")] = "https://accelerator-api.iiasa.ac.at",
|
|
158
|
+
):
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
term_cli_project_service = AcceleratorTerminalCliProjectService(
|
|
162
|
+
user_token="",
|
|
163
|
+
server_url=server,
|
|
164
|
+
verify_cert=False
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
validate = CsvRegionalTimeseriesValidator(
|
|
168
|
+
project_slug=project_slug,
|
|
169
|
+
dataset_template_slug=template_slug,
|
|
170
|
+
input_filepath=filepath,
|
|
171
|
+
project_service=term_cli_project_service,
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
validate()
|
|
175
|
+
|
|
176
|
+
@app.command()
|
|
177
|
+
def dispatch(
|
|
178
|
+
project_slug: Annotated[str, typer.Argument(help="Unique Accelerator project slug.")],
|
|
179
|
+
# experiment_name: Annotated[str, typer.Argument(help="Experiment or simulation name. Please put name in quote it includes space(s).")],
|
|
180
|
+
workflow_file: Annotated[str, typer.Argument(help="Python workflow filepath.")],
|
|
181
|
+
root_task_variable: Annotated[str, typer.Argument(help="Root task variable in workflow_file.")],
|
|
182
|
+
server: Annotated[str, typer.Option(help="Accelerator server url.")] = "https://accelerator-api.iiasa.ac.at",
|
|
183
|
+
):
|
|
184
|
+
|
|
185
|
+
access_token = get_token()
|
|
186
|
+
|
|
187
|
+
server_url = get_server_url()
|
|
188
|
+
|
|
189
|
+
term_cli_project_service = AcceleratorTerminalCliProjectService(
|
|
190
|
+
user_token=access_token,
|
|
191
|
+
server_url=server_url,
|
|
192
|
+
verify_cert=False
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
github_app_token = term_cli_project_service.get_github_app_token(project_slug)
|
|
196
|
+
set_github_app_token(github_app_token)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
spec = importlib.util.spec_from_file_location("workflow", workflow_file)
|
|
200
|
+
|
|
201
|
+
module = importlib.util.module_from_spec(spec)
|
|
202
|
+
|
|
203
|
+
spec.loader.exec_module(module)
|
|
204
|
+
|
|
205
|
+
job_to_dispatch = getattr(module, root_task_variable)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
if not job_to_dispatch:
|
|
209
|
+
raise ValueError(f"No root task variable found with name {root_task_variable}")
|
|
210
|
+
|
|
211
|
+
print(job_to_dispatch.description)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
root_job_id = term_cli_project_service.dispatch(
|
|
215
|
+
project_slug,
|
|
216
|
+
job_to_dispatch.description
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
print(f"Dispatched root job #ID: {root_job_id}")
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
|
accli-0.4/accli/token.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from tinydb import TinyDB, Query
|
|
3
|
+
|
|
4
|
+
def get_db_path():
|
|
5
|
+
|
|
6
|
+
home = os.path.expanduser("~")
|
|
7
|
+
token_directory = f"{home}/.accli"
|
|
8
|
+
|
|
9
|
+
if not os.path.exists(token_directory):
|
|
10
|
+
os.makedirs(token_directory)
|
|
11
|
+
|
|
12
|
+
return f"{token_directory}/data.json"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def save_token_details(token, server_url, webcli_url):
|
|
16
|
+
|
|
17
|
+
db_path = get_db_path()
|
|
18
|
+
|
|
19
|
+
try:
|
|
20
|
+
os.remove(db_path)
|
|
21
|
+
except OSError:
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
db = TinyDB(db_path)
|
|
25
|
+
db.insert({
|
|
26
|
+
'token': token,
|
|
27
|
+
'server_url': server_url,
|
|
28
|
+
'webcli_url': webcli_url
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
def get_token():
|
|
32
|
+
db_path = get_db_path()
|
|
33
|
+
|
|
34
|
+
db = TinyDB(db_path)
|
|
35
|
+
|
|
36
|
+
for item in db:
|
|
37
|
+
token = item.get('token')
|
|
38
|
+
if token:
|
|
39
|
+
break
|
|
40
|
+
|
|
41
|
+
if not token:
|
|
42
|
+
print("Token does not exists. Please login.")
|
|
43
|
+
return token
|
|
44
|
+
|
|
45
|
+
def get_github_app_token():
|
|
46
|
+
db_path = get_db_path()
|
|
47
|
+
|
|
48
|
+
db = TinyDB(db_path)
|
|
49
|
+
|
|
50
|
+
for item in db:
|
|
51
|
+
token = item.get('github_app_token')
|
|
52
|
+
if token:
|
|
53
|
+
break
|
|
54
|
+
|
|
55
|
+
if not token:
|
|
56
|
+
print("Github app token does not exists.")
|
|
57
|
+
return token
|
|
58
|
+
|
|
59
|
+
def set_github_app_token(github_app_token):
|
|
60
|
+
db_path = get_db_path()
|
|
61
|
+
db = TinyDB(db_path)
|
|
62
|
+
db.update({'github_app_token': github_app_token}, doc_ids=[1])
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def get_server_url():
|
|
66
|
+
db_path = get_db_path()
|
|
67
|
+
|
|
68
|
+
db = TinyDB(db_path)
|
|
69
|
+
|
|
70
|
+
for item in db:
|
|
71
|
+
server_url = item.get('server_url')
|
|
72
|
+
if server_url:
|
|
73
|
+
break
|
|
74
|
+
|
|
75
|
+
if not server_url:
|
|
76
|
+
print("Server url does not exists. Please login.")
|
|
77
|
+
return server_url
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: accli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4
|
|
4
4
|
Summary: IIASA Accelerator Client
|
|
5
5
|
Author-email: Wrufesh S <wrufesh@gmail.com>
|
|
6
6
|
License: The MIT License (MIT)
|
|
@@ -81,6 +81,7 @@ Please follow [this link.](https://packaging.python.org/en/latest/tutorials/pack
|
|
|
81
81
|
**Release process**
|
|
82
82
|
1. Commit with right version on accli/_version.py
|
|
83
83
|
2. Run 'python scripts/tag.py'
|
|
84
|
-
3. python -m build
|
|
84
|
+
3. `python -m build`
|
|
85
|
+
4. `twine upload -r pypi -u __token__ -p <password-or-token> ./dist/*`
|
|
85
86
|
|
|
86
87
|
|
|
@@ -5,11 +5,14 @@ README.md
|
|
|
5
5
|
pyproject.toml
|
|
6
6
|
requirements.txt
|
|
7
7
|
accli/AcceleratorJobProjectService.py
|
|
8
|
+
accli/AcceleratorTaskDispatcher.py
|
|
8
9
|
accli/AcceleratorTerminalCliProjectService.py
|
|
10
|
+
accli/CsvRegionalTimeseriesValidator.py
|
|
9
11
|
accli/__init__.py
|
|
10
12
|
accli/__main__.py
|
|
11
13
|
accli/_version.py
|
|
12
14
|
accli/cli.py
|
|
15
|
+
accli/token.py
|
|
13
16
|
accli.egg-info/PKG-INFO
|
|
14
17
|
accli.egg-info/SOURCES.txt
|
|
15
18
|
accli.egg-info/dependency_links.txt
|
|
@@ -12,7 +12,7 @@ description = "IIASA Accelerator Client"
|
|
|
12
12
|
readme = "README.md"
|
|
13
13
|
license = {file = "LICENSE"}
|
|
14
14
|
requires-python = ">=3.7.17"
|
|
15
|
-
dependencies = ['urllib3', 'typer[all]', 'tinydb', 'requests']
|
|
15
|
+
dependencies = ['urllib3', 'typer[all]', 'tinydb', 'requests', 'jsonschema', 'pydantic==1.10.13']
|
|
16
16
|
classifiers = [
|
|
17
17
|
"Programming Language :: Python :: 3",
|
|
18
18
|
"License :: OSI Approved :: MIT License",
|
accli-0.2.2/requirements.txt
DELETED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|