q8s 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.
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,77 @@
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, Target
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
+ target: Annotated[
21
+ Target, typer.Option(help="Execution target", case_sensitive=False)
22
+ ] = Target.gpu,
23
+ kubeconfig: Annotated[
24
+ Path, typer.Option(help="Kubernetes configuration", envvar="KUBECONFIG")
25
+ ] = None,
26
+ image: Annotated[str, typer.Option(help="Docker image")] = "vstirbu/benchmark-deps",
27
+ registry_pat: Annotated[
28
+ str,
29
+ typer.Option(
30
+ help="Registry personal access token (PAT)",
31
+ envvar="REGISTRY_PAT",
32
+ ),
33
+ ] = None,
34
+ ):
35
+ if kubeconfig.exists() is False:
36
+ typer.echo(f"kubeconfig file {kubeconfig} does not exist")
37
+ raise typer.Exit(code=1)
38
+
39
+ kubeconfig = os.environ.get("KUBECONFIG", kubeconfig.as_posix())
40
+
41
+ if kubeconfig is None:
42
+ typer.echo("KUBECONFIG not set")
43
+ raise typer.Exit(code=1)
44
+
45
+ # config.load_kube_config(kubeconfig)
46
+
47
+ k8s_context = K8sContext(kubeconfig)
48
+ k8s_context.set_target(target)
49
+ k8s_context.set_container_image(image)
50
+ k8s_context.set_registry_pat(registry_pat)
51
+
52
+ with open(file, "r") as f:
53
+ code = f.read()
54
+ # output, stream_name = execute_k8s(code, None, image, registry_pat)
55
+ output, stream_name = k8s_context.execute(code)
56
+
57
+ print(f"output:\n{output}")
58
+ print(f"output stream: {stream_name}")
59
+
60
+
61
+ @app.command()
62
+ def jupyter(
63
+ install: Annotated[
64
+ bool,
65
+ typer.Option(
66
+ help="Install kernel spec for Jupyter",
67
+ ),
68
+ ] = False,
69
+ ):
70
+ print("install:", install)
71
+ if install:
72
+ install_my_kernel_spec(user=False, prefix=sys.prefix)
73
+ # install_my_kernel_spec(user=user, prefix=prefix)
74
+
75
+
76
+ # if __name__ == "__main__":
77
+ 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,454 @@
1
+ import base64
2
+ from enum import Enum
3
+ from json import JSONEncoder, loads
4
+ import logging
5
+ import os
6
+ import random
7
+ import string
8
+ from time import sleep
9
+ from dotenv import dotenv_values
10
+ from kubernetes import client, config
11
+
12
+
13
+ FORMAT = "[%(levelname)s %(asctime)-15s q8s_context] %(message)s"
14
+ logging.basicConfig(level=logging.INFO, format=FORMAT)
15
+
16
+ MEMORY = os.environ.get("MEMORY", "32Gi")
17
+
18
+
19
+ class Target(str, Enum):
20
+ cpu = "cpu"
21
+ gpu = "gpu"
22
+ qpu = "qpu"
23
+
24
+
25
+ def load_env():
26
+ env = dotenv_values(".env.q8s")
27
+
28
+ # for key in env.keys():
29
+ # logging.info(f"{key} = {env[key]}")
30
+
31
+ return env
32
+
33
+
34
+ class K8sContext:
35
+ container_image: str = "vsirbu/benchmark-deps"
36
+ registry_pat: str | None = None
37
+ jupyter_logger: None
38
+ target: Target = Target.gpu
39
+
40
+ def __init__(self, kubeconfig: str, logger=None):
41
+ """
42
+ Initialize the Kubernetes context.
43
+ """
44
+ config.load_kube_config(kubeconfig)
45
+ logging.info("Kubeconfig loaded")
46
+
47
+ _, active_context = config.list_kube_config_contexts(config_file=kubeconfig)
48
+
49
+ try:
50
+ self.namespace = active_context["context"]["namespace"]
51
+ except KeyError:
52
+ self.namespace = "default"
53
+ logging.info("Active namespace: %s" % self.namespace)
54
+
55
+ self.core_api_instance = client.CoreV1Api()
56
+ self.batch_api_instance = client.BatchV1Api()
57
+
58
+ self.name = f"qubernetes-job-{K8sContext.get_id()}"
59
+
60
+ self.__env = load_env()
61
+
62
+ self.jupyter_logger = logger
63
+
64
+ @staticmethod
65
+ def get_id():
66
+ """
67
+ Generate a random ID.
68
+ """
69
+ return "".join(random.choices(string.ascii_lowercase + string.digits, k=6))
70
+
71
+ def set_container_image(self, image: str):
72
+ self.container_image = image
73
+
74
+ def set_registry_pat(self, pat: str):
75
+ self.registry_pat = pat
76
+
77
+ def set_target(self, target: Target):
78
+ self.target = target
79
+
80
+ def create_job_object(self, code: str) -> client.V1Job:
81
+ return None
82
+
83
+ def __registry_credentials_secret_name(self):
84
+ return f"{self.name}-regcred"
85
+
86
+ def __create_job_object(self, code: str):
87
+ """
88
+ Create a job object with the given code.
89
+ """
90
+ env = self.__prepare_environment()
91
+
92
+ # Configureate Pod template container
93
+ container = client.V1Container(
94
+ name=self.name,
95
+ image=self.container_image,
96
+ env=env,
97
+ command=["python3"],
98
+ args=["./app/main.py"],
99
+ resources=(
100
+ client.V1ResourceRequirements(
101
+ limits=(
102
+ {
103
+ "cpu": "2",
104
+ "ephemeral-storage": "50Gi",
105
+ "memory": MEMORY,
106
+ "nvidia.com/gpu": "1",
107
+ # "qubernetes.dev/qpu": "1",
108
+ }
109
+ ),
110
+ requests=(
111
+ {
112
+ "cpu": "2",
113
+ "ephemeral-storage": "0",
114
+ "memory": MEMORY,
115
+ "nvidia.com/gpu": "1",
116
+ # "qubernetes.dev/qpu": "1",
117
+ }
118
+ ),
119
+ )
120
+ if self.target == Target.gpu
121
+ else None
122
+ ),
123
+ volume_mounts=[
124
+ client.V1VolumeMount(
125
+ name="app-volume", mount_path="/app", read_only=True
126
+ )
127
+ ],
128
+ )
129
+
130
+ # Create and configurate a spec section
131
+ template = client.V1PodTemplateSpec(
132
+ metadata=client.V1ObjectMeta(labels={"app": self.name}),
133
+ spec=client.V1PodSpec(
134
+ containers=[container],
135
+ image_pull_secrets=(
136
+ [
137
+ client.V1LocalObjectReference(
138
+ name=self.__registry_credentials_secret_name()
139
+ )
140
+ ]
141
+ if self.registry_pat
142
+ else []
143
+ ),
144
+ runtime_class_name="nvidia" if self.target == Target.gpu else None,
145
+ restart_policy="Never",
146
+ volumes=[
147
+ client.V1Volume(
148
+ name="app-volume",
149
+ config_map=client.V1ConfigMapVolumeSource(name=self.name),
150
+ )
151
+ ],
152
+ ),
153
+ )
154
+
155
+ # Create the specification of deployment
156
+ spec = client.V1JobSpec(template=template) # , ttl_seconds_after_finished=10
157
+
158
+ # Instantiate the job object
159
+ job_spec = client.V1Job(
160
+ api_version="batch/v1",
161
+ kind="Job",
162
+ metadata=client.V1ObjectMeta(
163
+ name=self.name,
164
+ namespace=self.namespace,
165
+ labels={"qubernetes.dev/job.type": "jupyter"},
166
+ ),
167
+ spec=spec,
168
+ )
169
+
170
+ job = self.batch_api_instance.create_namespaced_job(
171
+ body=job_spec, namespace=self.namespace
172
+ )
173
+ logging.info("Job created")
174
+
175
+ self.__create_config_map_object(code, job)
176
+ self.__create_environment_secret()
177
+ if self.registry_pat:
178
+ self.__create_registry_credentials_secret()
179
+
180
+ return job
181
+
182
+ def __create_config_map_object(self, code: str, job: client.V1Job):
183
+ """
184
+ Create a ConfigMap object with the given code.
185
+ """
186
+ # Configureate ConfigMap from a local file
187
+ configmap = client.V1ConfigMap(
188
+ api_version="v1",
189
+ kind="ConfigMap",
190
+ data={"main.py": code},
191
+ metadata=client.V1ObjectMeta(
192
+ name=self.name,
193
+ owner_references=[
194
+ client.V1OwnerReference(
195
+ api_version="v1",
196
+ kind="Job",
197
+ name=job.metadata.name,
198
+ uid=job.metadata.uid,
199
+ # block_owner_deletion=True,
200
+ # controller=True,
201
+ )
202
+ ],
203
+ ),
204
+ )
205
+
206
+ self.core_api_instance.create_namespaced_config_map(
207
+ namespace=self.namespace, body=configmap
208
+ )
209
+
210
+ logging.info("ConfigMap created.")
211
+
212
+ def __create_environment_secret(self):
213
+ """
214
+ Create a Secret object with the environment variables.
215
+ """
216
+ data = {}
217
+
218
+ for key in self.__env.keys():
219
+ data[key] = base64.b64encode(self.__env[key].encode()).decode()
220
+
221
+ secret = client.V1Secret(
222
+ api_version="v1",
223
+ kind="Secret",
224
+ type="Opaque",
225
+ immutable=True,
226
+ data=data,
227
+ metadata=client.V1ObjectMeta(
228
+ name=self.name,
229
+ namespace=self.namespace,
230
+ # owner_references=[
231
+ # client.V1OwnerReference(
232
+ # api_version="v1",
233
+ # kind="Job",
234
+ # name=job.metadata.name,
235
+ # uid=job.metadata.uid,
236
+ # # block_owner_deletion=True,
237
+ # # controller=True,
238
+ # )
239
+ # ],
240
+ ),
241
+ )
242
+
243
+ self.core_api_instance.create_namespaced_secret(
244
+ namespace=self.namespace, body=secret
245
+ )
246
+
247
+ logging.info("Environment created.")
248
+
249
+ def __create_registry_credentials_secret(self):
250
+ """
251
+ Create a Secret object with the registry credentials.
252
+ """
253
+ segments = self.container_image.split("/")
254
+ # Find user name for images on Docker Hub
255
+ username = segments[0] if len(segments) == 2 else segments[1]
256
+ registry = segments[0] if len(segments) == 3 else "https://index.docker.io/v1/"
257
+
258
+ config = {
259
+ "auths": {
260
+ registry: {
261
+ "auth": base64.b64encode(
262
+ f"{username}:{self.registry_pat}".encode()
263
+ ).decode(),
264
+ }
265
+ }
266
+ }
267
+
268
+ secret = client.V1Secret(
269
+ api_version="v1",
270
+ kind="Secret",
271
+ type="kubernetes.io/dockerconfigjson",
272
+ immutable=True,
273
+ metadata=client.V1ObjectMeta(
274
+ name=self.__registry_credentials_secret_name(),
275
+ namespace=self.namespace,
276
+ ),
277
+ data={
278
+ ".dockerconfigjson": base64.b64encode(
279
+ JSONEncoder().encode(config).encode()
280
+ ).decode(),
281
+ },
282
+ )
283
+
284
+ self.core_api_instance.create_namespaced_secret(
285
+ namespace=self.namespace, body=secret
286
+ )
287
+
288
+ logging.info("Registry credentials created.")
289
+
290
+ def __delete_job(self):
291
+ """
292
+ Delete the job and its associated resources.
293
+ """
294
+ if self.registry_pat:
295
+ self.core_api_instance.delete_namespaced_secret(
296
+ self.__registry_credentials_secret_name(), self.namespace
297
+ )
298
+ logging.info("Registry credentials removed")
299
+
300
+ self.core_api_instance.delete_namespaced_secret(self.name, self.namespace)
301
+ logging.info("Environment removed.")
302
+
303
+ self.core_api_instance.delete_namespaced_config_map(
304
+ self.name,
305
+ self.namespace,
306
+ body=client.V1DeleteOptions(propagation_policy="Foreground"),
307
+ )
308
+
309
+ api_response = self.batch_api_instance.delete_namespaced_job(
310
+ self.name,
311
+ self.namespace,
312
+ body=client.V1DeleteOptions(propagation_policy="Foreground"),
313
+ _preload_content=False,
314
+ )
315
+
316
+ try:
317
+ data = loads(api_response.data)
318
+
319
+ logging.info(
320
+ "Job removed. status='%s'"
321
+ % str(data["status"]["conditions"][0]["type"])
322
+ )
323
+ except:
324
+ logging.info(api_response.status)
325
+ logging.info("Job removed. status='%s'" % str(api_response.status))
326
+
327
+ def __get_job_logs(self, name="qiskit-aer-gpu"):
328
+ """
329
+ Get the logs of the job.
330
+ """
331
+ api_response = self.core_api_instance.read_namespaced_pod_log(
332
+ name=name, namespace=self.namespace
333
+ )
334
+ logging.debug("Job logs='%s'" % str(api_response))
335
+ return api_response
336
+
337
+ def __get_pods_in_job(self):
338
+ """
339
+ Get the pods in the job.
340
+ """
341
+ pods = self.core_api_instance.list_namespaced_pod(
342
+ self.namespace, label_selector=f"app={self.name}"
343
+ )
344
+
345
+ pod_name = pods.items[0].metadata.name
346
+
347
+ logging.info("Pods in the job='%s'" % str(pod_name))
348
+
349
+ return pod_name
350
+
351
+ def __complete_and_get_job_status(self):
352
+ """
353
+ Wait for the job to complete and get its status.
354
+ """
355
+ pod_name = None
356
+ job_completed = False
357
+
358
+ while not job_completed:
359
+ api_response = self.batch_api_instance.read_namespaced_job_status(
360
+ self.name, self.namespace
361
+ )
362
+
363
+ logging.debug("Job status='%s'" % str(api_response.status.start_time))
364
+
365
+ if (
366
+ api_response.status.succeeded is not None
367
+ or api_response.status.failed is not None
368
+ ):
369
+ job_completed = True
370
+ elif api_response.status.active is not None:
371
+ if pod_name is None:
372
+ pod_name = self.__get_pods_in_job()
373
+
374
+ s = self.core_api_instance.read_namespaced_pod_status(
375
+ name=pod_name,
376
+ namespace=self.namespace,
377
+ )
378
+
379
+ try:
380
+ if s.status.container_statuses[0].state.terminated is not None:
381
+ if (
382
+ s.status.container_statuses[0].state.terminated.exit_code
383
+ == 0
384
+ ):
385
+ job_completed = True
386
+ except TypeError:
387
+ pass
388
+ finally:
389
+ logging.info("Pod status='%s'" % str(s.status.phase))
390
+ if self.jupyter_logger is not None:
391
+ self.jupyter_logger(f"Pod status: {s.status.phase}")
392
+
393
+ sleep(1)
394
+
395
+ return self.__map_job_status_to_stream(api_response.status)
396
+
397
+ def __map_job_status_to_stream(self, status):
398
+ logging.debug("Job status='%s'" % str(status))
399
+ if status.succeeded is not None:
400
+ return "stdout"
401
+ elif status.failed is not None:
402
+ return "stderr"
403
+ else:
404
+ return "unknown"
405
+
406
+ def __prepare_environment(self):
407
+ """
408
+ Prepare the environment variables.
409
+ """
410
+ env = []
411
+
412
+ for key in self.__env.keys():
413
+ env.append(
414
+ client.V1EnvVar(
415
+ name=key,
416
+ value_from=client.V1EnvVarSource(
417
+ secret_key_ref=client.V1SecretKeySelector(
418
+ name=self.name, key=key
419
+ )
420
+ ),
421
+ )
422
+ )
423
+
424
+ return env
425
+
426
+ def execute(self, code: str) -> tuple[str, str]:
427
+ """
428
+ Execute the given code.
429
+ """
430
+ try:
431
+ self.__create_job_object(code=code)
432
+
433
+ if self.jupyter_logger is not None:
434
+ self.jupyter_logger(f"Job {self.name} created")
435
+
436
+ stream = self.__complete_and_get_job_status()
437
+
438
+ job = self.__get_pods_in_job()
439
+ logs = self.__get_job_logs(job)
440
+
441
+ return logs, stream
442
+ except KeyboardInterrupt:
443
+ return "Task interrupted by user", "stderr"
444
+ except:
445
+ logging.info("An error occurred.")
446
+ return "An error occurred.", "stderr"
447
+ finally:
448
+ self.__delete_job()
449
+
450
+ def abort(self):
451
+ """
452
+ Abort the execution.
453
+ """
454
+ self.__delete_job()