Mercurial > libervia-backend
annotate sat/plugins/plugin_misc_app_manager.py @ 3732:0fac164ff2d8
tools (xml_tools): fix `widget_args` modification in `_dataFormField2XMLUIData`:
in `textbox` widget, the first arg was modified with all values, lettings other ones
unchanger, resulting in invalid number of arguments. This has been fixed by replacing all
args with the new value.
author | Goffi <goffi@goffi.org> |
---|---|
date | Mon, 31 Jan 2022 18:35:52 +0100 |
parents | d66a8453b02b |
children | 402d31527af4 |
rev | line source |
---|---|
3372 | 1 #!/usr/bin/env python3 |
2 | |
3 # SàT plugin to manage external applications | |
3479 | 4 # Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org) |
3372 | 5 |
6 # This program is free software: you can redistribute it and/or modify | |
7 # it under the terms of the GNU Affero General Public License as published by | |
8 # the Free Software Foundation, either version 3 of the License, or | |
9 # (at your option) any later version. | |
10 | |
11 # This program is distributed in the hope that it will be useful, | |
12 # but WITHOUT ANY WARRANTY; without even the implied warranty of | |
13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | |
14 # GNU Affero General Public License for more details. | |
15 | |
16 # You should have received a copy of the GNU Affero General Public License | |
17 # along with this program. If not, see <http://www.gnu.org/licenses/>. | |
18 | |
19 from pathlib import Path | |
20 from typing import Optional, List | |
21 from functools import partial, reduce | |
22 import tempfile | |
23 import secrets | |
24 import string | |
25 import shortuuid | |
26 from twisted.internet import defer | |
27 from twisted.python.procutils import which | |
28 from sat.core.i18n import _ | |
29 from sat.core import exceptions | |
30 from sat.core.constants import Const as C | |
31 from sat.core.log import getLogger | |
32 from sat.tools.common import data_format | |
33 from sat.tools.common import async_process | |
34 | |
35 log = getLogger(__name__) | |
36 | |
37 try: | |
38 import yaml | |
39 except ImportError: | |
40 raise exceptions.MissingModule( | |
41 'Missing module PyYAML, please download/install it. You can use ' | |
42 '"pip install pyyaml"' | |
43 ) | |
44 | |
45 try: | |
46 from yaml import CLoader as Loader, CDumper as Dumper | |
47 except ImportError: | |
48 log.warning( | |
49 "Can't use LibYAML binding (is libyaml installed?), pure Python version will be " | |
50 "used, but it is slower" | |
51 ) | |
52 from yaml import Loader, Dumper | |
53 | |
54 from yaml.constructor import ConstructorError | |
55 | |
56 | |
57 PLUGIN_INFO = { | |
58 C.PI_NAME: "Applications Manager", | |
59 C.PI_IMPORT_NAME: "APP_MANAGER", | |
60 C.PI_TYPE: C.PLUG_TYPE_MISC, | |
61 C.PI_MODES: C.PLUG_MODE_BOTH, | |
62 C.PI_MAIN: "AppManager", | |
63 C.PI_HANDLER: "no", | |
64 C.PI_DESCRIPTION: _( | |
65 """Applications Manager | |
66 | |
67 Manage external applications using packagers, OS virtualization/containers or other | |
68 software management tools. | |
69 """), | |
70 } | |
71 | |
72 APP_FILE_PREFIX = "sat_app_" | |
73 | |
74 | |
75 class AppManager: | |
76 load = partial(yaml.load, Loader=Loader) | |
77 dump = partial(yaml.dump, Dumper=Dumper) | |
78 | |
79 def __init__(self, host): | |
80 log.info(_("plugin Applications Manager initialization")) | |
81 self.host = host | |
82 self._managers = {} | |
83 self._apps = {} | |
84 self._started = {} | |
85 # instance id to app data map | |
86 self._instances = {} | |
87 host.bridge.addMethod( | |
88 "applicationsList", | |
89 ".plugin", | |
90 in_sign="as", | |
91 out_sign="as", | |
92 method=self.list_applications, | |
93 ) | |
94 host.bridge.addMethod( | |
95 "applicationStart", | |
96 ".plugin", | |
97 in_sign="ss", | |
98 out_sign="", | |
99 method=self._start, | |
100 async_=True, | |
101 ) | |
102 host.bridge.addMethod( | |
103 "applicationStop", | |
104 ".plugin", | |
105 in_sign="sss", | |
106 out_sign="", | |
107 method=self._stop, | |
108 async_=True, | |
109 ) | |
110 host.bridge.addMethod( | |
111 "applicationExposedGet", | |
112 ".plugin", | |
113 in_sign="sss", | |
114 out_sign="s", | |
115 method=self._getExposed, | |
116 async_=True, | |
117 ) | |
118 yaml.add_constructor( | |
119 "!sat_conf", self._sat_conf_constr, Loader=Loader) | |
120 yaml.add_constructor( | |
121 "!sat_generate_pwd", self._sat_generate_pwd_constr, Loader=Loader) | |
122 yaml.add_constructor( | |
123 "!sat_param", self._sat_param_constr, Loader=Loader) | |
124 | |
125 def unload(self): | |
126 log.debug("unloading applications manager") | |
127 for instances in self._started.values(): | |
128 for instance in instances: | |
129 data = instance['data'] | |
130 if not data['single_instance']: | |
131 log.debug( | |
132 f"cleaning temporary directory at {data['_instance_dir_path']}") | |
133 data['_instance_dir_obj'].cleanup() | |
134 | |
135 def _sat_conf_constr(self, loader, node): | |
136 """Get a value from SàT configuration | |
137 | |
138 A list is expected with either "name" of a config parameter, a one or more of | |
139 those parameters: | |
140 - section | |
141 - name | |
142 - default value | |
143 - filter | |
144 filter can be: | |
145 - "first": get the first item of the value | |
146 """ | |
147 config_data = loader.construct_sequence(node) | |
148 if len(config_data) == 1: | |
149 section, name, default, filter_ = "", config_data[0], None, None | |
150 if len(config_data) == 2: | |
151 (section, name), default, filter_ = config_data, None, None | |
152 elif len(config_data) == 3: | |
153 (section, name, default), filter_ = config_data, None | |
154 elif len(config_data) == 4: | |
155 section, name, default, filter_ = config_data | |
156 else: | |
157 raise ValueError( | |
158 f"invalid !sat_conf value ({config_data!r}), a list of 1 to 4 items is " | |
159 "expected" | |
160 ) | |
161 | |
162 value = self.host.memory.getConfig(section, name, default) | |
3564
2c9e95796371
plugin app manager: "public_url" must NOT have a schema
Goffi <goffi@goffi.org>
parents:
3479
diff
changeset
|
163 # FIXME: "public_url" is used only here and doesn't take multi-sites into account |
2c9e95796371
plugin app manager: "public_url" must NOT have a schema
Goffi <goffi@goffi.org>
parents:
3479
diff
changeset
|
164 if name == "public_url" and (not value or value.startswith('http')): |
3372 | 165 if not value: |
166 log.warning(_( | |
3564
2c9e95796371
plugin app manager: "public_url" must NOT have a schema
Goffi <goffi@goffi.org>
parents:
3479
diff
changeset
|
167 'No value found for "public_url", using "example.org" for ' |
2c9e95796371
plugin app manager: "public_url" must NOT have a schema
Goffi <goffi@goffi.org>
parents:
3479
diff
changeset
|
168 'now, please set the proper value in libervia.conf')) |
3372 | 169 else: |
170 log.warning(_( | |
3564
2c9e95796371
plugin app manager: "public_url" must NOT have a schema
Goffi <goffi@goffi.org>
parents:
3479
diff
changeset
|
171 'invalid value for "public_url" ({value}), it musts not start with ' |
2c9e95796371
plugin app manager: "public_url" must NOT have a schema
Goffi <goffi@goffi.org>
parents:
3479
diff
changeset
|
172 'schema ("http"), ignoring it and using "example.org" ' |
2c9e95796371
plugin app manager: "public_url" must NOT have a schema
Goffi <goffi@goffi.org>
parents:
3479
diff
changeset
|
173 'instead') |
3372 | 174 .format(value=value)) |
3564
2c9e95796371
plugin app manager: "public_url" must NOT have a schema
Goffi <goffi@goffi.org>
parents:
3479
diff
changeset
|
175 value = "example.org" |
3372 | 176 |
177 if filter_ is None: | |
178 pass | |
179 elif filter_ == 'first': | |
180 value = value[0] | |
181 else: | |
182 raise ValueError(f"unmanaged filter: {filter_}") | |
183 | |
184 return value | |
185 | |
186 def _sat_generate_pwd_constr(self, loader, node): | |
187 alphabet = string.ascii_letters + string.digits | |
188 return ''.join(secrets.choice(alphabet) for i in range(30)) | |
189 | |
190 def _sat_param_constr(self, loader, node): | |
191 """Get a parameter specified when starting the application | |
192 | |
193 The value can be either the name of the parameter to get, or a list as | |
194 [name, default_value] | |
195 """ | |
196 try: | |
197 name, default = loader.construct_sequence(node) | |
198 except ConstructorError: | |
199 name, default = loader.construct_scalar(node), None | |
200 return self._params.get(name, default) | |
201 | |
202 def register(self, manager): | |
203 name = manager.name | |
204 if name in self._managers: | |
205 raise exceptions.ConflictError( | |
206 f"There is already a manager with the name {name}") | |
207 self._managers[manager.name] = manager | |
208 if hasattr(manager, "discover_path"): | |
209 self.discover(manager.discover_path, manager) | |
210 | |
211 def getManager(self, app_data: dict) -> object: | |
212 """Get manager instance needed for this app | |
213 | |
214 @raise exceptions.DataError: something is wrong with the type | |
215 @raise exceptions.NotFound: manager is not registered | |
216 """ | |
217 try: | |
218 app_type = app_data["type"] | |
219 except KeyError: | |
220 raise exceptions.DataError( | |
221 "app file doesn't have the mandatory \"type\" key" | |
222 ) | |
223 if not isinstance(app_type, str): | |
224 raise exceptions.DataError( | |
225 f"invalid app data type: {app_type!r}" | |
226 ) | |
227 app_type = app_type.strip() | |
228 try: | |
229 return self._managers[app_type] | |
230 except KeyError: | |
231 raise exceptions.NotFound( | |
232 f"No manager found to manage app of type {app_type!r}") | |
233 | |
234 def getAppData( | |
235 self, | |
236 id_type: Optional[str], | |
237 identifier: str | |
238 ) -> dict: | |
239 """Retrieve instance's app_data from identifier | |
240 | |
241 @param id_type: type of the identifier, can be: | |
242 - "name": identifier is a canonical application name | |
243 the first found instance of this application is returned | |
244 - "instance": identifier is an instance id | |
245 @param identifier: identifier according to id_type | |
246 @return: instance application data | |
247 @raise exceptions.NotFound: no instance with this id can be found | |
248 @raise ValueError: id_type is invalid | |
249 """ | |
250 if not id_type: | |
251 id_type = 'name' | |
252 if id_type == 'name': | |
253 identifier = identifier.lower().strip() | |
254 try: | |
255 return next(iter(self._started[identifier])) | |
256 except (KeyError, StopIteration): | |
257 raise exceptions.NotFound( | |
258 f"No instance of {identifier!r} is currently running" | |
259 ) | |
260 elif id_type == 'instance': | |
261 instance_id = identifier | |
262 try: | |
263 return self._instances[instance_id] | |
264 except KeyError: | |
265 raise exceptions.NotFound( | |
266 f"There is no application instance running with id {instance_id!r}" | |
267 ) | |
268 else: | |
269 raise ValueError(f"invalid id_type: {id_type!r}") | |
270 | |
271 def discover( | |
272 self, | |
273 dir_path: Path, | |
274 manager: Optional = None | |
275 ) -> None: | |
276 for file_path in dir_path.glob(f"{APP_FILE_PREFIX}*.yaml"): | |
277 if manager is None: | |
278 try: | |
279 app_data = self.parse(file_path) | |
280 manager = self.getManager(app_data) | |
281 except (exceptions.DataError, exceptions.NotFound) as e: | |
282 log.warning( | |
283 f"Can't parse {file_path}, skipping: {e}") | |
284 app_name = file_path.stem[len(APP_FILE_PREFIX):].strip().lower() | |
285 if not app_name: | |
286 log.warning( | |
287 f"invalid app file name at {file_path}") | |
288 continue | |
289 app_dict = self._apps.setdefault(app_name, {}) | |
290 manager_set = app_dict.setdefault(manager, set()) | |
291 manager_set.add(file_path) | |
292 log.debug( | |
293 f"{app_name!r} {manager.name} application found" | |
294 ) | |
295 | |
296 def parse(self, file_path: Path, params: Optional[dict] = None) -> dict: | |
297 """Parse SàT application file | |
298 | |
299 @param params: parameters for running this instance | |
300 @raise exceptions.DataError: something is wrong in the file | |
301 """ | |
302 if params is None: | |
303 params = {} | |
304 with file_path.open() as f: | |
305 # we set parameters to be used only with this instance | |
306 # no async method must used between this assignation and `load` | |
307 self._params = params | |
308 app_data = self.load(f) | |
309 self._params = None | |
310 if "name" not in app_data: | |
311 # note that we don't use lower() here as we want human readable name and | |
312 # uppercase may be set on purpose | |
313 app_data['name'] = file_path.stem[len(APP_FILE_PREFIX):].strip() | |
314 single_instance = app_data.setdefault("single_instance", True) | |
315 if not isinstance(single_instance, bool): | |
316 raise ValueError( | |
317 f'"single_instance" must be a boolean, but it is {type(single_instance)}' | |
318 ) | |
319 return app_data | |
320 | |
321 def list_applications(self, filters: Optional[List[str]]) -> List[str]: | |
322 """List available application | |
323 | |
324 @param filters: only show applications matching those filters. | |
325 using None will list all known applications | |
326 a filter can be: | |
327 - available: applications available locally | |
328 - running: only show launched applications | |
329 """ | |
330 if not filters: | |
331 return list(self.apps) | |
332 found = set() | |
333 for filter_ in filters: | |
334 if filter_ == "available": | |
335 found.update(self._apps) | |
336 elif filter_ == "running": | |
337 found.update(self._started) | |
338 else: | |
339 raise ValueError(f"Unknown filter: {filter_}") | |
340 return list(found) | |
341 | |
342 def _start(self, app_name, extra): | |
343 extra = data_format.deserialise(extra) | |
344 return defer.ensureDeferred(self.start(str(app_name), extra)) | |
345 | |
346 async def start( | |
347 self, | |
348 app_name: str, | |
349 extra: Optional[dict] = None, | |
350 ) -> None: | |
351 # FIXME: for now we use the first app manager available for the requested app_name | |
352 # TODO: implement running multiple instance of the same app if some metadata | |
353 # to be defined in app_data allows explicitly it. | |
354 app_name = app_name.lower().strip() | |
355 try: | |
356 app_file_path = next(iter(next(iter(self._apps[app_name].values())))) | |
357 except KeyError: | |
358 raise exceptions.NotFound( | |
359 f"No application found with the name {app_name!r}" | |
360 ) | |
361 started_data = self._started.setdefault(app_name, []) | |
362 app_data = self.parse(app_file_path, extra) | |
363 app_data['_file_path'] = app_file_path | |
364 app_data['_name_canonical'] = app_name | |
365 single_instance = app_data['single_instance'] | |
366 if single_instance: | |
367 if started_data: | |
368 log.info(f"{app_name!r} is already started") | |
369 return | |
370 else: | |
371 cache_path = self.host.memory.getCachePath( | |
372 PLUGIN_INFO[C.PI_IMPORT_NAME], app_name | |
373 ) | |
374 cache_path.mkdir(0o700, parents=True, exist_ok=True) | |
375 app_data['_instance_dir_path'] = cache_path | |
376 else: | |
377 dest_dir_obj = tempfile.TemporaryDirectory(prefix="sat_app_") | |
378 app_data['_instance_dir_obj'] = dest_dir_obj | |
379 app_data['_instance_dir_path'] = Path(dest_dir_obj.name) | |
380 instance_id = app_data['_instance_id'] = shortuuid.uuid() | |
381 manager = self.getManager(app_data) | |
382 app_data['_manager'] = manager | |
383 started_data.append(app_data) | |
384 self._instances[instance_id] = app_data | |
385 | |
386 try: | |
387 start = manager.start | |
388 except AttributeError: | |
389 raise exceptions.InternalError( | |
390 f"{manager.name} doesn't have the mandatory \"start\" method" | |
391 ) | |
392 else: | |
393 await start(app_data) | |
394 log.info(f"{app_name!r} started") | |
395 | |
396 def _stop(self, identifier, id_type, extra): | |
397 extra = data_format.deserialise(extra) | |
398 return defer.ensureDeferred( | |
399 self.stop(str(identifier), str(id_type) or None, extra)) | |
400 | |
401 async def stop( | |
402 self, | |
403 identifier: str, | |
404 id_type: Optional[str] = None, | |
405 extra: Optional[dict] = None, | |
406 ) -> None: | |
407 if extra is None: | |
408 extra = {} | |
409 | |
410 app_data = self.getAppData(id_type, identifier) | |
411 | |
412 log.info(f"stopping {app_data['name']!r}") | |
413 | |
414 app_name = app_data['_name_canonical'] | |
415 instance_id = app_data['_instance_id'] | |
416 manager = app_data['_manager'] | |
417 | |
418 try: | |
419 stop = manager.stop | |
420 except AttributeError: | |
421 raise exceptions.InternalError( | |
422 f"{manager.name} doesn't have the mandatory \"stop\" method" | |
423 ) | |
424 else: | |
425 try: | |
426 await stop(app_data) | |
427 except Exception as e: | |
428 log.warning( | |
429 f"Instance {instance_id} of application {app_name} can't be stopped " | |
430 f"properly: {e}" | |
431 ) | |
432 return | |
433 | |
434 try: | |
435 del self._instances[instance_id] | |
436 except KeyError: | |
437 log.error( | |
438 f"INTERNAL ERROR: {instance_id!r} is not present in self._instances") | |
439 | |
440 try: | |
441 self._started[app_name].remove(app_data) | |
442 except ValueError: | |
443 log.error( | |
444 "INTERNAL ERROR: there is no app data in self._started with id " | |
445 f"{instance_id!r}" | |
446 ) | |
447 | |
448 log.info(f"{app_name!r} stopped") | |
449 | |
450 def _getExposed(self, identifier, id_type, extra): | |
451 extra = data_format.deserialise(extra) | |
452 d = defer.ensureDeferred(self.getExposed(identifier, id_type, extra)) | |
453 d.addCallback(lambda d: data_format.serialise(d)) | |
454 return d | |
455 | |
456 def getValueFromPath(self, app_data: dict, path: List[str]) -> any: | |
457 """Retrieve a value set in the data from it path | |
458 | |
459 @param path: list of key to use in app_data to retrieve the value | |
460 @return: found value | |
461 @raise NotFound: the value can't be found | |
462 """ | |
463 | |
464 async def getExposed( | |
465 self, | |
466 identifier: str, | |
467 id_type: Optional[str] = None, | |
468 extra: Optional[dict] = None, | |
469 ) -> dict: | |
470 """Get data exposed by the application | |
471 | |
472 The manager's "computeExpose" method will be called if it exists. It can be used | |
473 to handle manager specific conventions. | |
474 """ | |
475 app_data = self.getAppData(id_type, identifier) | |
476 if app_data.get('_exposed_computed', False): | |
477 return app_data['expose'] | |
478 if extra is None: | |
479 extra = {} | |
480 expose = app_data.setdefault("expose", {}) | |
481 if "passwords" in expose: | |
482 passwords = expose['passwords'] | |
483 for name, value in list(passwords.items()): | |
484 if isinstance(value, list): | |
485 # if we have a list, is the sequence of keys leading to the value | |
486 # to expose. We use "reduce" to retrieve the desired value | |
487 try: | |
488 passwords[name] = reduce(lambda l, k: l[k], value, app_data) | |
489 except Exception as e: | |
490 log.warning( | |
491 f"Can't retrieve exposed value for password {name!r}: {e}") | |
492 del passwords[name] | |
493 | |
494 url_prefix = expose.get("url_prefix") | |
495 if isinstance(url_prefix, list): | |
496 try: | |
497 expose["url_prefix"] = reduce(lambda l, k: l[k], url_prefix, app_data) | |
498 except Exception as e: | |
499 log.warning( | |
500 f"Can't retrieve exposed value for url_prefix: {e}") | |
501 del expose["url_prefix"] | |
502 | |
503 try: | |
504 computeExpose = app_data['_manager'].computeExpose | |
505 except AttributeError: | |
506 pass | |
507 else: | |
508 await computeExpose(app_data) | |
509 | |
510 app_data['_exposed_computed'] = True | |
511 return expose | |
512 | |
513 async def _doPrepare( | |
514 self, | |
515 app_data: dict, | |
516 ) -> None: | |
517 name = app_data['name'] | |
518 dest_path = app_data['_instance_dir_path'] | |
519 if next(dest_path.iterdir(), None) != None: | |
520 log.debug(f"There is already a prepared dir at {dest_path}, nothing to do") | |
521 return | |
522 try: | |
523 prepare = app_data['prepare'].copy() | |
524 except KeyError: | |
525 prepare = {} | |
526 | |
527 if not prepare: | |
528 log.debug("Nothing to prepare for {name!r}") | |
529 return | |
530 | |
531 for action, value in list(prepare.items()): | |
532 log.debug(f"[{name}] [prepare] running {action!r} action") | |
533 if action == "git": | |
534 try: | |
535 git_path = which('git')[0] | |
536 except IndexError: | |
537 raise exceptions.NotFound( | |
538 "Can't find \"git\" executable, {name} can't be started without it" | |
539 ) | |
540 await async_process.run(git_path, "clone", value, str(dest_path)) | |
541 log.debug(f"{value!r} git repository cloned at {dest_path}") | |
542 else: | |
543 raise NotImplementedError( | |
544 f"{action!r} is not managed, can't start {name}" | |
545 ) | |
546 del prepare[action] | |
547 | |
548 if prepare: | |
549 raise exceptions.InternalError('"prepare" should be empty') | |
550 | |
3565
d66a8453b02b
plugin app manager: add a way to create files:
Goffi <goffi@goffi.org>
parents:
3564
diff
changeset
|
551 async def _doCreateFiles( |
d66a8453b02b
plugin app manager: add a way to create files:
Goffi <goffi@goffi.org>
parents:
3564
diff
changeset
|
552 self, |
d66a8453b02b
plugin app manager: add a way to create files:
Goffi <goffi@goffi.org>
parents:
3564
diff
changeset
|
553 app_data: dict, |
d66a8453b02b
plugin app manager: add a way to create files:
Goffi <goffi@goffi.org>
parents:
3564
diff
changeset
|
554 ) -> None: |
d66a8453b02b
plugin app manager: add a way to create files:
Goffi <goffi@goffi.org>
parents:
3564
diff
changeset
|
555 dest_path = app_data['_instance_dir_path'] |
d66a8453b02b
plugin app manager: add a way to create files:
Goffi <goffi@goffi.org>
parents:
3564
diff
changeset
|
556 files = app_data.get('files') |
d66a8453b02b
plugin app manager: add a way to create files:
Goffi <goffi@goffi.org>
parents:
3564
diff
changeset
|
557 if not files: |
d66a8453b02b
plugin app manager: add a way to create files:
Goffi <goffi@goffi.org>
parents:
3564
diff
changeset
|
558 return |
d66a8453b02b
plugin app manager: add a way to create files:
Goffi <goffi@goffi.org>
parents:
3564
diff
changeset
|
559 if not isinstance(files, dict): |
d66a8453b02b
plugin app manager: add a way to create files:
Goffi <goffi@goffi.org>
parents:
3564
diff
changeset
|
560 raise ValueError('"files" must be a dictionary') |
d66a8453b02b
plugin app manager: add a way to create files:
Goffi <goffi@goffi.org>
parents:
3564
diff
changeset
|
561 for filename, data in files.items(): |
d66a8453b02b
plugin app manager: add a way to create files:
Goffi <goffi@goffi.org>
parents:
3564
diff
changeset
|
562 path = dest_path / filename |
d66a8453b02b
plugin app manager: add a way to create files:
Goffi <goffi@goffi.org>
parents:
3564
diff
changeset
|
563 if path.is_file(): |
d66a8453b02b
plugin app manager: add a way to create files:
Goffi <goffi@goffi.org>
parents:
3564
diff
changeset
|
564 log.info(f"{path} already exists, skipping") |
d66a8453b02b
plugin app manager: add a way to create files:
Goffi <goffi@goffi.org>
parents:
3564
diff
changeset
|
565 with path.open("w") as f: |
d66a8453b02b
plugin app manager: add a way to create files:
Goffi <goffi@goffi.org>
parents:
3564
diff
changeset
|
566 f.write(data.get("content", "")) |
d66a8453b02b
plugin app manager: add a way to create files:
Goffi <goffi@goffi.org>
parents:
3564
diff
changeset
|
567 log.debug(f"{path} created") |
d66a8453b02b
plugin app manager: add a way to create files:
Goffi <goffi@goffi.org>
parents:
3564
diff
changeset
|
568 |
3372 | 569 async def startCommon(self, app_data: dict) -> None: |
570 """Method running common action when starting a manager | |
571 | |
572 It should be called by managers in "start" method. | |
573 """ | |
574 log.info(f"starting {app_data['name']!r}") | |
575 await self._doPrepare(app_data) | |
3565
d66a8453b02b
plugin app manager: add a way to create files:
Goffi <goffi@goffi.org>
parents:
3564
diff
changeset
|
576 await self._doCreateFiles(app_data) |