docassemblecli3 0.0.1__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.
@@ -0,0 +1,2 @@
1
+ # docassemblecli3/__init__.py
2
+ from .docassemblecli3 import cli
@@ -0,0 +1,810 @@
1
+ import datetime
2
+ import fnmatch
3
+ import os
4
+ import re
5
+ import stat
6
+ import subprocess
7
+ import tempfile
8
+ import time
9
+ import zipfile
10
+ from functools import wraps
11
+ from urllib.parse import urlparse
12
+
13
+ import click
14
+ import requests
15
+ import yaml
16
+ from packaging import version
17
+ from watchdog.events import FileSystemEventHandler
18
+ from watchdog.observers import Observer
19
+
20
+ global DEFAULT_CONFIG
21
+ DEFAULT_CONFIG = os.path.join(os.path.expanduser("~"), ".docassemblecli")
22
+
23
+
24
+ global LAST_MODIFIED
25
+ LAST_MODIFIED = {
26
+ "time": 0,
27
+ "files": {},
28
+ "restart": False,
29
+ }
30
+
31
+
32
+ # -----------------------------------------------------------------------------
33
+ # click
34
+ # -----------------------------------------------------------------------------
35
+
36
+ CONTEXT_SETTINGS = dict(help_option_names=["--help", "-h"])
37
+
38
+
39
+ @click.group(context_settings=CONTEXT_SETTINGS)
40
+ @click.option("--color/--no-color", "-C/-N", default=None, show_default=True, help="Overrides color auto-detection in interactive terminals.")
41
+ def cli(color):
42
+ """
43
+ Commands for interacting with docassemble servers.
44
+ """
45
+ CONTEXT_SETTINGS["color"] = color
46
+
47
+
48
+ @cli.group(context_settings=CONTEXT_SETTINGS)
49
+ def config():
50
+ """
51
+ Manage servers in a docassemblecli config file.
52
+ """
53
+ pass
54
+
55
+
56
+ def common_params_for_api(func):
57
+ @click.option("--api", "-a", type=(APIURLType(), str), default=(None, None), help="URL of the docassemble server and API key of the user (admin or developer)")
58
+ @click.option("--server", "-s", metavar="SERVER", default="", help="Specify a server from the config file")
59
+ @wraps(func)
60
+ def wrapper(*args, **kwargs):
61
+ return func(*args, **kwargs)
62
+ return wrapper
63
+
64
+
65
+ def common_params_for_config(func):
66
+ @click.option("--config", "-c", default=DEFAULT_CONFIG, type=click.Path(), callback=validate_and_load_or_create_config, show_default=True, help="Specify the config file to use")
67
+ @wraps(func)
68
+ def wrapper(*args, **kwargs):
69
+ return func(*args, **kwargs)
70
+ return wrapper
71
+
72
+
73
+ def common_params_for_installation(func):
74
+ @click.option("--directory", "-d", default=os.getcwd(), type=click.Path(), callback=validate_package_directory, help="Specify package directory [default: current directory]")
75
+ @click.option("--config", "-c", is_flag=False, flag_value="", default=DEFAULT_CONFIG, type=click.Path(), callback=validate_and_load_or_create_config, show_default=True, help="Specify the config file to use or leave it blank to skip using any config file")
76
+ @click.option("--playground", "-p", metavar="(PROJECT)", is_flag=False, flag_value="default", help="Install into the default Playground or into the specified Playground project.")
77
+ @wraps(func)
78
+ def wrapper(*args, **kwargs):
79
+ return func(*args, **kwargs)
80
+ return wrapper
81
+
82
+
83
+ class APIURLType(click.ParamType):
84
+ name = "url"
85
+ def convert(self, value, param, ctx):
86
+ parsed_url = urlparse(value)
87
+ if all([re.search(r"""^https?://[^\s]+$""", value), parsed_url.scheme, parsed_url.netloc]):
88
+ return f"""{parsed_url.scheme}://{parsed_url.netloc}"""
89
+ else:
90
+ self.fail(f""""{value}" is not a valid URL""", param, ctx)
91
+
92
+
93
+ def validate_package_directory(ctx, param, directory: str) -> str:
94
+ directory = os.path.abspath(directory)
95
+ if not os.path.exists(directory):
96
+ raise click.BadParameter(f"""Directory "{directory}" does not exist.""")
97
+ if not os.path.isfile(os.path.join(directory, "setup.py")):
98
+ raise click.BadParameter(f"""Directory "{directory}" does not contain a setup.py file, so it is not the directory of a valid Python package.""")
99
+ else:
100
+ return directory
101
+
102
+
103
+ def validate_and_load_or_create_config(ctx, param, config: str) -> tuple[str, list]:
104
+ if not config:
105
+ return (None, [])
106
+ config = os.path.abspath(config)
107
+ if not os.path.isfile(config):
108
+ if config == DEFAULT_CONFIG:
109
+ env = []
110
+ with open(config, "w", encoding="utf-8") as fp:
111
+ yaml.dump(env, fp)
112
+ os.chmod(config, stat.S_IRUSR | stat.S_IWUSR)
113
+ else:
114
+ raise click.BadParameter(f"""{config} doesn't exist.""")
115
+ try:
116
+ with open(config, "r", encoding="utf-8") as fp:
117
+ env = yaml.load(fp, Loader=yaml.FullLoader)
118
+ if not isinstance(env, list):
119
+ raise Exception
120
+ except Exception:
121
+ raise click.BadParameter("File is not a usable docassemblecli config.")
122
+ return (config, env)
123
+
124
+
125
+ # -----------------------------------------------------------------------------
126
+ # utility functions
127
+ # -----------------------------------------------------------------------------
128
+
129
+ def name_from_url(url: str) -> str:
130
+ if not url:
131
+ return ""
132
+ return urlparse(url).netloc
133
+
134
+
135
+ def display_servers(env: list = None) -> list[str]:
136
+ if not env:
137
+ return ["No servers found."]
138
+ servers = []
139
+ for idx, item in enumerate(env):
140
+ if idx:
141
+ servers.append(item.get("name", ""))
142
+ else:
143
+ servers.append(item.get("name", "") + " (default)")
144
+ return servers
145
+
146
+
147
+ def select_server(cfg: str = None, env: list = None, apiurl: str = None, apikey: str = None, server: str = None) -> dict:
148
+ if apiurl and apikey:
149
+ return add_server_to_env(cfg=cfg, env=env, apiurl=apiurl, apikey=apikey)[-1]
150
+ if isinstance(env, list):
151
+ if server:
152
+ if not cfg:
153
+ raise click.BadParameter("Cannot be used without a config file.", param_hint="--server")
154
+ else:
155
+ for item in env:
156
+ if item.get("name", "") == server:
157
+ return item
158
+ raise click.BadParameter(f"""Server "{server}" was not found.""", param_hint="--server")
159
+ if len(env) > 0:
160
+ return env[0]
161
+ if "DOCASSEMBLEAPIURL" in os.environ and "DOCASSEMBLEAPIKEY" in os.environ:
162
+ apiurl: str = os.environ["DOCASSEMBLEAPIURL"]
163
+ apikey: str = os.environ["DOCASSEMBLEAPIKEY"]
164
+ return add_or_update_env(apiurl=apiurl, apikey=apikey)[0]
165
+ return add_server_to_env(cfg, env)[0]
166
+
167
+
168
+ def add_or_update_env(env: list = None, apiurl: str = "", apikey: str = "") -> list:
169
+ if not env:
170
+ env: list = []
171
+ apiname: str = name_from_url(apiurl)
172
+ found: bool = False
173
+ for item in env:
174
+ if item.get("name", None) == apiname:
175
+ item["apiurl"] = apiurl
176
+ item["apikey"] = apikey
177
+ found = True
178
+ click.echo(f"""Server "{apiname}" was found and updated.""")
179
+ break
180
+ if not found:
181
+ env.append({"apiurl": apiurl, "apikey": apikey, "name": apiname})
182
+ return env
183
+
184
+
185
+ def save_config(cfg: str, env: list) -> bool:
186
+ try:
187
+ with open(cfg, "w", encoding="utf-8") as fp:
188
+ yaml.dump(env, fp)
189
+ os.chmod(cfg, stat.S_IRUSR | stat.S_IWUSR)
190
+ except Exception as err:
191
+ click.echo(f"Unable to save {cfg} file. {err.__class__.__name__}: {err}")
192
+ return False
193
+ return True
194
+
195
+
196
+ def prompt_for_api(retry: str = False, previous_url: str = None, previous_key: str = None) -> tuple[str, str]:
197
+ if retry:
198
+ if not click.confirm("Do you want to try another URL and API key?", default=True):
199
+ raise click.Abort()
200
+ apiurl = click.prompt("""Base URL of your docassemble server (e.g., https://da.example.com)""", type=APIURLType(), default=previous_url)
201
+ apikey = click.prompt(f"""API key of admin or developer user on {apiurl}""", default=previous_key).strip()
202
+ return apiurl, apikey
203
+
204
+
205
+ def test_apiurl_apikey(apiurl: str, apikey: str) -> bool:
206
+ click.echo("Testing the URL and API key...")
207
+ try:
208
+ api_test = requests.get(apiurl + "/api/package", headers={"X-API-Key": apikey})
209
+ if api_test.status_code != 200:
210
+ if api_test.status_code == 403:
211
+ click.secho(f"""\nThe API KEY is invalid. ({api_test.status_code} {api_test.text.strip()})\n""", fg="red")
212
+ else:
213
+ click.secho(f"""\nThe API URL or KEY is invalid. ({api_test.status_code} {api_test.text.strip()})\n""", fg="red")
214
+ return False
215
+ except Exception as err:
216
+ click.secho(f"""\n{err.__class__.__name__}""", fg="red")
217
+ click.echo(f"""{err}\n""")
218
+ return False
219
+ click.secho("Success!", fg="green")
220
+ return True
221
+
222
+
223
+ def add_server_to_env(cfg: str = None, env: list = None, apiurl: str = None, apikey: str = None):
224
+ if not apiurl or not apikey:
225
+ apiurl, apikey = prompt_for_api()
226
+ while not test_apiurl_apikey(apiurl=apiurl, apikey=apikey):
227
+ apiurl, apikey = prompt_for_api(retry=True, previous_url=apiurl, previous_key=apikey)
228
+ env = add_or_update_env(env=env, apiurl=apiurl, apikey=apikey)
229
+ if cfg:
230
+ if save_config(cfg, env):
231
+ click.echo(f"""Configuration saved: {cfg}""")
232
+ return env
233
+
234
+
235
+ def select_env(cfg: str = None, env: list = None, apiurl: str = None, apikey: str = None, server: str = None) -> dict:
236
+ if apiurl and apikey:
237
+ return add_server_to_env(cfg=cfg, env=env, apiurl=apiurl, apikey=apikey)[-1]
238
+ else:
239
+ return select_server(cfg=cfg, env=env, server=server)
240
+
241
+
242
+ def wait_for_server(playground:bool, task_id, apikey, apiurl):
243
+ click.secho("Waiting for package to install...", fg="cyan")
244
+ tries = 0
245
+ before_wait_for_server = time.time()
246
+ while tries < 300:
247
+ if playground:
248
+ full_url = apiurl + "/api/restart_status"
249
+ else:
250
+ full_url = apiurl + "/api/package_update_status"
251
+ try:
252
+ r = requests.get(full_url, params={"task_id": task_id}, headers={"X-API-Key": apikey})
253
+ except requests.exceptions.RequestException:
254
+ pass
255
+ if r.status_code != 200:
256
+ return("package_update_status returned " + str(r.status_code) + ": " + r.text)
257
+ info = r.json()
258
+ if info["status"] == "completed" or info["status"] == "unknown":
259
+ break
260
+ time.sleep(1)
261
+ tries += 1
262
+ after_wait_for_server = time.time()
263
+ success = False
264
+ if playground:
265
+ if info.get("status", None) == "completed":
266
+ success = True
267
+ elif info.get("ok", False):
268
+ success = True
269
+ click.secho("Waiting for server...", fg="cyan")
270
+ time.sleep(after_wait_for_server - before_wait_for_server)
271
+ if success:
272
+ return True
273
+ click.secho("\nUnable to install package.\n", fg="red")
274
+ if not playground:
275
+ if "error_message" in info and isinstance(info["error_message"], str):
276
+ click.secho(info["error_message"], fg="red")
277
+ else:
278
+ click.echo(info)
279
+ return False
280
+
281
+
282
+ # -----------------------------------------------------------------------------
283
+ # package_installer
284
+ # -----------------------------------------------------------------------------
285
+ def package_installer(directory, apiurl, apikey, playground, restart):
286
+ archive = tempfile.NamedTemporaryFile(suffix=".zip")
287
+ zf = zipfile.ZipFile(archive, compression=zipfile.ZIP_DEFLATED, mode="w")
288
+ try:
289
+ ignore_process = subprocess.run(["git", "ls-files", "-i", "--directory", "-o", "--exclude-standard"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True, cwd=directory)
290
+ ignore_process.check_returncode()
291
+ raw_ignore = ignore_process.stdout.splitlines()
292
+ except Exception:
293
+ raw_ignore = []
294
+ to_ignore = [path.rstrip("/") for path in raw_ignore]
295
+ root_directory = None
296
+ has_python_files = False
297
+ this_package_name = None
298
+ dependencies = {}
299
+ for root, dirs, files in os.walk(directory, topdown=True):
300
+ adjusted_root = os.sep.join(root.split(os.sep)[1:])
301
+ dirs[:] = [d for d in dirs if d not in [".git", "__pycache__", ".mypy_cache", ".venv", ".history", "build"] and not d.endswith(".egg-info") and os.path.join(adjusted_root, d) not in to_ignore]
302
+ if root_directory is None and ("setup.py" in files or "setup.cfg" in files):
303
+ root_directory = root
304
+ if "setup.py" in files:
305
+ with open(os.path.join(root, "setup.py"), "r", encoding="utf-8") as fp:
306
+ setup_text = fp.read()
307
+ m = re.search(r"""setup\(.*\bname=(["\"])(.*?)(["\"])""", setup_text)
308
+ if m and m.group(1) == m.group(3):
309
+ this_package_name = m.group(2).strip()
310
+ m = re.search(r"""setup\(.*install_requires=\[(.*?)\]""", setup_text, flags=re.DOTALL)
311
+ if m:
312
+ for package_text in m.group(1).split(","):
313
+ package_name = package_text.strip()
314
+ if len(package_name) >= 3 and package_name[0] == package_name[-1] and package_name[0] in (""", """):
315
+ package_name = package_name[1:-1]
316
+ mm = re.search(r"""(.*)(<=|>=|==|<|>)(.*)""", package_name)
317
+ if mm:
318
+ dependencies[mm.group(1).strip()] = {"installed": False, "operator": mm.group(2), "version": mm.group(3).strip()}
319
+ else:
320
+ dependencies[package_name] = {"installed": False, "operator": None, "version": None}
321
+ for the_file in files:
322
+ if the_file.endswith("~") or the_file.endswith(".pyc") or the_file.startswith("#") or the_file.startswith(".#") or (the_file == ".gitignore" and root_directory == root) or os.path.join(adjusted_root, the_file) in to_ignore:
323
+ continue
324
+ if not has_python_files and the_file.endswith(".py") and not (the_file == "setup.py" and root == root_directory) and the_file != "__init__.py":
325
+ has_python_files = True
326
+ zf.write(os.path.join(root, the_file), os.path.relpath(os.path.join(root, the_file), os.path.join(directory, "..")))
327
+ zf.close()
328
+ archive.seek(0)
329
+ if restart == "no":
330
+ should_restart = False
331
+ elif restart =="yes" or has_python_files:
332
+ should_restart = True
333
+ elif len(dependencies) > 0 or this_package_name:
334
+ try:
335
+ r = requests.get(apiurl + "/api/package", headers={"X-API-Key": apikey})
336
+ except Exception as err:
337
+ click.secho(f"""\n{err.__class__.__name__}""", fg="red")
338
+ raise click.ClickException(f"""{err}\n""")
339
+ if r.status_code != 200:
340
+ return("/api/package returned " + str(r.status_code) + ": " + r.text)
341
+ installed_packages = r.json()
342
+ already_installed = False
343
+ for package_info in installed_packages:
344
+ package_info["alt_name"] = re.sub(r"^docassemble\.", "docassemble-", package_info["name"])
345
+ for dependency_name, dependency_info in dependencies.items():
346
+ if dependency_name in (package_info["name"], package_info["alt_name"]):
347
+ condition = True
348
+ if dependency_info["operator"]:
349
+ if dependency_info["operator"] == "==":
350
+ condition = version.parse(package_info["version"]) == version.parse(dependency_info["version"])
351
+ elif dependency_info["operator"] == "<=":
352
+ condition = version.parse(package_info["version"]) <= version.parse(dependency_info["version"])
353
+ elif dependency_info["operator"] == ">=":
354
+ condition = version.parse(package_info["version"]) >= version.parse(dependency_info["version"])
355
+ elif dependency_info["operator"] == "<":
356
+ condition = version.parse(package_info["version"]) < version.parse(dependency_info["version"])
357
+ elif dependency_info["operator"] == ">":
358
+ condition = version.parse(package_info["version"]) > version.parse(dependency_info["version"])
359
+ if condition:
360
+ dependency_info["installed"] = True
361
+ if this_package_name and this_package_name in (package_info["name"], package_info["alt_name"]):
362
+ already_installed = True
363
+ should_restart = bool((not already_installed and len(dependencies) > 0) or not all(item["installed"] for item in dependencies.values()))
364
+ else:
365
+ should_restart = True
366
+ data = {}
367
+ if should_restart:
368
+ click.secho("Server will restart.", fg="yellow")
369
+ if not should_restart:
370
+ data["restart"] = "0"
371
+ if playground:
372
+ if playground != "default":
373
+ data["project"] = playground
374
+ project_endpoint = apiurl + "/api/playground/project"
375
+ project_list = requests.get(project_endpoint, headers={"X-API-Key": apikey})
376
+ if project_list.status_code == 200:
377
+ if playground not in project_list:
378
+ try:
379
+ requests.post(project_endpoint, data={"project": playground}, headers={"X-API-Key": apikey})
380
+ except Exception:
381
+ return("create project POST returned " + project_list.text)
382
+ else:
383
+ click.echo("\n")
384
+ return("playground list of projects GET returned " + str(project_list.status_code) + ": " + project_list.text)
385
+ try:
386
+ r = requests.post(apiurl + "/api/playground_install", data=data, files={"file": archive}, headers={"X-API-Key": apikey})
387
+ except Exception as err:
388
+ click.secho(f"""\n{err.__class__.__name__}""", fg="red")
389
+ raise click.ClickException(f"""{err}\n""")
390
+ if r.status_code == 400:
391
+ try:
392
+ error_message = r.json()
393
+ except Exception:
394
+ error_message = ""
395
+ if "project" not in data or error_message != "Invalid project.":
396
+ return("playground_install POST returned " + str(r.status_code) + ": " + r.text)
397
+ try:
398
+ r = requests.post(apiurl + "/api/playground/project", data={"project": data["project"]}, headers={"X-API-Key": apikey})
399
+ except Exception as err:
400
+ click.secho(f"""\n{err.__class__.__name__}""", fg="red")
401
+ raise click.ClickException(f"""{err}\n""")
402
+ if r.status_code != 204:
403
+ return("needed to create playground project but POST to api/playground/project returned " + str(r.status_code) + ": " + r.text)
404
+ archive.seek(0)
405
+ try:
406
+ r = requests.post(apiurl + "/api/playground_install", data=data, files={"file": archive}, headers={"X-API-Key": apikey})
407
+ except Exception as err:
408
+ click.secho(f"""\n{err.__class__.__name__}""", fg="red")
409
+ raise click.ClickException(f"""{err}\n""")
410
+ if r.status_code == 200:
411
+ try:
412
+ info = r.json()
413
+ except Exception:
414
+ return(r.text)
415
+ task_id = info["task_id"]
416
+ success = wait_for_server(bool(playground), task_id, apikey, apiurl)
417
+ elif r.status_code == 204:
418
+ success = True
419
+ else:
420
+ click.echo("\n")
421
+ return("playground_install POST returned " + str(r.status_code) + ": " + r.text)
422
+ if success:
423
+ click.secho(f"""[{datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")}] Installed.""", fg="green")
424
+ else:
425
+ click.secho(f"""\n[{datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")}] Install failed!\n""", fg="red")
426
+ return 1
427
+ else:
428
+ try:
429
+ r = requests.post(apiurl + "/api/package", data=data, files={"zip": archive}, headers={"X-API-Key": apikey})
430
+ except Exception as err:
431
+ click.secho(f"""\n{err.__class__.__name__}""", fg="red")
432
+ raise click.ClickException(f"""{err}\n""")
433
+ if r.status_code != 200:
434
+ return("package POST returned " + str(r.status_code) + ": " + r.text)
435
+ info = r.json()
436
+ task_id = info["task_id"]
437
+ if wait_for_server(bool(playground), task_id, apikey, apiurl):
438
+ click.secho(f"""[{datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")}] Installed.""", fg="green")
439
+ if not should_restart:
440
+ try:
441
+ r = requests.post(apiurl + "/api/clear_cache", headers={"X-API-Key": apikey})
442
+ except Exception as err:
443
+ click.secho(f"""\n{err.__class__.__name__}""", fg="red")
444
+ raise click.ClickException(f"""{err}\n""")
445
+ if r.status_code != 204:
446
+ return("clear_cache returned " + str(r.status_code) + ": " + r.text)
447
+ return 0
448
+
449
+
450
+ # =============================================================================
451
+ # install
452
+ # =============================================================================
453
+ @cli.command(context_settings=CONTEXT_SETTINGS)
454
+ @common_params_for_api
455
+ @common_params_for_installation
456
+ @click.option("--restart", "-r", type=click.Choice(["yes", "no", "auto"]), default="auto", show_default=True, help="On package install: yes, force a restart | no, do not restart | auto, only restart if the package has any .py files or if there are dependencies to be installed")
457
+ def install(directory, config, api, server, playground, restart):
458
+ """
459
+ Install a docassemble package on a docassemble server.
460
+
461
+ `install` tries to get API info from the --api option first (if used), then from the first server listed in the ~/.docassemblecli file if it exists (unless the --config option is used), then it tries to use environmental variables, and finally it prompts the user directly.
462
+ """
463
+ selected_server = select_server(*config, *api, server)
464
+ click.secho(f"""[{datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")}] Installing...""", fg="yellow")
465
+ package_installer(directory=directory, apiurl=selected_server["apiurl"], apikey=selected_server["apikey"], playground=playground, restart=restart)
466
+ return 0
467
+
468
+
469
+ # -----------------------------------------------------------------------------
470
+ # watchdog
471
+ # -----------------------------------------------------------------------------
472
+
473
+ def matches_ignore_patterns(string):
474
+ ignore_patterns = [
475
+ r"*#*",
476
+ r"*/~*",
477
+ r".*~",
478
+ r"*/.git*",
479
+ r"*/flycheck_*",
480
+ r"*/.git/*",
481
+ r"*__pycache__*",
482
+ ]
483
+ for pattern in ignore_patterns:
484
+ if fnmatch.fnmatch(string, pattern):
485
+ return True
486
+ return False
487
+
488
+
489
+ class WatchHandler(FileSystemEventHandler):
490
+ def __init__(self, *args, **kwargs):
491
+ self.args = kwargs.pop("args", None)
492
+ super(WatchHandler, self).__init__(*args, **kwargs)
493
+
494
+ def on_any_event(self, event):
495
+ global LAST_MODIFIED
496
+ if event.is_directory:
497
+ return None
498
+ if event.event_type == "created" or event.event_type == "modified":
499
+ path = event.src_path.replace("\\", "/")
500
+ if not matches_ignore_patterns(path):
501
+ LAST_MODIFIED["time"] = time.time()
502
+ LAST_MODIFIED["files"][str(event.src_path)] = True
503
+ if str(event.src_path).endswith(".py"):
504
+ LAST_MODIFIED["restart"] = True
505
+
506
+
507
+ # =============================================================================
508
+ # watch
509
+ # =============================================================================
510
+ @cli.command(context_settings=CONTEXT_SETTINGS)
511
+ @common_params_for_installation
512
+ @common_params_for_api
513
+ @click.option("--restart", "-r", type=click.Choice(["yes", "no", "auto"]), default="auto", show_default=True, help="On package install: yes, force a restart | no, do not restart | auto, only restart if any .py files were changed")
514
+ @click.option("--buffer", "-b", metavar="SECONDS", default=3, show_default=True, help="Set the buffer (wait time) between a file change event and package installation. If you are experiencing multiple installs back-to-back, try increasing this value.")
515
+ def watch(directory, config, api, server, playground, restart, buffer):
516
+ """
517
+ Watch a package directory and `install` any changes. Press Ctrl + c to exit.
518
+ """
519
+ selected_server = select_server(*config, *api, server)
520
+ restart_param = restart
521
+ global LAST_MODIFIED
522
+ event_handler = WatchHandler()
523
+ observer = Observer()
524
+ observer.schedule(event_handler, directory, recursive=True)
525
+ observer.start()
526
+ click.echo()
527
+ click.echo(f"""Server: {selected_server["name"]}""")
528
+ if not playground:
529
+ click.echo("Location: Package")
530
+ else:
531
+ click.echo(f"""Location: Playground "{playground}" """)
532
+ click.echo(f"""Watching: {directory}""")
533
+ click.secho(f"""[{datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")}] Started""", fg="green")
534
+ try:
535
+ while True:
536
+ if LAST_MODIFIED["time"]:
537
+ click.secho(f"""[{datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")}] Installing...""", fg="yellow")
538
+ if restart_param == "yes" or (restart_param == "auto" and LAST_MODIFIED["restart"]):
539
+ restart = "yes"
540
+ else:
541
+ restart = "no"
542
+ time.sleep(buffer)
543
+ for item in LAST_MODIFIED["files"].keys():
544
+ click.echo(" " + item.replace(directory, ""))
545
+ LAST_MODIFIED["time"] = 0
546
+ LAST_MODIFIED["files"] = {}
547
+ LAST_MODIFIED["restart"] = False
548
+ package_installer(directory=directory, apiurl=selected_server["apiurl"], apikey=selected_server["apikey"], playground=playground, restart=restart)
549
+ # click.echo(f"""\n[{datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")}] Watching... {directory}""")
550
+ time.sleep(1)
551
+ except Exception as e:
552
+ click.echo(f"\nException occurred: {e}")
553
+ finally:
554
+ observer.stop()
555
+ observer.join()
556
+ return("""\nStopping "docassemblecli3 watch".""")
557
+
558
+
559
+ # =============================================================================
560
+ # create
561
+ # =============================================================================
562
+ @cli.command(context_settings=CONTEXT_SETTINGS)
563
+ @click.option("--package", metavar="PACKAGE", help="Name of the package you want to create")
564
+ @click.option("--developer-name", metavar="NAME", help="Name of the developer of the package")
565
+ @click.option("--developer-email", metavar="EMAIL", help="Email of the developer of the package")
566
+ @click.option("--description", metavar="DESCRIPTION", help="Description of package")
567
+ @click.option("--url", metavar="URL", help="URL of package")
568
+ @click.option("--license", metavar="LICENSE", help="License of package")
569
+ @click.option("--version", metavar="VERSION", help="Version number of package")
570
+ @click.option("--output", metavar="OUTPUT", help="Output directory in which to create the package")
571
+ def create(package, developer_name, developer_email, description, url, license, version, output):
572
+ """
573
+ Create an empty docassemble add-on package.
574
+ """
575
+ pkgname = package
576
+ if not pkgname:
577
+ pkgname = click.prompt("Name of the package you want to create (e.g., childsupport)")
578
+ pkgname = re.sub(r"\s", "", pkgname)
579
+ if not pkgname:
580
+ return("The package name you entered is invalid.")
581
+ pkgname = re.sub(r"^docassemble[\-\.]", "", pkgname, flags=re.IGNORECASE)
582
+ if output:
583
+ packagedir = output
584
+ else:
585
+ packagedir = "docassemble-" + pkgname
586
+ if os.path.exists(packagedir):
587
+ if not os.path.isdir(packagedir):
588
+ return("Cannot create the directory " + packagedir + " because the path already exists.")
589
+ dir_listing = list(os.listdir(packagedir))
590
+ if "setup.py" in dir_listing or "setup.cfg" in dir_listing:
591
+ return("The directory " + packagedir + " already has a package in it.")
592
+ else:
593
+ os.makedirs(packagedir, exist_ok=True)
594
+ if not developer_name:
595
+ developer_name = click.prompt("Name of developer").strip()
596
+ if not developer_name:
597
+ developer_name = "Your Name Here"
598
+ if not developer_email:
599
+ developer_email = click.prompt("Email address of developer (e.g., developer@example.com)").strip()
600
+ if not developer_email:
601
+ developer_email = "developer@example.com"
602
+ if not description:
603
+ description = click.prompt("Description of package (e.g., A docassemble extension)").strip()
604
+ if not description:
605
+ description = "A docassemble extension."
606
+ package_url = url
607
+ if not package_url:
608
+ package_url = click.prompt("URL of package (e.g., https://docassemble.org)").strip()
609
+ if not package_url:
610
+ package_url = "https://docassemble.org"
611
+ if not license:
612
+ license = click.prompt("License of package", default="MIT", show_default=True).strip()
613
+ if not version:
614
+ version = click.prompt("Version of package", default="0.0.1", show_default=True).strip()
615
+ initpy = """\
616
+ __import__("pkg_resources").declare_namespace(__name__)
617
+
618
+ """
619
+ if "MIT" in license:
620
+ licensetext = "The MIT License (MIT)\n\nCopyright (c) " + str(datetime.datetime.now().year) + " " + developer_name + """
621
+
622
+ Permission is hereby granted, free of charge, to any person obtaining a copy
623
+ of this software and associated documentation files (the "Software"), to deal
624
+ in the Software without restriction, including without limitation the rights
625
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
626
+ copies of the Software, and to permit persons to whom the Software is
627
+ furnished to do so, subject to the following conditions:
628
+
629
+ The above copyright notice and this permission notice shall be included in all
630
+ copies or substantial portions of the Software.
631
+
632
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
633
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
634
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
635
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
636
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
637
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
638
+ SOFTWARE.
639
+ """
640
+ else:
641
+ licensetext = license + "\n"
642
+ readme = "# docassemble." + pkgname + "\n\n" + description + "\n\n## Author\n\n" + developer_name + ", " + developer_email + "\n"
643
+ manifestin = """\
644
+ include README.md
645
+ """
646
+ setupcfg = """\
647
+ [metadata]
648
+ description_file = README.md
649
+ """
650
+ setuppy = """\
651
+ import os
652
+ import sys
653
+ from setuptools import setup, find_packages
654
+ from fnmatch import fnmatchcase
655
+ from distutils.util import convert_path
656
+
657
+ standard_exclude = ("*.pyc", "*~", ".*", "*.bak", "*.swp*")
658
+ standard_exclude_directories = (".*", "CVS", "_darcs", "./build", "./dist", "EGG-INFO", "*.egg-info")
659
+
660
+ def find_package_data(where=".", package="", exclude=standard_exclude, exclude_directories=standard_exclude_directories):
661
+ out = {}
662
+ stack = [(convert_path(where), "", package)]
663
+ while stack:
664
+ where, prefix, package = stack.pop(0)
665
+ for name in os.listdir(where):
666
+ fn = os.path.join(where, name)
667
+ if os.path.isdir(fn):
668
+ bad_name = False
669
+ for pattern in exclude_directories:
670
+ if (fnmatchcase(name, pattern)
671
+ or fn.lower() == pattern.lower()):
672
+ bad_name = True
673
+ break
674
+ if bad_name:
675
+ continue
676
+ if os.path.isfile(os.path.join(fn, "__init__.py")):
677
+ if not package:
678
+ new_package = name
679
+ else:
680
+ new_package = package + "." + name
681
+ stack.append((fn, "", new_package))
682
+ else:
683
+ stack.append((fn, prefix + name + "/", package))
684
+ else:
685
+ bad_name = False
686
+ for pattern in exclude:
687
+ if (fnmatchcase(name, pattern)
688
+ or fn.lower() == pattern.lower()):
689
+ bad_name = True
690
+ break
691
+ if bad_name:
692
+ continue
693
+ out.setdefault(package, []).append(prefix+name)
694
+ return out
695
+
696
+ """
697
+ setuppy += "setup(name=" + repr("docassemble." + pkgname) + """,
698
+ version=""" + repr(version) + """,
699
+ description=(""" + repr(description) + """),
700
+ long_description=""" + repr(readme) + """,
701
+ long_description_content_type="text/markdown",
702
+ author=""" + repr(developer_name) + """,
703
+ author_email=""" + repr(developer_email) + """,
704
+ license=""" + repr(license) + """,
705
+ url=""" + repr(package_url) + """,
706
+ packages=find_packages(),
707
+ namespace_packages=["docassemble"],
708
+ install_requires=[],
709
+ zip_safe=False,
710
+ package_data=find_package_data(where='docassemble/""" + pkgname + """/', package='docassemble.""" + pkgname + """'),
711
+ )
712
+ """
713
+ # maindir = os.path.join(packagedir, "docassemble", pkgname)
714
+ questionsdir = os.path.join(packagedir, "docassemble", pkgname, "data", "questions")
715
+ templatesdir = os.path.join(packagedir, "docassemble", pkgname, "data", "templates")
716
+ staticdir = os.path.join(packagedir, "docassemble", pkgname, "data", "static")
717
+ sourcesdir = os.path.join(packagedir, "docassemble", pkgname, "data", "sources")
718
+ if not os.path.isdir(questionsdir):
719
+ os.makedirs(questionsdir, exist_ok=True)
720
+ if not os.path.isdir(templatesdir):
721
+ os.makedirs(templatesdir, exist_ok=True)
722
+ if not os.path.isdir(staticdir):
723
+ os.makedirs(staticdir, exist_ok=True)
724
+ if not os.path.isdir(sourcesdir):
725
+ os.makedirs(sourcesdir, exist_ok=True)
726
+ with open(os.path.join(packagedir, "README.md"), "w", encoding="utf-8") as the_file:
727
+ the_file.write(readme)
728
+ with open(os.path.join(packagedir, "LICENSE"), "w", encoding="utf-8") as the_file:
729
+ the_file.write(licensetext)
730
+ with open(os.path.join(packagedir, "setup.py"), "w", encoding="utf-8") as the_file:
731
+ the_file.write(setuppy)
732
+ with open(os.path.join(packagedir, "setup.cfg"), "w", encoding="utf-8") as the_file:
733
+ the_file.write(setupcfg)
734
+ with open(os.path.join(packagedir, "MANIFEST.in"), "w", encoding="utf-8") as the_file:
735
+ the_file.write(manifestin)
736
+ with open(os.path.join(packagedir, "docassemble", "__init__.py"), "w", encoding="utf-8") as the_file:
737
+ the_file.write(initpy)
738
+ with open(os.path.join(packagedir, "docassemble", pkgname, "__init__.py"), "w", encoding="utf-8") as the_file:
739
+ the_file.write("__version__ = " + repr(version) + "\n")
740
+ return 0
741
+
742
+
743
+ # =============================================================================
744
+ # config
745
+ # =============================================================================
746
+
747
+ @config.command(context_settings=CONTEXT_SETTINGS)
748
+ @common_params_for_config
749
+ @click.option("--api", "-a", type=(APIURLType(), str), default=(None, None), help="URL of the docassemble server and API key of the user (admin or developer)")
750
+ def add(config, api):
751
+ """
752
+ Add a server to the config file.
753
+ """
754
+ apiurl, apikey = api
755
+ if not apiurl or not apikey:
756
+ apiurl, apikey = prompt_for_api(previous_url=apiurl, previous_key=apikey)
757
+ cfg, env = config
758
+ add_server_to_env(cfg=cfg, env=env, apiurl=apiurl, apikey=apikey)
759
+
760
+
761
+ @config.command(context_settings=CONTEXT_SETTINGS)
762
+ @common_params_for_config
763
+ @click.option("--server", "-s", metavar="SERVER", help="Specify a server to remove from the config file")
764
+ def remove(config, server):
765
+ """
766
+ Remove a server from the config file.
767
+ """
768
+ cfg, env = config
769
+ if not server:
770
+ click.echo(f"""Servers in {cfg}:""")
771
+ for item in display_servers(env=env):
772
+ click.echo(" " + item)
773
+ server = click.prompt("Remove which server?")
774
+ selected_server = select_server(cfg=cfg, env=env, server=server)
775
+ env.remove(selected_server)
776
+ save_config(cfg=cfg, env=env)
777
+ click.echo(f"""Server "{server}" has been removed from {cfg}.""")
778
+
779
+
780
+ @config.command(context_settings=CONTEXT_SETTINGS)
781
+ @common_params_for_config
782
+ def display(config):
783
+ """
784
+ List the servers in the config file.
785
+ """
786
+ _, env = config
787
+ for item in display_servers(env=env):
788
+ click.echo(" " + item)
789
+
790
+
791
+ @config.command(context_settings=CONTEXT_SETTINGS)
792
+ @click.argument("config", type=click.File(mode="w", encoding="utf-8"))
793
+ def new(config):
794
+ """
795
+ Create a new config file.
796
+ """
797
+ if os.path.exists(config.name) and os.stat(config.name).st_size != 0:
798
+ raise click.BadParameter("File exists and is not empty!")
799
+ env = []
800
+ try:
801
+ yaml.dump(env, config)
802
+ os.chmod(config.name, stat.S_IRUSR | stat.S_IWUSR)
803
+ except Exception:
804
+ raise click.BadParameter("File is not usable.")
805
+ click.echo(f"""Config created successfully: {os.path.abspath(config.name)}""")
806
+ if click.confirm("Do you want to add a server to this new config file?", default=True):
807
+ apiurl, apikey = prompt_for_api()
808
+ add_server_to_env(cfg=config.name, env=env, apiurl=apiurl, apikey=apikey)
809
+
810
+
@@ -0,0 +1,22 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2021 Jonathan Pyle
4
+ Copyright (c) 2024 Jack Adamson
5
+
6
+ Permission is hereby granted, free of charge, to any person obtaining a copy
7
+ of this software and associated documentation files (the "Software"), to deal
8
+ in the Software without restriction, including without limitation the rights
9
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
+ copies of the Software, and to permit persons to whom the Software is
11
+ furnished to do so, subject to the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be included in all
14
+ copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
+ SOFTWARE.
@@ -0,0 +1,354 @@
1
+ Metadata-Version: 2.1
2
+ Name: docassemblecli3
3
+ Version: 0.0.1
4
+ Summary: Multi-platform CLI utilities for using Docassemble
5
+ Home-page: https://github.com/jpagh/docassemblecli3/
6
+ License: MIT
7
+ Author: Jack Adamson
8
+ Author-email: jackadamson@gmail.com
9
+ Requires-Python: >=3.12,<4.0
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Requires-Dist: PyYAML (>=6.0.2,<7.0.0)
14
+ Requires-Dist: click (>=8.1.7,<9.0.0)
15
+ Requires-Dist: packaging (>=24.1,<25.0)
16
+ Requires-Dist: requests (>=2.32.3,<3.0.0)
17
+ Requires-Dist: watchdog (>=4.0.2,<5.0.0)
18
+ Description-Content-Type: text/markdown
19
+
20
+ # docassemblecli3
21
+
22
+ `docassemblecli3` provides command-line utilities for interacting with
23
+ [docassemble] servers. This package is meant to be installed on your local
24
+ machine, not on a [docassemble] server.
25
+
26
+ This project is based on [docassemblecli] by Jonathan Pyle Copyright (c) 2021
27
+ released under the MIT License.
28
+
29
+ ## Differences from [docassemblecli]
30
+
31
+ - Requires Python 3.
32
+ - Adds multi-platform file monitoring, a.k.a. `dawatchinstall` works on Windows
33
+ and without requiring fswatch.
34
+ - Adds queueing and batching to improve file monitoring and installation
35
+ (improves multi-file saving, late file metadata changes, and avoids server
36
+ restart induced timeouts).
37
+ - Improves invocation, requiring less configuration of PATH and scripts to work,
38
+ especially in Windows (and does not conflict with [docassemblecli]).
39
+ - Improved command structure and option flags (so please read this documentation
40
+ or utilize the `--help` or `-h` options in the terminal).
41
+
42
+ ## Prerequisites
43
+
44
+ This program should only require that you have Python 3.8 installed on your
45
+ computer, but it was developed and tested with Python 3.12. Please report any
46
+ bugs or errors you experience.
47
+
48
+ ## Installation
49
+
50
+ To install `docassemblecli3` from PyPI, run:
51
+
52
+ pip install docassemblecli3
53
+
54
+ ## Usage
55
+
56
+ `docassemblecli3` be more easily be run by typing `da`.
57
+
58
+ All of the command options, such as showing the "help", have both long `--help`
59
+ and short `-h` versions. This documentation will always use the long version,
60
+ but feel free to use whichever you prefer.
61
+
62
+ Usage: da [OPTIONS] COMMAND [ARGS]...
63
+
64
+ Commands for interacting with docassemble servers.
65
+
66
+ Options:
67
+ -C, --color / -N, --no-color Overrides color auto-detection in interactive
68
+ terminals.
69
+ -h, --help Show this message and exit.
70
+
71
+ Commands:
72
+ config Manage servers in a docassemblecli config file.
73
+ create Create an empty docassemble add-on package.
74
+ install Install a docassemble package on a docassemble server.
75
+ watch Watch a package directory and `install` any changes.
76
+
77
+ ### create
78
+
79
+ `docassemblecli3` provides a command-line utility called `create`, which
80
+ creates an empty **docassemble** add-on package.
81
+
82
+ To create a package called `docassemble-foobar` in the current directory, run:
83
+
84
+ da create --package foobar
85
+
86
+ You will be asked some questions about the package and the developer. This
87
+ information is necessary because it goes into the `setup.py`, `README.md`, and
88
+ `LICENSE` files of the package. If you do not yet know what answers to give,
89
+ just press enter, and you can edit these files later.
90
+
91
+ When the command exits, you will find a directory in the current directory
92
+ called `docassemble-foobar` containing a shell of a **docassemble** add-on
93
+ package.
94
+
95
+ You can run `da create --help` to get more information about how `create`
96
+ works:
97
+
98
+ Usage: da create [OPTIONS]
99
+
100
+ Create an empty docassemble add-on package.
101
+
102
+ Options:
103
+ --package PACKAGE Name of the package you want to create
104
+ --developer-name NAME Name of the developer of the package
105
+ --developer-email EMAIL Email of the developer of the package
106
+ --description DESCRIPTION Description of package
107
+ --url URL URL of package
108
+ --license LICENSE License of package
109
+ --version VERSION Version number of package
110
+ --output OUTPUT Output directory in which to create the package
111
+ -h, --help Show this message and exit.
112
+
113
+ ### install
114
+
115
+ `docassemblecli3` provides a command-line utility called `install`, which
116
+ installs a Python package on a remote server using files on your local computer.
117
+
118
+ For example, suppose that you wrote a docassemble extension package called
119
+ `docassemble.foobar` using the **docassemble** Playground. In the Playground,
120
+ you can download the package as a ZIP file called `docassemble-foobar.zip`. You
121
+ can then unpack this ZIP file and you will see a directory called
122
+ `docassemble-foobar`. Inside of this directory there is a directory called
123
+ `docassemble` and a `setup.py` file.
124
+
125
+ From the command line, use `cd` to navigate into the directory
126
+ `docassemble-foobar`. Then run:
127
+
128
+ da install
129
+
130
+ or you can specify the directory of the package you want to install (if
131
+ `docassemble-foobar` is in your current directory):
132
+
133
+ da install --directory docassemble-foobar
134
+
135
+ The first time you run this command, it will ask you for the URL of your
136
+ **docassemble** server and the [API key] of a user with `admin` or `developer`
137
+ privileges.
138
+
139
+ It will look something like this:
140
+
141
+ $ da install --directory docassemble-foobar
142
+ Base URL of your docassemble server (e.g., https://da.example.com): https://da.example.com
143
+ API key of admin or developer user on https://da.example.com: H3PWMKJOIVAXL4PWUJH3HG7EKPFU5GYT
144
+ Testing the URL and API key...
145
+ Success!
146
+ Configuration saved: ~\.docassemblecli
147
+ [2024-08-16 18:10:18] Installing...
148
+ Server will restart.
149
+ Waiting for package to install...
150
+ Waiting for server...
151
+ [2024-08-16 18:11:43] Installed.
152
+
153
+ The next time you run `da install`, it will not ask you for the URL and API key.
154
+
155
+ You can run `da install --help` to get more information about how `install`
156
+ works:
157
+
158
+ Usage: da install [OPTIONS]
159
+
160
+ Install a docassemble package on a docassemble server.
161
+
162
+ `da install` tries to get API info from the --api option first (if used), then
163
+ from the first server listed in the ~/.docassemblecli file if it exists
164
+ (unless the --config option is used), then it tries to use environmental
165
+ variables, and finally it prompts the user directly.
166
+
167
+ Options:
168
+ -a, --api <URL TEXT>... URL of the docassemble server and API key of
169
+ the user (admin or developer)
170
+ -s, --server SERVER Specify a server from the config file
171
+ -d, --directory PATH Specify package directory [default: current
172
+ directory]
173
+ -c, --config PATH Specify the config file to use or leave it
174
+ blank to skip using any config file [default:
175
+ C:\Users\jacka\.docassemblecli]
176
+ -p, --playground (PROJECT) Install into the default Playground or into the
177
+ specified Playground project.
178
+ -r, --restart [yes|no|auto] On package install: yes, force a restart | no,
179
+ do not restart | auto, only restart if the
180
+ package has any .py files or if there are
181
+ dependencies to be installed [default: auto]
182
+ -h, --help Show this message and exit.
183
+
184
+ For example, you might want to pass the URL and API key in the command itself:
185
+
186
+ da install --api https://da.example.com H3PWMKJOIVAXL4PWUJH3HG7EKPFU5GYT --directory docassemble-foobar
187
+
188
+ If you have more than one server, you can utilize one of the `config` tools `add`:
189
+
190
+ da config add
191
+
192
+ to add an additional server configuration to store in your `.docassemblecli`
193
+ config file. Then you can select the server using `--server`:
194
+
195
+ da install --server da.example.com --directory docassemble-foobar
196
+
197
+ If you do not specify a `--server`, the first server indicated in your
198
+ `.docassemblecli` file will be used.
199
+
200
+ The `--restart no` option can be used when your **docassemble** installation
201
+ only uses one server (which is typical) and you are not modifying .py files. In
202
+ this case, it is not necessary for the Python web application to restart after
203
+ the package has been installed. This will cause `da install` to return a few
204
+ seconds faster than otherwise.
205
+
206
+ The `--restart yes` option should be used when you want to make sure that
207
+ **docassemble** restarts the Python web application after the package is
208
+ installed. By default, `da install` will avoid restarting the server if the
209
+ package has no module files and all of its dependencies (if any) are installed.
210
+
211
+ By default, `da install` installs a package on the server. If you want to install
212
+ a package into your Playground, you can use the `--playground` option.
213
+
214
+ dainstall --playground --directory docassemble-foobar
215
+
216
+ If you want to install into a particular project in your Playground, indicate
217
+ the project after the `--playground` option, for example project "testing".
218
+
219
+ da install --playground testing --directory docassemble-foobar
220
+
221
+ Installing into the Playground with `--playground` is faster than installing an
222
+ actual Python package because it does not need to run `pip`.
223
+
224
+ If your development installation uses more than one server, it is safe to run
225
+ `da install --playground` with `--restart no` if you are only changing YAML files,
226
+ because Playground YAML files are stored in cloud storage and will thus be
227
+ available immediately to all servers.
228
+
229
+ ### watch
230
+
231
+ You can use `watch` to automatically `install` your docassemble package every
232
+ time a file in your package directory is changed.
233
+
234
+ For example, if you run:
235
+
236
+ da watch --playground testing --directory docassemble-foobar
237
+
238
+ This will monitor the `docassemble-foobar` directory, and if any non-`.py` file
239
+ changes, it will run:
240
+
241
+ da install --playground testing --restart no --directory docassemble-foobar
242
+
243
+ If a `.py` file is changed, however, it will run
244
+
245
+ da install --playground testing --restart yes --directory docassemble-foobar
246
+
247
+ With `da watch --playground` constantly running, soon after you save a YAML file
248
+ on your local machine, it will very quickly be available for testing on your
249
+ server.
250
+
251
+ To exit `watch`, press **Ctrl + c**.
252
+
253
+ You can run `da watch --help` to get more information about how `watch`
254
+ works:
255
+
256
+ Usage: da watch [OPTIONS]
257
+
258
+ Watch a package directory and `install` any changes. Press Ctrl + c to exit.
259
+
260
+ Options:
261
+ -d, --directory PATH Specify package directory [default: current
262
+ directory]
263
+ -c, --config PATH Specify the config file to use or leave it
264
+ blank to skip using any config file [default:
265
+ C:\Users\jacka\.docassemblecli]
266
+ -p, --playground (PROJECT) Install into the default Playground or into the
267
+ specified Playground project.
268
+ -a, --api <URL TEXT>... URL of the docassemble server and API key of
269
+ the user (admin or developer)
270
+ -s, --server SERVER Specify a server from the config file
271
+ -r, --restart [yes|no|auto] On package install: yes, force a restart | no,
272
+ do not restart | auto, only restart if any .py
273
+ files were changed [default: auto]
274
+ -b, --buffer SECONDS Set the buffer (wait time) between a file
275
+ change event and package installation. If you
276
+ are experiencing multiple installs back-to-
277
+ back, try increasing this value. [default: 3]
278
+ -h, --help Show this message and exit.
279
+
280
+ #### watchdog
281
+
282
+ The `watch` command now depends on the
283
+ [watchdog](https://pypi.org/project/watchdog/) Python package. This allows
284
+ `watch` to work on the following platforms that [watchdog] supports:
285
+
286
+ - Linux 2.6 (inotify)
287
+ - macOS (FSEvents, kqueue)
288
+ - FreeBSD/BSD (kqueue)
289
+ - Windows (ReadDirectoryChangesW with I/O completion ports;
290
+ ReadDirectoryChangesW worker threads)
291
+ - OS-independent (polling the disk for directory snapshots and comparing them
292
+ periodically; slow and not recommended)
293
+
294
+ An additional note from [watchdog]'s documentation:
295
+
296
+ Note that when using watchdog with kqueue (macOS and BSD), you need the number of file
297
+ descriptors allowed to be opened by programs running on your system to be
298
+ increased to more than the number of files that you will be monitoring. The
299
+ easiest way to do that is to edit your ~/.profile file and add a line similar
300
+ to:
301
+
302
+ ```bash
303
+ ulimit -n 1024
304
+ ```
305
+
306
+ This is an inherent problem with kqueue because it uses file descriptors to
307
+ monitor files. That plus the enormous amount of bookkeeping that watchdog needs
308
+ to do in order to monitor file descriptors just makes this a painful way to
309
+ monitor files and directories. In essence, kqueue is not a very scalable way to
310
+ monitor a deeply nested directory of files and directories with a large number
311
+ of files.
312
+
313
+ ### config
314
+
315
+ There are four commands for managing your saved servers/your config file, `add`,
316
+ `display`, `new`, and `remove`.
317
+
318
+ Usage: da config [OPTIONS] COMMAND [ARGS]...
319
+
320
+ Manage servers in a docassemblecli config file.
321
+
322
+ Options:
323
+ -h, --help Show this message and exit.
324
+
325
+ Commands:
326
+ add Add a server to the config file.
327
+ display List the servers in the config file.
328
+ new Create a new config file.
329
+ remove Remove a server from the config file.
330
+
331
+ They are all really easy to use and will prompt you for all necessary
332
+ information.
333
+
334
+ ## How it works
335
+
336
+ The `install` command is just a simple Python script that creates a ZIP file and
337
+ uploads it through the **docassemble** API. Feel free to copy the code and write
338
+ your own scripts to save yourself time. (That's how this version started!)
339
+
340
+ ## Contributing
341
+
342
+ Pull requests are welcome. For major changes, please open an issue first
343
+ to discuss what you would like to change.
344
+
345
+ Please make sure to update tests as appropriate.
346
+
347
+ ## License
348
+
349
+ [MIT](https://choosealicense.com/licenses/mit/)
350
+
351
+ [docassemble]: https://docassemble.org
352
+ [docassemblecli]: https://github.com/jhpyle/docassemblecli/
353
+ [API key]: https://docassemble.org/docs/api.html#manage_api
354
+ [watchdog]: https://pypi.org/project/watchdog/
@@ -0,0 +1,7 @@
1
+ docassemblecli3/__init__.py,sha256=LmknC22vAdPEpXQod8hsJN8aDNhpm6vTeu2LVxwfxp4,63
2
+ docassemblecli3/docassemblecli3.py,sha256=x6T5IyLKYO6s2VE4fGNatFQuV9-6qYuK59aCtAPnU7Y,37748
3
+ docassemblecli3-0.0.1.dist-info/entry_points.txt,sha256=u1zHCoRgSfXRJDzhxZJD_O_f0_0L9O7DTdhTm5Y4ybo,78
4
+ docassemblecli3-0.0.1.dist-info/LICENSE.txt,sha256=USWXc4uJSvBWQchfDUBM9fmA_9XrgaPdffGSj5scrqc,1134
5
+ docassemblecli3-0.0.1.dist-info/METADATA,sha256=8UD-5iYwtcV1gx6u4qIufY9UhrMIXRDgy62QgRbj9wk,14312
6
+ docassemblecli3-0.0.1.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
7
+ docassemblecli3-0.0.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: poetry-core 1.9.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,4 @@
1
+ [console_scripts]
2
+ da=docassemblecli3:cli
3
+ docassemblecli3=docassemblecli3:cli
4
+