ngapp 0.0.2.dev2236__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.
ngapp/app.py ADDED
@@ -0,0 +1,618 @@
1
+ # pylint: disable=invalid-name
2
+ """Base class for all applications"""
3
+
4
+ import base64
5
+ import copy
6
+ import hashlib
7
+ import importlib.util
8
+ import json
9
+ import os
10
+ import os.path
11
+ import subprocess
12
+ import sys
13
+ import tempfile
14
+ import time
15
+ import types
16
+ from contextlib import contextmanager
17
+ from enum import IntEnum
18
+ from pathlib import Path
19
+
20
+ import pydantic
21
+ import urllib3
22
+
23
+ from . import api, utils
24
+ from .components.basecomponent import AppStatus, Component, reset_components
25
+ from .utils import (
26
+ ComputeEnvironment,
27
+ EnvironmentType,
28
+ get_environment,
29
+ is_pyodide,
30
+ read_file,
31
+ read_file_binary,
32
+ read_json,
33
+ )
34
+
35
+
36
+ def asset(filename: Path | str, binary: bool | None = None, module=None) -> str:
37
+ """Load an asset from the 'assets' directory within an application"""
38
+ import inspect
39
+
40
+ filename = Path(filename)
41
+ file_format = filename.suffix[1:].lower()
42
+ if file_format == "svg":
43
+ file_format = "svg+xml"
44
+ if module:
45
+ calling_file = Path(inspect.getfile(module))
46
+ else:
47
+ calling_file = Path(inspect.stack()[1].filename)
48
+ filename = calling_file.parent / "assets" / filename
49
+
50
+ if not filename.exists():
51
+ raise FileNotFoundError(f"Asset file {filename} not found.")
52
+
53
+ if binary is None:
54
+ binary = filename.suffix in [".png", ".jpg", ".jpeg", ".gif", ".svg"]
55
+
56
+ if binary:
57
+ data = base64.b64encode(read_file_binary(filename)).decode("ascii")
58
+ else:
59
+ data = read_file(filename)
60
+
61
+ if file_format in ["png", "jpg", "jpeg", "gif", "svg+xml"]:
62
+ data = f"data:image/{file_format};base64,{data}"
63
+
64
+ return data
65
+
66
+
67
+ class AccessLevel(IntEnum):
68
+ """Access levels enum to distinguish acess rights (HIDDEN, ADMIN, etc.)"""
69
+
70
+ HIDDEN = 0
71
+ VISIBLE = 5
72
+ TRIAL = 10
73
+ STANDARD = 20
74
+ ADMIN = 9000
75
+
76
+
77
+ class AccessLevelConfig(pydantic.BaseModel):
78
+ """Defines the restrictions of access to an Application for a certain access level"""
79
+
80
+ model_config = pydantic.ConfigDict(extra="forbid")
81
+
82
+ name: str
83
+ access_level: AccessLevel = AccessLevel.HIDDEN
84
+ max_cpus: int = 0
85
+ max_memory: str = "0G"
86
+ max_saved_results: int = 0
87
+
88
+
89
+ class AppAccessConfig(pydantic.BaseModel):
90
+ """Defines the restrictions of access to an Application for a certain access level"""
91
+
92
+ model_config = pydantic.ConfigDict(extra="forbid")
93
+
94
+ default_level: AccessLevel = AccessLevel.STANDARD
95
+ enable_trial: bool = False
96
+ auto_grant_trial: bool = False
97
+ trial_days: int = 21
98
+ levels: dict[AccessLevel, AccessLevelConfig] = {
99
+ AccessLevel.HIDDEN: AccessLevelConfig(
100
+ access_level=AccessLevel.HIDDEN,
101
+ name="hidden",
102
+ ),
103
+ AccessLevel.VISIBLE: AccessLevelConfig(
104
+ access_level=AccessLevel.VISIBLE,
105
+ name="visible",
106
+ ),
107
+ AccessLevel.TRIAL: AccessLevelConfig(
108
+ access_level=AccessLevel.TRIAL,
109
+ name="trial",
110
+ max_memory="14G",
111
+ max_cpus=4,
112
+ max_saved_results=20,
113
+ ),
114
+ AccessLevel.STANDARD: AccessLevelConfig(
115
+ access_level=AccessLevel.STANDARD,
116
+ name="standard",
117
+ max_memory="14G",
118
+ max_cpus=4,
119
+ max_saved_results=20,
120
+ ),
121
+ AccessLevel.ADMIN: AccessLevelConfig(
122
+ access_level=AccessLevel.ADMIN,
123
+ name="admin",
124
+ max_memory="60G",
125
+ max_cpus=20,
126
+ max_saved_results=999,
127
+ ),
128
+ }
129
+
130
+
131
+ class PythonPackage(pydantic.BaseModel):
132
+ """Python package model"""
133
+
134
+ file_name: str
135
+ package: bytes | None
136
+
137
+ model_config = pydantic.ConfigDict(ser_json_bytes="base64")
138
+
139
+
140
+ class AppConfig(pydantic.BaseModel):
141
+ """App configuration class, contains all metadata about an app"""
142
+
143
+ model_config = pydantic.ConfigDict(extra="forbid")
144
+
145
+ name: str
146
+ version: str
147
+
148
+ python_class: str
149
+ frontend_pip_dependencies: list[str] = []
150
+ frontend_dependencies: list[str] = []
151
+
152
+ description: str = "No description."
153
+ image: str | None = None
154
+ logo: dict = {}
155
+
156
+ compute_environments: list[ComputeEnvironment] = []
157
+ access: AppAccessConfig = AppAccessConfig()
158
+
159
+ frontend_package: bytes = b""
160
+ python_packages: list[PythonPackage] = []
161
+ python_packages_hash: str = ""
162
+
163
+ def __init__(self, python_class, **kwargs):
164
+ if not isinstance(python_class, str):
165
+ python_class = python_class.__module__ + "." + python_class.__name__
166
+ super().__init__(python_class=python_class, **kwargs)
167
+
168
+ @property
169
+ def python_package_name(self):
170
+ return self.python_class.split(".")[0]
171
+
172
+ def create_frontend_package(self):
173
+ self.frontend_package = utils.zip_modules(
174
+ [self.python_package_name] + self.frontend_dependencies
175
+ )
176
+
177
+ def create_backend_packages(self, include_dependencies=True):
178
+ if len(self.compute_environments) == 0:
179
+ return
180
+
181
+ origin = Path(importlib.util.find_spec(self.python_package_name).origin)
182
+ src_dir = None
183
+ for _ in range(3):
184
+ origin = origin.parent
185
+ for filename in ["pyproject.toml", "setup.py"]:
186
+ if (origin / filename).exists():
187
+ src_dir = origin
188
+ break
189
+ if src_dir is None:
190
+ raise RuntimeError(
191
+ f"Could not find source directory for package {self.python_package_name}"
192
+ )
193
+
194
+ with tempfile.TemporaryDirectory() as temp_dir_:
195
+ temp_dir = Path(temp_dir_)
196
+ subprocess.run(
197
+ [
198
+ sys.executable,
199
+ "-m",
200
+ "build",
201
+ "-n",
202
+ "-w",
203
+ "-o",
204
+ temp_dir,
205
+ src_dir,
206
+ ],
207
+ check=True,
208
+ )
209
+
210
+ package_hash = hashlib.sha256()
211
+ packages = []
212
+
213
+ dirs = [temp_dir]
214
+ if include_dependencies:
215
+ dirs.append(src_dir / "dependencies")
216
+
217
+ for d in dirs:
218
+ if not d.exists():
219
+ continue
220
+ for filename in sorted(os.listdir(d)):
221
+ if filename.endswith(".whl"):
222
+ data = (d / filename).read_bytes()
223
+ package_hash.update(data)
224
+ packages.append(
225
+ PythonPackage(
226
+ file_name=filename,
227
+ package=data,
228
+ )
229
+ )
230
+ self.python_packages = packages
231
+ self.python_packages_hash = package_hash.hexdigest()
232
+
233
+
234
+ class App:
235
+ """Base class for all applications"""
236
+
237
+ component: Component
238
+ metadata: dict
239
+ _default_data: dict | None
240
+ _status: AppStatus
241
+
242
+ def __init__(self, component: Component | None = None, name=None):
243
+ self.metadata = {"name": name}
244
+ self._default_data = None
245
+ self._status = AppStatus()
246
+ self._status.app = self
247
+
248
+ if component is not None:
249
+ self.component = component
250
+ component._namespace_id = ""
251
+ component._parent = self
252
+ component._status = self._status
253
+ component._recurse(Component._calc_namespace_id, True, set())
254
+
255
+ def __getitem__(self, key: str) -> Component:
256
+ return self._status.components_by_id[key]
257
+
258
+ @property
259
+ def assets_path(self) -> Path:
260
+ """Get the path to the assets directory in the python package of the app"""
261
+ return (
262
+ Path(sys.modules[self.__module__.split(".")[0]].__file__).parent / "assets"
263
+ )
264
+
265
+ def set_colors(
266
+ self,
267
+ primary: str | None = None,
268
+ secondary: str | None = None,
269
+ accent: str | None = None,
270
+ dark: str | None = None,
271
+ positive: str | None = None,
272
+ negative: str | None = None,
273
+ info: str | None = None,
274
+ warning: str | None = None,
275
+ ):
276
+ """Set the colors of the app"""
277
+ if get_environment().type == EnvironmentType.LOCAL_APP:
278
+ from webgpu.platform import execute_when_init
279
+
280
+ def f(js):
281
+ if primary is not None:
282
+ js.document.body.style.setProperty("--q-primary", primary)
283
+ if secondary is not None:
284
+ js.document.body.style.setProperty("--q-secondary", secondary)
285
+ if accent is not None:
286
+ js.document.body.style.setProperty("--q-accent", accent)
287
+ if dark is not None:
288
+ js.document.body.style.setProperty("--q-dark", dark)
289
+ if positive is not None:
290
+ js.document.body.style.setProperty("--q-positive", positive)
291
+ if negative is not None:
292
+ js.document.body.style.setProperty("--q-negative", negative)
293
+ if info is not None:
294
+ js.document.body.style.setProperty("--q-info", info)
295
+ if warning is not None:
296
+ js.document.body.style.setProperty("--q-warning", warning)
297
+
298
+ execute_when_init(f)
299
+
300
+ @property
301
+ def name(self):
302
+ return self.metadata.get("name", "Untitled")
303
+
304
+ @name.setter
305
+ def name(self, value):
306
+ self.metadata["name"] = value
307
+
308
+ def upgrade(self, data):
309
+ """Upgrade data to the current version"""
310
+ return data
311
+
312
+ def dump(
313
+ self, exclude_default_data=False, keep_storage=False, include_storage_data=False
314
+ ):
315
+ """Get input data for storage"""
316
+ if self.component is None:
317
+ return {}
318
+ if (
319
+ not include_storage_data
320
+ and keep_storage
321
+ and not get_environment().have_backend
322
+ ):
323
+ self._save_storage_local()
324
+ component_data = {
325
+ "data": self.component._dump_recursive(),
326
+ "storage": self.component._dump_storage(include_storage_data),
327
+ }
328
+
329
+ if exclude_default_data:
330
+
331
+ def remove_default_data(data, default_data):
332
+ if isinstance(data, dict):
333
+ for key in list(data.keys()):
334
+ if key not in default_data:
335
+ return
336
+ if isinstance(data[key], dict) and isinstance(
337
+ default_data[key], dict
338
+ ):
339
+ if data[key] == default_data[key]:
340
+ del data[key]
341
+ else:
342
+ remove_default_data(data[key], default_data[key])
343
+
344
+ remove_default_data(component_data["data"], self._default_data["data"])
345
+ remove_default_data(
346
+ component_data["storage"], self._default_data["storage"]
347
+ )
348
+
349
+ return {"component": component_data, "metadata": self.metadata}
350
+
351
+ def save(self):
352
+ """Save data to backend"""
353
+ env = get_environment()
354
+ if not env.have_backend:
355
+ return
356
+
357
+ self.component._emit_recursive("before_save")
358
+ status = self._status
359
+ file_id = status.file_id
360
+ if file_id is None:
361
+ metadata = api.post(
362
+ "/create_model",
363
+ {"app_id": self.metadata["app_id"], "name": self.name or "Untitled"},
364
+ )
365
+ self.metadata |= metadata
366
+ status.file_id = metadata["id"]
367
+
368
+ api.put(f"/model/{status.file_id}", self.dump())
369
+ self.component._emit_recursive("save")
370
+
371
+ if env.type == EnvironmentType.PYODIDE:
372
+ env.frontend.set_query_parameter("fileId", status.file_id)
373
+
374
+ @classmethod
375
+ def reset(cls):
376
+ """
377
+ Reset the app in the user interface to clean initial state. Only available in frontend and local environment. Return value is the new app instance.
378
+ """
379
+ app = cls()
380
+ get_environment().frontend.reset_app(app)
381
+ return app
382
+
383
+ def save_local(self):
384
+ from webgpu import platform
385
+
386
+ dump = self.dump(include_storage_data=True)
387
+ name = self.name + ".sav" if self.name is not None else "untitled.sav"
388
+ options = {"suggestedName": name}
389
+ import pickle
390
+
391
+ pick = platform.js.showSaveFilePicker(options)
392
+ stream = pick.createWritable()
393
+ stream.write(pickle.dumps(dump))
394
+ stream.close()
395
+
396
+ def load_local(self):
397
+ from webgpu import platform
398
+
399
+ options = {"multiple": False, "accept": ".sav"}
400
+ pick = platform.js.showOpenFilePicker(options)
401
+ import pickle
402
+
403
+ data = pickle.loads(pick[0].getFile().arrayBuffer())
404
+ self.load(data)
405
+
406
+ def update(self, data: dict, load_local_storage=False, update_frontend=False):
407
+ """Update app with new data"""
408
+ metadata = data.get("metadata", None)
409
+ component_data = data.get(
410
+ "component", data.get("data", {}).get("component", {})
411
+ )
412
+
413
+ if metadata:
414
+ self.metadata.update(metadata)
415
+ self._status.app_id = metadata.get("app_id", None)
416
+ self._status.file_id = metadata.get("id", None)
417
+
418
+ if "storage" in component_data:
419
+ self.component._load_storage(component_data["storage"])
420
+
421
+ if load_local_storage:
422
+ self.component._load_storage_local()
423
+
424
+ if "data" in component_data:
425
+ self.component._load_recursive(
426
+ component_data["data"], update_frontend=update_frontend
427
+ )
428
+
429
+ def block_frontend_update(comp):
430
+ comp._block_frontend_update = True
431
+
432
+ self.component._recurse(block_frontend_update, True, set())
433
+ self.component._emit_recursive("load")
434
+
435
+ def unblock_frontend_update(comp):
436
+ comp._block_frontend_update = False
437
+
438
+ self.component._recurse(unblock_frontend_update, True, set())
439
+ if is_pyodide() and update_frontend:
440
+ import webapp_frontend
441
+
442
+ webapp_frontend.reload(
443
+ webapp_frontend.to_js(self.component._get_my_wrapper_props())
444
+ )
445
+
446
+ def load(self, data, load_local_storage=False, update_frontend=is_pyodide()):
447
+ """Load app from stored data"""
448
+ self.component._namespace_id = ""
449
+ self.component._parent = self
450
+ self.component._status = self._status
451
+ self._namespace_id = ""
452
+
453
+ self.component._recurse(Component._calc_namespace_id, True, set())
454
+
455
+ component_data = data.get("component", {})
456
+
457
+ if self._default_data and "data" in component_data:
458
+ # we are hot-reloading the app, this means we skipped the default data when dumping before
459
+ # so we need to update the data with the default data
460
+ def update_props(data, default_data):
461
+ for key, value in default_data.items():
462
+ if not key in data:
463
+ data[key] = copy.deepcopy(value)
464
+ continue
465
+ if isinstance(value, dict):
466
+ update_props(value, default_data[key])
467
+ data[key] = copy.deepcopy(value)
468
+
469
+ self.update(
470
+ data,
471
+ load_local_storage=load_local_storage,
472
+ update_frontend=update_frontend,
473
+ )
474
+
475
+ def report_context(self) -> tuple[dict, dict]:
476
+ """
477
+ Returns the context and images for the report (docx, md).
478
+ """
479
+ return ({}, {})
480
+
481
+ def load_asset(self, filename: str) -> str:
482
+ """
483
+ Load an asset from the 'assets' directory within an application.
484
+ If the asset is an image, it will be returned as a data url.
485
+ """
486
+ module = sys.modules[self.__module__.split(".")[0]]
487
+ return asset(filename, module=module)
488
+
489
+ def _get_file_url(self, name: str):
490
+ if "WEBAPP_JOB_ID" in os.environ:
491
+ id_ = int(os.environ["WEBAPP_JOB_ID"])
492
+ else:
493
+ id_ = self.metadata["id"]
494
+ url = "results" if isinstance(self, Report) else "files"
495
+ return os.environ["WEBAPP_API_URL"] + f"/{url}/{id_}/files/{name}"
496
+
497
+ def _load_data_file(self, name):
498
+ if is_pyodide():
499
+ http = urllib3.PoolManager()
500
+ response = http.request(
501
+ "GET",
502
+ self._get_file_url(name),
503
+ headers={"Authorization": f'{os.environ["WEBAPP_API_TOKEN"]}'},
504
+ )
505
+ return response.json()
506
+ return read_json(f"_data_files/{name}")
507
+
508
+ def _store_data_file(self, data, name):
509
+ # if is_pyodide():
510
+ if True:
511
+ id_ = self.metadata["id"]
512
+ api.post(
513
+ f"/files/{id_}/files/{name}",
514
+ data,
515
+ )
516
+ # else:
517
+ # os.makedirs("_data_files", exist_ok=True)
518
+ # import json
519
+ # write_json(json.dumps(data), f"_data_files/{name}")
520
+
521
+ def _save_storage_local(self):
522
+ self.component._save_storage_local()
523
+
524
+ def _load_storage_local(self):
525
+ self.component._load_storage_local()
526
+
527
+ def testing_data(self):
528
+ """
529
+ Get data for testing purposes. User should implement this method in the app class.
530
+ Returns:
531
+ dict: Dictionary with filename, data and type keys
532
+ """
533
+ app_data = self.dump()
534
+ app_data["component"].pop("storage", None)
535
+ return {
536
+ "filename": f"{self.name}.json",
537
+ "data": json.dumps(app_data),
538
+ "type": "application/json",
539
+ }
540
+
541
+
542
+ BaseModel = App
543
+
544
+
545
+ def replace_app(new_app):
546
+ env = get_environment()
547
+ env.frontend.reset_app(new_app)
548
+
549
+
550
+ def reload_package(package_name):
551
+ """Reload python package and all submodules (searches in modules for references to other submodules)"""
552
+ package = importlib.import_module(package_name)
553
+ assert hasattr(package, "__package__")
554
+ file_name = package.__file__
555
+ if file_name is None:
556
+ file_name = package.__path__[0]
557
+ package_dir = os.path.dirname(file_name) + os.sep
558
+ reloaded_modules = {file_name: package}
559
+
560
+ def reload_recursive(module):
561
+ t = time.time()
562
+ module = importlib.reload(module)
563
+ t = time.time() - t
564
+
565
+ if t > 0.1:
566
+ print(f"Reloaded module {module.__name__} in {1000*t:.0f} ms")
567
+
568
+ var_values = vars(module).values()
569
+ for var in var_values:
570
+ if isinstance(var, types.ModuleType):
571
+ file_name = getattr(var, "__file__", None)
572
+ if file_name is not None and file_name.startswith(package_dir):
573
+ if file_name not in reloaded_modules:
574
+ reloaded_modules[file_name] = reload_recursive(var)
575
+
576
+ return module
577
+
578
+ reload_recursive(package)
579
+ return reloaded_modules
580
+
581
+
582
+ def loadModel(app_metadata, data, reload_python_modules=[], load_local_storage=False):
583
+ """Load model from data"""
584
+
585
+ reloaded_modules = {}
586
+ for m in reload_python_modules:
587
+ # hot reloading of python module on client side
588
+ t = time.time()
589
+ reloaded_modules |= reload_package(m)
590
+ t = time.time() - t
591
+ print(f"Reloaded package {m} in {1000*t:.0f} ms")
592
+
593
+ module_name = app_metadata["python_class"].split(".")
594
+ module_name, class_name = ".".join(module_name[:-1]), module_name[-1]
595
+ if module_name in reloaded_modules:
596
+ module = reloaded_modules[module_name]
597
+ else:
598
+ module = importlib.import_module(module_name)
599
+ cls = getattr(module, class_name)
600
+ reset_components()
601
+ app = cls()
602
+ app._default_data = app.dump()["component"]
603
+ if not "metadata" in data:
604
+ data["metadata"] = {
605
+ "app_id": app_metadata["id"],
606
+ "python_class": app_metadata["python_class"],
607
+ }
608
+ app.load(data=data, load_local_storage=load_local_storage)
609
+ return app
610
+
611
+
612
+ _app_register = []
613
+
614
+
615
+ def register_application(app):
616
+ """Class decorator to register an application"""
617
+ _app_register.append(app)
618
+ return app
ngapp/basecomponent.py ADDED
@@ -0,0 +1,5 @@
1
+ # for backward compatility
2
+ print(
3
+ "WARNING: Using webapp.basecomponent is deprecated. Module has been moved to webapp.components.basecomponent"
4
+ )
5
+ from .components.basecomponent import *
ngapp/cli/__init__.py ADDED
File without changes