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.
- docassemblecli3/__init__.py +2 -0
- docassemblecli3/docassemblecli3.py +810 -0
- docassemblecli3-0.0.1.dist-info/LICENSE.txt +22 -0
- docassemblecli3-0.0.1.dist-info/METADATA +354 -0
- docassemblecli3-0.0.1.dist-info/RECORD +7 -0
- docassemblecli3-0.0.1.dist-info/WHEEL +4 -0
- docassemblecli3-0.0.1.dist-info/entry_points.txt +4 -0
|
@@ -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,,
|