ert 19.0.0rc1__py3-none-any.whl → 19.0.0rc3__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.
Files changed (48) hide show
  1. ert/__main__.py +63 -94
  2. ert/analysis/_es_update.py +14 -11
  3. ert/config/_create_observation_dataframes.py +262 -23
  4. ert/config/_observations.py +153 -181
  5. ert/config/_read_summary.py +5 -4
  6. ert/config/ert_config.py +56 -1
  7. ert/config/parsing/observations_parser.py +0 -6
  8. ert/config/rft_config.py +1 -1
  9. ert/dark_storage/compute/__init__.py +0 -0
  10. ert/dark_storage/compute/misfits.py +42 -0
  11. ert/dark_storage/endpoints/__init__.py +2 -0
  12. ert/dark_storage/endpoints/compute/__init__.py +0 -0
  13. ert/dark_storage/endpoints/compute/misfits.py +95 -0
  14. ert/dark_storage/endpoints/experiments.py +3 -0
  15. ert/dark_storage/json_schema/experiment.py +1 -0
  16. ert/gui/main_window.py +0 -2
  17. ert/gui/tools/manage_experiments/export_dialog.py +0 -4
  18. ert/gui/tools/manage_experiments/storage_info_widget.py +5 -1
  19. ert/gui/tools/plot/plot_api.py +10 -10
  20. ert/gui/tools/plot/plot_widget.py +0 -5
  21. ert/gui/tools/plot/plot_window.py +1 -1
  22. ert/services/__init__.py +3 -7
  23. ert/services/_base_service.py +387 -0
  24. ert/services/_storage_main.py +22 -59
  25. ert/services/ert_server.py +24 -186
  26. ert/services/webviz_ert_service.py +20 -0
  27. ert/shared/storage/command.py +38 -0
  28. ert/shared/storage/extraction.py +42 -0
  29. ert/shared/version.py +3 -3
  30. ert/storage/local_ensemble.py +95 -2
  31. ert/storage/local_experiment.py +16 -0
  32. ert/storage/local_storage.py +1 -3
  33. ert/utils/__init__.py +0 -20
  34. {ert-19.0.0rc1.dist-info → ert-19.0.0rc3.dist-info}/METADATA +2 -2
  35. {ert-19.0.0rc1.dist-info → ert-19.0.0rc3.dist-info}/RECORD +46 -41
  36. {ert-19.0.0rc1.dist-info → ert-19.0.0rc3.dist-info}/WHEEL +1 -1
  37. everest/bin/everest_script.py +5 -5
  38. everest/bin/kill_script.py +2 -2
  39. everest/bin/monitor_script.py +2 -2
  40. everest/bin/utils.py +4 -4
  41. everest/detached/everserver.py +6 -6
  42. everest/gui/main_window.py +2 -2
  43. everest/util/__init__.py +19 -1
  44. ert/config/observation_config_migrations.py +0 -793
  45. ert/storage/migration/to22.py +0 -18
  46. {ert-19.0.0rc1.dist-info → ert-19.0.0rc3.dist-info}/entry_points.txt +0 -0
  47. {ert-19.0.0rc1.dist-info → ert-19.0.0rc3.dist-info}/licenses/COPYING +0 -0
  48. {ert-19.0.0rc1.dist-info → ert-19.0.0rc3.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,387 @@
1
+ """
2
+ This file contains a more generic version of "ert services", and
3
+ is scheduled for removal when WebvizErt is removed.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import contextlib
9
+ import io
10
+ import json
11
+ import os
12
+ import signal
13
+ import sys
14
+ import threading
15
+ import types
16
+ from collections.abc import Callable, Mapping, Sequence
17
+ from logging import Logger, getLogger
18
+ from pathlib import Path
19
+ from select import PIPE_BUF, select
20
+ from subprocess import Popen, TimeoutExpired
21
+ from tempfile import NamedTemporaryFile
22
+ from time import sleep
23
+ from types import FrameType
24
+ from typing import TYPE_CHECKING, Any, Generic, Self, TypedDict, TypeVar
25
+
26
+ if TYPE_CHECKING:
27
+ pass
28
+
29
+ T = TypeVar("T", bound="BaseService")
30
+
31
+
32
+ class ErtServerConnectionInfo(TypedDict):
33
+ urls: list[str]
34
+ authtoken: str
35
+ host: str
36
+ port: str
37
+ cert: str
38
+ auth: str
39
+
40
+
41
+ SERVICE_CONF_PATHS: set[str] = set()
42
+
43
+
44
+ class BaseServiceExit(OSError):
45
+ pass
46
+
47
+
48
+ def cleanup_service_files(signum: int, frame: FrameType | None) -> None:
49
+ for file_path in SERVICE_CONF_PATHS:
50
+ file = Path(file_path)
51
+ if file.exists():
52
+ file.unlink()
53
+ raise BaseServiceExit(f"Signal {signum} received.")
54
+
55
+
56
+ if threading.current_thread() is threading.main_thread():
57
+ signal.signal(signal.SIGTERM, cleanup_service_files)
58
+ signal.signal(signal.SIGINT, cleanup_service_files)
59
+
60
+
61
+ def local_exec_args(script_args: str | list[str]) -> list[str]:
62
+ """
63
+ Convenience function that returns the exec_args for executing a Python
64
+ script in the directory of '_base_service.py'.
65
+
66
+ This is done instead of using 'python -m [module path]' due to the '-m' flag
67
+ adding the user's current working directory to sys.path. Executing a Python
68
+ script by itself will add the directory of the script rather than the
69
+ current working directory, thus we avoid accidentally importing user's
70
+ directories that just happen to have the same names as the ones we use.
71
+ """
72
+ if isinstance(script_args, str):
73
+ script = script_args
74
+ rest: list[str] = []
75
+ else:
76
+ script = script_args[0]
77
+ rest = script_args[1:]
78
+ script = f"_{script}_main.py"
79
+ return [sys.executable, str(Path(__file__).parent / script), *rest]
80
+
81
+
82
+ class _Context(Generic[T]):
83
+ def __init__(self, service: T) -> None:
84
+ self._service = service
85
+
86
+ def __enter__(self) -> T:
87
+ return self._service
88
+
89
+ def __exit__(
90
+ self,
91
+ exc_type: type[BaseException] | None,
92
+ exc_value: BaseException | None,
93
+ traceback: types.TracebackType | None,
94
+ ) -> bool:
95
+ self._service.shutdown()
96
+ return exc_type is None
97
+
98
+
99
+ class _Proc(threading.Thread):
100
+ def __init__(
101
+ self,
102
+ service_name: str,
103
+ exec_args: Sequence[str],
104
+ timeout: int,
105
+ on_connection_info_received: Callable[
106
+ [ErtServerConnectionInfo | Exception | None], None
107
+ ],
108
+ project: Path,
109
+ ) -> None:
110
+ super().__init__()
111
+
112
+ self._shutdown = threading.Event()
113
+
114
+ self._service_name = service_name
115
+ self._exec_args = exec_args
116
+ self._timeout = timeout
117
+ self._propagate_connection_info_from_childproc = on_connection_info_received
118
+ self._service_config_path = project / f"{self._service_name}_server.json"
119
+
120
+ fd_read, fd_write = os.pipe()
121
+ self._comm_pipe = os.fdopen(fd_read)
122
+
123
+ env = os.environ.copy()
124
+ env["ERT_COMM_FD"] = str(fd_write)
125
+
126
+ SERVICE_CONF_PATHS.add(str(self._service_config_path))
127
+
128
+ # The process is waited for in _do_shutdown()
129
+ self._childproc = Popen(
130
+ self._exec_args,
131
+ pass_fds=(fd_write,),
132
+ env=env,
133
+ close_fds=True,
134
+ )
135
+ os.close(fd_write)
136
+
137
+ def run(self) -> None:
138
+ comm = self._read_connection_info_from_process(self._childproc)
139
+
140
+ if comm is None:
141
+ self._propagate_connection_info_from_childproc(TimeoutError())
142
+ return # _read_conn_info() has already cleaned up in this case
143
+
144
+ conn_info: ErtServerConnectionInfo | Exception | None = None
145
+ try:
146
+ conn_info = json.loads(comm)
147
+ except json.JSONDecodeError:
148
+ conn_info = ServerBootFail()
149
+ except Exception as exc:
150
+ conn_info = exc
151
+
152
+ try:
153
+ self._propagate_connection_info_from_childproc(conn_info)
154
+
155
+ while True:
156
+ if self._childproc.poll() is not None:
157
+ break
158
+ if self._shutdown.wait(1):
159
+ self._do_shutdown()
160
+ break
161
+
162
+ except Exception as e:
163
+ print(str(e))
164
+ self.logger.exception(e)
165
+
166
+ finally:
167
+ self._ensure_connection_info_file_is_deleted()
168
+
169
+ def shutdown(self) -> int:
170
+ """Shutdown the server."""
171
+ self._shutdown.set()
172
+ self.join()
173
+
174
+ return self._childproc.returncode
175
+
176
+ def _read_connection_info_from_process(self, proc: Popen[bytes]) -> str | None:
177
+ comm_buf = io.StringIO()
178
+ first_iter = True
179
+ while first_iter or proc.poll() is None:
180
+ first_iter = False
181
+ ready = select([self._comm_pipe], [], [], self._timeout)
182
+
183
+ # Timeout reached, exit with a failure
184
+ if ready == ([], [], []):
185
+ self._do_shutdown()
186
+ self._ensure_connection_info_file_is_deleted()
187
+ return None
188
+
189
+ x = self._comm_pipe.read(PIPE_BUF)
190
+ if not x: # EOF
191
+ break
192
+ comm_buf.write(x)
193
+ return comm_buf.getvalue()
194
+
195
+ def _do_shutdown(self) -> None:
196
+ if self._childproc is None:
197
+ return
198
+ try:
199
+ self._childproc.terminate()
200
+ self._childproc.wait(10) # Give it 10s to shut down cleanly..
201
+ except TimeoutExpired:
202
+ try:
203
+ self._childproc.kill() # ... then kick it harder...
204
+ self._childproc.wait(self._timeout) # ... and wait again
205
+ except TimeoutExpired:
206
+ self.logger.error(
207
+ f"waiting for child-process exceeded timeout {self._timeout}s"
208
+ )
209
+
210
+ def _ensure_connection_info_file_is_deleted(self) -> None:
211
+ """
212
+ Ensure that the JSON connection information file is deleted
213
+ """
214
+ with contextlib.suppress(OSError):
215
+ if self._service_config_path.exists():
216
+ self._service_config_path.unlink()
217
+
218
+ @property
219
+ def logger(self) -> Logger:
220
+ return getLogger(f"ert.shared.{self._service_name}")
221
+
222
+
223
+ class ServerBootFail(RuntimeError):
224
+ pass
225
+
226
+
227
+ class BaseService:
228
+ """
229
+ BaseService provides a block-only-when-needed mechanism for starting and
230
+ maintaining services as subprocesses.
231
+
232
+ This is achieved by using a POSIX communication pipe, over which the service
233
+ can communicate that it has started. The contents of the communication is
234
+ also written to a file inside of the ERT storage directory.
235
+
236
+ The service itself can implement the other side of the pipe as such::
237
+
238
+ import os
239
+
240
+ # ... perform initialisation ...
241
+
242
+ # BaseService provides this environment variable with the pipe's FD
243
+ comm_fd = os.environ["ERT_COMM_FD"]
244
+
245
+ # Open the pipe with Python's IO classes for ease of use
246
+ with os.fdopen(comm_fd, "wb") as comm:
247
+ # Write JSON over the pipe, which will be interpreted by a subclass
248
+ # of BaseService on ERT's side
249
+ comm.write('{"some": "json"}')
250
+
251
+ # The pipe is flushed and closed here. This tells BaseService that
252
+ # initialisation is finished and it will try to read the JSON data.
253
+ """
254
+
255
+ _instance: BaseService | None = None
256
+
257
+ def __init__(
258
+ self,
259
+ exec_args: Sequence[str] = (),
260
+ timeout: int = 120,
261
+ conn_info: ErtServerConnectionInfo | Exception | None = None,
262
+ project: str | None = None,
263
+ ) -> None:
264
+ self._exec_args = exec_args
265
+ self._timeout = timeout
266
+
267
+ self._proc: _Proc | None = None
268
+ self._conn_info: ErtServerConnectionInfo | Exception | None = conn_info
269
+ self._conn_info_event = threading.Event()
270
+ self._project = Path(project) if project is not None else Path.cwd()
271
+
272
+ # Flag that we have connection information
273
+ if self._conn_info:
274
+ self._conn_info_event.set()
275
+ else:
276
+ self._proc = _Proc(
277
+ self.service_name, exec_args, timeout, self.set_conn_info, self._project
278
+ )
279
+
280
+ @classmethod
281
+ def start_server(cls, *args: Any, **kwargs: Any) -> _Context[Self]:
282
+ if cls._instance is not None:
283
+ raise RuntimeError("Server already running")
284
+ cls._instance = obj = cls(*args, **kwargs)
285
+ if obj._proc is not None:
286
+ obj._proc.start()
287
+ return _Context(obj)
288
+
289
+ @classmethod
290
+ def connect(
291
+ cls,
292
+ *,
293
+ project: os.PathLike[str],
294
+ timeout: int | None = None,
295
+ ) -> Self:
296
+ if cls._instance is not None:
297
+ cls._instance.wait_until_ready()
298
+ assert isinstance(cls._instance, cls)
299
+ return cls._instance
300
+
301
+ path = Path(project)
302
+ name = f"{cls.service_name}_server.json"
303
+ # Note: If the caller actually pass None, we override that here...
304
+ if timeout is None:
305
+ timeout = 240
306
+ t = -1
307
+ while t < timeout:
308
+ if (path / name).exists():
309
+ with (path / name).open() as f:
310
+ return cls((), conn_info=json.load(f), project=str(path))
311
+
312
+ sleep(1)
313
+ t += 1
314
+
315
+ raise TimeoutError("Server not started")
316
+
317
+ def wait_until_ready(self, timeout: int | None = None) -> bool:
318
+ if timeout is None:
319
+ timeout = self._timeout
320
+
321
+ if self._conn_info_event.wait(timeout):
322
+ return not (
323
+ self._conn_info is None or isinstance(self._conn_info, Exception)
324
+ )
325
+ if isinstance(self._conn_info, TimeoutError):
326
+ self.logger.critical(f"startup exceeded defined timeout {timeout}s")
327
+ return False # Timeout reached
328
+
329
+ def wait(self) -> None:
330
+ if self._proc is not None:
331
+ self._proc.join()
332
+
333
+ def set_conn_info(self, info: ErtServerConnectionInfo | Exception | None) -> None:
334
+ if self._conn_info is not None:
335
+ raise ValueError("Connection information already set")
336
+ if info is None:
337
+ raise ValueError
338
+ self._conn_info = info
339
+
340
+ if self._project is not None:
341
+ if not Path(self._project).exists():
342
+ raise RuntimeError(f"No storage exists at : {self._project}")
343
+ path = f"{self._project}/{self.service_name}_server.json"
344
+ else:
345
+ path = f"{self.service_name}_server.json"
346
+
347
+ if isinstance(info, Mapping):
348
+ with NamedTemporaryFile(dir=f"{self._project}", delete=False) as f:
349
+ f.write(json.dumps(info, indent=4).encode("utf-8"))
350
+ f.flush()
351
+ os.rename(f.name, path)
352
+
353
+ self._conn_info_event.set()
354
+
355
+ def fetch_conn_info(self) -> Mapping[str, Any]:
356
+ is_ready = self.wait_until_ready(self._timeout)
357
+ if isinstance(self._conn_info, Exception):
358
+ raise self._conn_info
359
+ if not is_ready:
360
+ raise TimeoutError
361
+ if self._conn_info is None:
362
+ raise ValueError("conn_info is None")
363
+ return self._conn_info
364
+
365
+ def shutdown(self) -> int:
366
+ """Shutdown the server."""
367
+ if self._proc is None:
368
+ return -1
369
+ self.__class__._instance = None
370
+ proc, self._proc = self._proc, None
371
+ return proc.shutdown()
372
+
373
+ @property
374
+ def service_name(self) -> str:
375
+ """
376
+ Subclass should return the name of the service, eg 'storage' for ERT Storage.
377
+ Used for identifying the server information JSON file.
378
+ """
379
+ raise NotImplementedError
380
+
381
+ @property
382
+ def logger(self) -> Logger:
383
+ return getLogger(f"ert.shared.{self.service_name}")
384
+
385
+ @property
386
+ def _service_file(self) -> str:
387
+ return f"{self.service_name}_server.json"
@@ -13,7 +13,6 @@ import sys
13
13
  import threading
14
14
  import time
15
15
  import warnings
16
- from argparse import ArgumentParser
17
16
  from base64 import b64encode
18
17
  from pathlib import Path
19
18
  from typing import Any
@@ -30,11 +29,12 @@ from uvicorn.supervisors import ChangeReload
30
29
 
31
30
  from ert.logging import STORAGE_LOG_CONFIG
32
31
  from ert.plugins import setup_site_logging
33
- from ert.services import ErtServerExit
32
+ from ert.services._base_service import BaseServiceExit
34
33
  from ert.shared import __file__ as ert_shared_path
35
34
  from ert.shared import find_available_socket, get_machine_name
35
+ from ert.shared.storage.command import add_parser_options
36
36
  from ert.trace import tracer
37
- from ert.utils import makedirs_if_needed
37
+ from everest.util import makedirs_if_needed
38
38
 
39
39
  DARK_STORAGE_APP = "ert.dark_storage.app:app"
40
40
 
@@ -82,7 +82,7 @@ def _get_host_list() -> list[str]:
82
82
 
83
83
 
84
84
  def _create_connection_info(
85
- sock: socket.socket, authtoken: str, cert: str | os.PathLike[str] | Path
85
+ sock: socket.socket, authtoken: str, cert: str | os.PathLike[str]
86
86
  ) -> dict[str, Any]:
87
87
  connection_info = {
88
88
  "urls": [
@@ -91,7 +91,7 @@ def _create_connection_info(
91
91
  "authtoken": authtoken,
92
92
  "host": get_machine_name(),
93
93
  "port": sock.getsockname()[1],
94
- "cert": str(cert),
94
+ "cert": cert,
95
95
  "auth": authtoken,
96
96
  }
97
97
 
@@ -102,17 +102,14 @@ def _create_connection_info(
102
102
  return connection_info
103
103
 
104
104
 
105
- def _generate_certificate(cert_folder: Path) -> tuple[Path, Path, bytes]:
105
+ def _generate_certificate(cert_folder: str) -> tuple[str, str, bytes]:
106
106
  """Generate a private key and a certificate signed with it
107
107
 
108
108
  Both the certificate and the key are written to files in the folder given
109
109
  by `get_certificate_dir(config)`. The key is encrypted before being
110
110
  stored.
111
-
112
- Returns a 3-tuple with
113
- * Certificate file path
114
- * Key file path
115
- * Password used for encrypting the key
111
+ Returns the path to the certificate file, the path to the key file, and
112
+ the password used for encrypting the key
116
113
  """
117
114
  # Generate private key
118
115
  key = rsa.generate_private_key(
@@ -153,11 +150,11 @@ def _generate_certificate(cert_folder: Path) -> tuple[Path, Path, bytes]:
153
150
 
154
151
  # Write certificate and key to disk
155
152
  makedirs_if_needed(cert_folder)
156
- cert_path = cert_folder / f"{dns_name}.crt"
157
- cert_path.write_bytes(cert.public_bytes(serialization.Encoding.PEM))
158
- key_path = cert_folder / f"{dns_name}.key"
153
+ cert_path = os.path.join(cert_folder, dns_name + ".crt")
154
+ Path(cert_path).write_bytes(cert.public_bytes(serialization.Encoding.PEM))
155
+ key_path = os.path.join(cert_folder, dns_name + ".key")
159
156
  pw = bytes(os.urandom(28))
160
- key_path.write_bytes(
157
+ Path(key_path).write_bytes(
161
158
  key.private_bytes(
162
159
  encoding=serialization.Encoding.PEM,
163
160
  format=serialization.PrivateFormat.TraditionalOpenSSL,
@@ -187,16 +184,16 @@ def run_server(
187
184
 
188
185
  config_args: dict[str, Any] = {}
189
186
  if args.debug or debug:
190
- config_args.update(reload=True, reload_dirs=[Path(ert_shared_path).parent])
187
+ config_args.update(reload=True, reload_dirs=[os.path.dirname(ert_shared_path)])
191
188
  os.environ["ERT_STORAGE_DEBUG"] = "1"
192
189
 
193
- sock: socket.socket = find_available_socket(
190
+ sock = find_available_socket(
194
191
  host=get_machine_name(), port_range=range(51850, 51870 + 1)
195
192
  )
196
193
 
197
194
  # Appropriated from uvicorn.main:run
198
195
  os.environ["ERT_STORAGE_NO_TOKEN"] = "1"
199
- os.environ["ERT_STORAGE_ENS_PATH"] = str(args.project.absolute())
196
+ os.environ["ERT_STORAGE_ENS_PATH"] = os.path.abspath(args.project)
200
197
  config = (
201
198
  # uvicorn.Config() resets the logging config (overriding additional
202
199
  # handlers added to loggers like e.g. the ert_azurelogger handler
@@ -256,11 +253,11 @@ def _join_terminate_thread(terminate_on_parent_death_thread: threading.Thread) -
256
253
  """Join the terminate thread, handling BaseServiceExit (which is used by Everest)"""
257
254
  try:
258
255
  terminate_on_parent_death_thread.join()
259
- except ErtServerExit:
256
+ except BaseServiceExit:
260
257
  logger = logging.getLogger("ert.shared.storage.info")
261
258
  logger.info(
262
259
  "Got BaseServiceExit while joining terminate thread, "
263
- "as expected from ert_server.py"
260
+ "as expected from _base_service.py"
264
261
  )
265
262
 
266
263
 
@@ -268,7 +265,9 @@ def main() -> None:
268
265
  args = parse_args()
269
266
  authentication = _generate_authentication()
270
267
  os.environ["ERT_STORAGE_TOKEN"] = authentication
271
- cert_path, key_path, key_pw = _generate_certificate(args.project / "cert")
268
+ cert_path, key_path, key_pw = _generate_certificate(
269
+ os.path.join(args.project, "cert")
270
+ )
272
271
  config_args: dict[str, Any] = {
273
272
  "ssl_keyfile": key_path,
274
273
  "ssl_certfile": cert_path,
@@ -284,7 +283,7 @@ def main() -> None:
284
283
  warnings.filterwarnings("ignore", category=DeprecationWarning)
285
284
 
286
285
  if args.debug:
287
- config_args.update(reload=True, reload_dirs=[Path(ert_shared_path).parent])
286
+ config_args.update(reload=True, reload_dirs=[os.path.dirname(ert_shared_path)])
288
287
 
289
288
  # Need to run uvicorn.Config before entering the ErtPluginContext because
290
289
  # uvicorn.Config overrides the configuration of existing loggers, thus removing
@@ -311,48 +310,12 @@ def main() -> None:
311
310
  logger.info("Starting dark storage")
312
311
  logger.info(f"Started dark storage with parent {args.parent_pid}")
313
312
  run_server(args, debug=False, uvicorn_config=uvicorn_config)
314
- except (SystemExit, ErtServerExit):
313
+ except (SystemExit, BaseServiceExit):
315
314
  logger.info("Stopping dark storage")
316
315
  finally:
317
316
  stopped.set()
318
317
  _join_terminate_thread(terminate_on_parent_death_thread)
319
318
 
320
319
 
321
- def add_parser_options(ap: ArgumentParser) -> None:
322
- ap.add_argument(
323
- "config",
324
- type=str,
325
- help=("ERT config file to start the server from "),
326
- nargs="?", # optional
327
- )
328
- ap.add_argument(
329
- "--project",
330
- "-p",
331
- type=Path,
332
- help="Path to directory in which to create storage_server.json",
333
- default=Path.cwd(),
334
- )
335
- ap.add_argument(
336
- "--traceparent",
337
- type=str,
338
- help="Trace parent id to be used by the storage root span",
339
- default=None,
340
- )
341
- ap.add_argument(
342
- "--parent_pid",
343
- type=int,
344
- help="The parent process id",
345
- default=os.getppid(),
346
- )
347
- ap.add_argument(
348
- "--host", type=str, default=os.environ.get("ERT_STORAGE_HOST", "127.0.0.1")
349
- )
350
- ap.add_argument("--logging-config", type=str, default=None)
351
- ap.add_argument(
352
- "--verbose", action="store_true", help="Show verbose output.", default=False
353
- )
354
- ap.add_argument("--debug", action="store_true", default=False)
355
-
356
-
357
320
  if __name__ == "__main__":
358
321
  main()