q8s 0.1.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.
q8s/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """A Python kernel for Jupyter that uses Kubernetes to execute code."""
2
+
3
+ from .kernel import Q8sKernel
q8s/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from . import Q8sKernel
2
+
3
+ from ipykernel.kernelapp import IPKernelApp
4
+ IPKernelApp.launch_instance(kernel_class=Q8sKernel)
q8s/cli.py ADDED
@@ -0,0 +1,73 @@
1
+ import os
2
+ from pathlib import Path
3
+ import typer
4
+ import sys
5
+ from typing_extensions import Annotated
6
+ from q8s.execution import K8sContext
7
+ from q8s.install import install_my_kernel_spec
8
+
9
+ app = typer.Typer()
10
+
11
+
12
+ @app.command()
13
+ def build(tag: str = None):
14
+ pass
15
+
16
+
17
+ @app.command()
18
+ def execute(
19
+ file: Annotated[Path, typer.Argument(help="Python file to be executed")],
20
+ kubeconfig: Annotated[
21
+ Path, typer.Option(help="Kubernetes configuration", envvar="KUBECONFIG")
22
+ ] = None,
23
+ image: Annotated[str, typer.Option(help="Docker image")] = "vstirbu/benchmark-deps",
24
+ registry_pat: Annotated[
25
+ str,
26
+ typer.Option(
27
+ help="Registry personal access token (PAT)",
28
+ envvar="REGISTRY_PAT",
29
+ ),
30
+ ] = None,
31
+ ):
32
+ if kubeconfig.exists() is False:
33
+ typer.echo(f"kubeconfig file {kubeconfig} does not exist")
34
+ raise typer.Exit(code=1)
35
+
36
+ kubeconfig = os.environ.get("KUBECONFIG", kubeconfig.as_posix())
37
+
38
+ if kubeconfig is None:
39
+ typer.echo("KUBECONFIG not set")
40
+ raise typer.Exit(code=1)
41
+
42
+ # config.load_kube_config(kubeconfig)
43
+
44
+ k8s_context = K8sContext(kubeconfig)
45
+ k8s_context.set_container_image(image)
46
+ k8s_context.set_registry_pat(registry_pat)
47
+
48
+ with open(file, "r") as f:
49
+ code = f.read()
50
+ # output, stream_name = execute_k8s(code, None, image, registry_pat)
51
+ output, stream_name = k8s_context.execute(code)
52
+
53
+ print(f"output:\n{output}")
54
+ print(f"output stream: {stream_name}")
55
+
56
+
57
+ @app.command()
58
+ def jupyter(
59
+ install: Annotated[
60
+ bool,
61
+ typer.Option(
62
+ help="Install kernel spec for Jupyter",
63
+ ),
64
+ ] = False,
65
+ ):
66
+ print("install:", install)
67
+ if install:
68
+ install_my_kernel_spec(user=False, prefix=sys.prefix)
69
+ # install_my_kernel_spec(user=user, prefix=prefix)
70
+
71
+
72
+ # if __name__ == "__main__":
73
+ app()
q8s/deps/__init__.py ADDED
File without changes
@@ -0,0 +1,61 @@
1
+ import ast
2
+ import sys
3
+
4
+ from stdlib_list import stdlib_list
5
+
6
+
7
+ class CodeAnalyzer(ast.NodeVisitor):
8
+ stdlibs = []
9
+ version = "{}.{}".format(sys.version_info.major, sys.version_info.minor)
10
+
11
+ def __init__(self, code):
12
+ self.imports = set()
13
+ self.stdlibs = self.stdlib_list()
14
+
15
+ # with open(file, "r") as source:
16
+ # tree = ast.parse(source.read())
17
+
18
+ # self.visit(tree)
19
+ tree = ast.parse(code)
20
+ self.visit(tree)
21
+
22
+ # print("working with version: ", self.version)
23
+
24
+ def stdlib_list(self):
25
+ if sys.version_info.major == 3 and sys.version_info.minor < 10:
26
+ return stdlib_list(self.version)
27
+ else:
28
+ return sys.stdlib_module_names
29
+
30
+ def addImport(self, name):
31
+ if name in self.stdlibs:
32
+ pass
33
+ elif name in sys.builtin_module_names:
34
+ pass
35
+ else:
36
+ self.imports.add(name.split(".")[0])
37
+
38
+ def visit_Import(self, node):
39
+ for alias in node.names:
40
+ # pprint(vars(alias))
41
+ self.addImport(alias.name)
42
+
43
+ self.generic_visit(node)
44
+
45
+ def visit_ImportFrom(self, node):
46
+ # pprint(vars(node))
47
+ # pprint(self.file)
48
+
49
+ if node.level == 0:
50
+ self.addImport(node.module)
51
+ else:
52
+ if node.module is None:
53
+ for name in node.names:
54
+ self.addImport(name.asname or name.name)
55
+ else:
56
+ self.addImport(node.module)
57
+
58
+ self.generic_visit(node)
59
+
60
+ def getImports(self):
61
+ return sorted(self.imports)
q8s/deps/parser.py ADDED
@@ -0,0 +1,45 @@
1
+ from importlib.metadata import (
2
+ packages_distributions,
3
+ version,
4
+ )
5
+
6
+ from .code_analyzer import CodeAnalyzer
7
+
8
+ mapping = {
9
+ "qiskit-aer": "qiskit-aer-gpu",
10
+ }
11
+
12
+
13
+ class Parser:
14
+ def __init__(self) -> None:
15
+ self.installed = packages_distributions()
16
+
17
+ def mapToPip(self, package: str) -> str:
18
+ """
19
+ Map the package to the pip package.
20
+ """
21
+
22
+ try:
23
+ return self.installed[package][0]
24
+ except KeyError:
25
+ return None
26
+
27
+ def parse(self, code: str) -> str:
28
+ """
29
+ Parse the code and returns the requirements.
30
+ """
31
+ imports = CodeAnalyzer(code).getImports()
32
+
33
+ pips = list(map(self.mapToPip, imports))
34
+
35
+ return "\n".join(
36
+ list(
37
+ map(
38
+ lambda x: "{name}=={version}".format(
39
+ name=mapping[x] if x in mapping.keys() else x,
40
+ version=version(x),
41
+ ),
42
+ pips,
43
+ )
44
+ )
45
+ )
q8s/execution.py ADDED
@@ -0,0 +1,395 @@
1
+ import base64
2
+ from json import JSONEncoder, loads
3
+ import logging
4
+ import os
5
+ import random
6
+ import string
7
+ from time import sleep
8
+ from dotenv import dotenv_values
9
+ from kubernetes import client, config
10
+
11
+
12
+ FORMAT = "[%(levelname)s %(asctime)-15s q8s_context] %(message)s"
13
+ logging.basicConfig(level=logging.INFO, format=FORMAT)
14
+
15
+ MEMORY = os.environ.get("MEMORY", "32Gi")
16
+
17
+
18
+ def load_env():
19
+ env = dotenv_values(".env.q8s")
20
+
21
+ # for key in env.keys():
22
+ # logging.info(f"{key} = {env[key]}")
23
+
24
+ return env
25
+
26
+
27
+ class K8sContext:
28
+ container_image: str = "vsirbu/benchmark-deps"
29
+ registry_pat: str | None = None
30
+ jupyter_logger: None
31
+
32
+ def __init__(self, kubeconfig: str, logger=None):
33
+ config.load_kube_config(kubeconfig)
34
+ logging.info("Kubeconfig loaded")
35
+
36
+ _, active_context = config.list_kube_config_contexts(config_file=kubeconfig)
37
+
38
+ self.namespace = active_context["context"]["namespace"]
39
+ logging.info("Active namespace: %s" % self.namespace)
40
+
41
+ self.core_api_instance = client.CoreV1Api()
42
+ self.batch_api_instance = client.BatchV1Api()
43
+
44
+ self.name = f"qubernetes-job-{K8sContext.get_id()}"
45
+
46
+ self.__env = load_env()
47
+
48
+ self.jupyter_logger = logger
49
+
50
+ @staticmethod
51
+ def get_id():
52
+ return "".join(random.choices(string.ascii_lowercase + string.digits, k=6))
53
+
54
+ def set_container_image(self, image: str):
55
+ self.container_image = image
56
+
57
+ def set_registry_pat(self, pat: str):
58
+ self.registry_pat = pat
59
+
60
+ def create_job_object(self, code: str) -> client.V1Job:
61
+ return None
62
+
63
+ def __registry_credentials_secret_name(self):
64
+ return f"{self.name}-regcred"
65
+
66
+ def __create_job_object(self, code: str):
67
+ env = self.__prepare_environment()
68
+
69
+ # Configureate Pod template container
70
+ container = client.V1Container(
71
+ name=self.name,
72
+ image=self.container_image,
73
+ env=env,
74
+ command=["python3"],
75
+ args=["./app/main.py"],
76
+ resources=client.V1ResourceRequirements(
77
+ limits={
78
+ "cpu": "2",
79
+ "ephemeral-storage": "50Gi",
80
+ # "memory": "64Gi",
81
+ "memory": MEMORY,
82
+ "nvidia.com/gpu": "1",
83
+ # "qubernetes.dev/qpu": "1",
84
+ },
85
+ requests={
86
+ "cpu": "2",
87
+ "ephemeral-storage": "0",
88
+ # "memory": "32Gi",
89
+ "memory": MEMORY,
90
+ "nvidia.com/gpu": "1",
91
+ # "qubernetes.dev/qpu": "1",
92
+ },
93
+ ),
94
+ volume_mounts=[
95
+ client.V1VolumeMount(
96
+ name="app-volume", mount_path="/app", read_only=True
97
+ )
98
+ ],
99
+ )
100
+
101
+ # Create and configurate a spec section
102
+ template = client.V1PodTemplateSpec(
103
+ metadata=client.V1ObjectMeta(labels={"app": self.name}),
104
+ spec=client.V1PodSpec(
105
+ containers=[container],
106
+ image_pull_secrets=(
107
+ [
108
+ client.V1LocalObjectReference(
109
+ name=self.__registry_credentials_secret_name()
110
+ )
111
+ ]
112
+ if self.registry_pat
113
+ else []
114
+ ),
115
+ runtime_class_name="nvidia",
116
+ restart_policy="Never",
117
+ volumes=[
118
+ client.V1Volume(
119
+ name="app-volume",
120
+ config_map=client.V1ConfigMapVolumeSource(name=self.name),
121
+ )
122
+ ],
123
+ ),
124
+ )
125
+
126
+ # Create the specification of deployment
127
+ spec = client.V1JobSpec(template=template) # , ttl_seconds_after_finished=10
128
+
129
+ # Instantiate the job object
130
+ job_spec = client.V1Job(
131
+ api_version="batch/v1",
132
+ kind="Job",
133
+ metadata=client.V1ObjectMeta(
134
+ name=self.name,
135
+ namespace=self.namespace,
136
+ labels={"qubernetes.dev/job.type": "jupyter"},
137
+ ),
138
+ spec=spec,
139
+ )
140
+
141
+ job = self.batch_api_instance.create_namespaced_job(
142
+ body=job_spec, namespace=self.namespace
143
+ )
144
+ logging.info("Job created")
145
+
146
+ self.__create_config_map_object(code, job)
147
+ self.__create_environment_secret()
148
+ if self.registry_pat:
149
+ self.__create_registry_credentials_secret()
150
+
151
+ return job
152
+
153
+ def __create_config_map_object(self, code: str, job: client.V1Job):
154
+ # Configureate ConfigMap from a local file
155
+ configmap = client.V1ConfigMap(
156
+ api_version="v1",
157
+ kind="ConfigMap",
158
+ data={"main.py": code},
159
+ metadata=client.V1ObjectMeta(
160
+ name=self.name,
161
+ owner_references=[
162
+ client.V1OwnerReference(
163
+ api_version="v1",
164
+ kind="Job",
165
+ name=job.metadata.name,
166
+ uid=job.metadata.uid,
167
+ # block_owner_deletion=True,
168
+ # controller=True,
169
+ )
170
+ ],
171
+ ),
172
+ )
173
+
174
+ self.core_api_instance.create_namespaced_config_map(
175
+ namespace=self.namespace, body=configmap
176
+ )
177
+
178
+ logging.info("ConfigMap created.")
179
+
180
+ def __create_environment_secret(self):
181
+ data = {}
182
+
183
+ for key in self.__env.keys():
184
+ data[key] = base64.b64encode(self.__env[key].encode()).decode()
185
+
186
+ secret = client.V1Secret(
187
+ api_version="v1",
188
+ kind="Secret",
189
+ type="Opaque",
190
+ immutable=True,
191
+ data=data,
192
+ metadata=client.V1ObjectMeta(
193
+ name=self.name,
194
+ namespace=self.namespace,
195
+ # owner_references=[
196
+ # client.V1OwnerReference(
197
+ # api_version="v1",
198
+ # kind="Job",
199
+ # name=job.metadata.name,
200
+ # uid=job.metadata.uid,
201
+ # # block_owner_deletion=True,
202
+ # # controller=True,
203
+ # )
204
+ # ],
205
+ ),
206
+ )
207
+
208
+ self.core_api_instance.create_namespaced_secret(
209
+ namespace=self.namespace, body=secret
210
+ )
211
+
212
+ logging.info("Environment created.")
213
+
214
+ def __create_registry_credentials_secret(self):
215
+ segments = self.container_image.split("/")
216
+ # Find user name for images on Docker Hub
217
+ username = segments[0] if len(segments) == 2 else segments[1]
218
+ registry = segments[0] if len(segments) == 3 else "https://index.docker.io/v1/"
219
+
220
+ config = {
221
+ "auths": {
222
+ registry: {
223
+ "auth": base64.b64encode(
224
+ f"{username}:{self.registry_pat}".encode()
225
+ ).decode(),
226
+ }
227
+ }
228
+ }
229
+
230
+ secret = client.V1Secret(
231
+ api_version="v1",
232
+ kind="Secret",
233
+ type="kubernetes.io/dockerconfigjson",
234
+ immutable=True,
235
+ metadata=client.V1ObjectMeta(
236
+ name=self.__registry_credentials_secret_name(),
237
+ namespace=self.namespace,
238
+ ),
239
+ data={
240
+ ".dockerconfigjson": base64.b64encode(
241
+ JSONEncoder().encode(config).encode()
242
+ ).decode(),
243
+ },
244
+ )
245
+
246
+ self.core_api_instance.create_namespaced_secret(
247
+ namespace=self.namespace, body=secret
248
+ )
249
+
250
+ logging.info("Registry credentials created.")
251
+
252
+ def __delete_job(self):
253
+ if self.registry_pat:
254
+ self.core_api_instance.delete_namespaced_secret(
255
+ self.__registry_credentials_secret_name(), self.namespace
256
+ )
257
+ logging.info("Registry credentials removed")
258
+
259
+ self.core_api_instance.delete_namespaced_secret(self.name, self.namespace)
260
+ logging.info("Environment removed.")
261
+
262
+ self.core_api_instance.delete_namespaced_config_map(
263
+ self.name,
264
+ self.namespace,
265
+ body=client.V1DeleteOptions(propagation_policy="Foreground"),
266
+ )
267
+
268
+ api_response = self.batch_api_instance.delete_namespaced_job(
269
+ self.name,
270
+ self.namespace,
271
+ body=client.V1DeleteOptions(propagation_policy="Foreground"),
272
+ _preload_content=False,
273
+ )
274
+
275
+ try:
276
+ data = loads(api_response.data)
277
+
278
+ logging.info(
279
+ "Job removed. status='%s'"
280
+ % str(data["status"]["conditions"][0]["type"])
281
+ )
282
+ except:
283
+ logging.info(api_response.status)
284
+ logging.info("Job removed. status='%s'" % str(api_response.status))
285
+
286
+ def __get_job_logs(self, name="qiskit-aer-gpu"):
287
+ api_response = self.core_api_instance.read_namespaced_pod_log(
288
+ name=name, namespace=self.namespace
289
+ )
290
+ logging.debug("Job logs='%s'" % str(api_response))
291
+ return api_response
292
+
293
+ def __get_pods_in_job(self):
294
+ pods = self.core_api_instance.list_namespaced_pod(
295
+ self.namespace, label_selector=f"app={self.name}"
296
+ )
297
+
298
+ pod_name = pods.items[0].metadata.name
299
+
300
+ logging.info("Pods in the job='%s'" % str(pod_name))
301
+
302
+ return pod_name
303
+
304
+ def __complete_and_get_job_status(self):
305
+ pod_name = None
306
+ job_completed = False
307
+
308
+ while not job_completed:
309
+ api_response = self.batch_api_instance.read_namespaced_job_status(
310
+ self.name, self.namespace
311
+ )
312
+
313
+ logging.debug("Job status='%s'" % str(api_response.status.start_time))
314
+
315
+ if (
316
+ api_response.status.succeeded is not None
317
+ or api_response.status.failed is not None
318
+ ):
319
+ job_completed = True
320
+ elif api_response.status.active is not None:
321
+ if pod_name is None:
322
+ pod_name = self.__get_pods_in_job()
323
+
324
+ s = self.core_api_instance.read_namespaced_pod_status(
325
+ name=pod_name,
326
+ namespace=self.namespace,
327
+ )
328
+
329
+ try:
330
+ if s.status.container_statuses[0].state.terminated is not None:
331
+ if (
332
+ s.status.container_statuses[0].state.terminated.exit_code
333
+ == 0
334
+ ):
335
+ job_completed = True
336
+ except TypeError:
337
+ pass
338
+ finally:
339
+ logging.info("Pod status='%s'" % str(s.status.phase))
340
+ if self.jupyter_logger is not None:
341
+ self.jupyter_logger(f"Pod status: {s.status.phase}")
342
+
343
+ sleep(1)
344
+
345
+ return self.__map_job_status_to_stream(api_response.status)
346
+
347
+ def __map_job_status_to_stream(self, status):
348
+ logging.debug("Job status='%s'" % str(status))
349
+ if status.succeeded is not None:
350
+ return "stdout"
351
+ elif status.failed is not None:
352
+ return "stderr"
353
+ else:
354
+ return "unknown"
355
+
356
+ def __prepare_environment(self):
357
+ env = []
358
+
359
+ for key in self.__env.keys():
360
+ env.append(
361
+ client.V1EnvVar(
362
+ name=key,
363
+ value_from=client.V1EnvVarSource(
364
+ secret_key_ref=client.V1SecretKeySelector(
365
+ name=self.name, key=key
366
+ )
367
+ ),
368
+ )
369
+ )
370
+
371
+ return env
372
+
373
+ def execute(self, code: str) -> tuple[str, str]:
374
+ try:
375
+ self.__create_job_object(code=code)
376
+
377
+ if self.jupyter_logger is not None:
378
+ self.jupyter_logger(f"Job {self.name} created")
379
+
380
+ stream = self.__complete_and_get_job_status()
381
+
382
+ job = self.__get_pods_in_job()
383
+ logs = self.__get_job_logs(job)
384
+
385
+ return logs, stream
386
+ except KeyboardInterrupt:
387
+ return "Task interrupted by user", "stderr"
388
+ except:
389
+ logging.info("An error occurred.")
390
+ return "An error occurred.", "stderr"
391
+ finally:
392
+ self.__delete_job()
393
+
394
+ def abort(self):
395
+ self.__delete_job()
q8s/install.py ADDED
@@ -0,0 +1,73 @@
1
+ import json
2
+ import os
3
+ import sys
4
+ import argparse
5
+ import pathlib
6
+ import shutil
7
+
8
+ from jupyter_client.kernelspec import KernelSpecManager
9
+ from IPython.utils.tempdir import TemporaryDirectory
10
+
11
+ # from .resources import _ICON_PATH
12
+
13
+ kernel_json = {
14
+ "argv": [sys.executable, "-m", "q8s", "-f", "{connection_file}"],
15
+ "display_name": "Q8s kernel",
16
+ "language": "python",
17
+ }
18
+
19
+
20
+ def install_my_kernel_spec(user=True, prefix=None):
21
+ with TemporaryDirectory() as td:
22
+ os.chmod(td, 0o755) # Starts off as 700, not user readable
23
+ with open(os.path.join(td, "kernel.json"), "w") as f:
24
+ json.dump(kernel_json, f, sort_keys=True)
25
+ # shutil.copyfile(_ICON_PATH, pathlib.Path(td) / _ICON_PATH.name)
26
+ print("Installing q8s kernel spec")
27
+ KernelSpecManager().install_kernel_spec(td, "q8s", user=user, prefix=prefix)
28
+
29
+
30
+ def _is_root():
31
+ try:
32
+ return os.geteuid() == 0
33
+ except AttributeError:
34
+ return False # assume not an admin on non-Unix platforms
35
+
36
+
37
+ def main(argv=None):
38
+ parser = argparse.ArgumentParser(
39
+ description="Install KernelSpec for Qubernetes Kernel"
40
+ )
41
+ prefix_locations = parser.add_mutually_exclusive_group()
42
+
43
+ prefix_locations.add_argument(
44
+ "--user",
45
+ help="Install KernelSpec in user's home directory",
46
+ action="store_true",
47
+ )
48
+ prefix_locations.add_argument(
49
+ "--sys-prefix",
50
+ help="Install KernelSpec in sys.prefix. Useful in conda / virtualenv",
51
+ action="store_true",
52
+ dest="sys_prefix",
53
+ )
54
+ prefix_locations.add_argument(
55
+ "--prefix", help="Install KernelSpec in this prefix", default=None
56
+ )
57
+
58
+ args = parser.parse_args(argv)
59
+
60
+ user = False
61
+ prefix = None
62
+ if args.sys_prefix:
63
+ prefix = sys.prefix
64
+ elif args.prefix:
65
+ prefix = args.prefix
66
+ elif args.user or not _is_root():
67
+ user = True
68
+
69
+ install_my_kernel_spec(user=user, prefix=prefix)
70
+
71
+
72
+ if __name__ == "__main__":
73
+ main()