Coverage for gramex\install.py : 63%

Hot-keys on this page
r m x p toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
1'''
2Defines command line services to install, setup and run apps.
3'''
4import io
5import os
6import re
7import six
8import sys
9import yaml
10import stat
11import shlex
12import string
13import shutil
14import datetime
15import requests
16from glob import glob
17from shutilwhich import which
18from pathlib import Path
19from subprocess import Popen, check_output, CalledProcessError # nosec
20from orderedattrdict import AttrDict
21from orderedattrdict.yamlutils import AttrDictYAMLLoader
22from zipfile import ZipFile
23from tornado.template import Template
24from orderedattrdict.yamlutils import from_yaml # noqa
25import gramex
26import gramex.license
27from gramex.config import ChainConfig, PathConfig, variables, app_log
29usage = '''
30install: |
31 usage:
32 gramex install <app> <url> [--target=DIR]
33 gramex install <app> --cmd="COMMAND" [--target=DIR]
35 "app" is any name you want to locally call the application.
37 "url" can be a:
38 - local ZIP file (/path/to/app.zip)
39 - local directory (/path/to/directory)
40 - URL of a ZIP file (https://github.com/user/repo/archive/master.zip)
42 target is the directory to install at (defaults to user data directory.)
44 cmd is a shell command to run. If it has the word "TARGET" in caps, it is
45 replaced by the target directory.
47 After installation, runs "gramex setup" which runs the Makefile, setup.ps1,
48 setup.sh, requirements.txt, setup.py, yarn/npm install and bower install.
50 Installed apps:
51 {apps}
53setup: |
54 usage: gramex setup <target> [<target> ...] [--all]
56 target is the directory to set up (required). This can be an absolute path,
57 relative path, or a directory name under $GRAMEXPATH/apps/.
59 gramex setup --all sets up all apps under $GRAMEXPATH/apps/
61 Run the following commands at that directory in sequence, if possible:
62 - make
63 - powershell -File setup.ps1
64 - bash setup.sh
65 - pip install --upgrade -r requirements.txt
66 - python setup.py
67 - yarn/npm install
68 - bower install
70run: |
71 usage: gramex run <app> [--target=DIR] [--dir=DIR] [--<options>=<value>]
73 "app" is the name of the locally installed application.
75 If "app" is not installed, specify --target=DIR to run from DIR. The next
76 "gramex run app" will automatically run from DIR.
78 "dir" is a *sub-directory* under "target" to run from. This is useful if
79 "app" has multiple sub-applications.
81 All Gramex command line options can be used. These are saved. For example:
83 gramex run app --target=/path/to/dir --listen.port=8899 --browser=true
85 ... will preserve the "target", "listen.port" and "browser" values. Running
86 "gramex run app" will re-use these values. To clear the option, leave the
87 value blank. For example "--browser=" will clear the browser option.
89 Installed apps:
90 {apps}
92uninstall: |
93 usage: gramex uninstall <app> [<app> ...]
95 "app" is the name of the locally installed application. You can uninstall
96 multiple applications in one command.
98 All information about the application is lost. You cannot undo this.
100 Installed apps:
101 {apps}
103service: |
104 usage: gramex service <cmd> [--options]
106 Install a Gramex application as a Windows service:
108 gramex service install
109 --cwd "C:/path/to/application/"
110 --user "DOMAIN\\USER" # Optional user to run as
111 --password "user-password" # Required if user is specified
112 --startup manual|auto|disabled
114 Update:
116 gramex service update <same parameters as install>
118 Remove:
120 gramex service remove # or gramex service uninstall
122 Start / stop commands
124 gramex service start
125 gramex service stop
127init: |
128 usage: gramex init [--target=DIR]
130 Initializes a Gramex project at the current or target dir. Specifically, it:
131 - Sets up a git repo
132 - Install supporting files for a gramex project
133 - Runs gramex setup (which runs yarn/npm install and other dependencies)
135mail: |
136 gramex mail <key> # Send mail named <key>
137 gramex mail --list # Lists all keys in config file
138 gramex mail --init # Initializes config file
140 The config is a gramex.yaml file. It must have email: and alert: sections.
141 If the current folder has a gramex.yaml, that's used. Else the default is
142 $GRAMEXDATA/mail/gramexmail.yaml.
144 Options:
145 --conf <path> # Specify a different conf file location
147license: |
148 gramex license # Show Gramex license
149 gramex license accept # Accept Gramex license
150 gramex license reject # Reject Gramex license
151'''
152usage = yaml.load(usage, Loader=AttrDictYAMLLoader) # nosec
155class TryAgainError(Exception):
156 '''If shutil.rmtree fails, and we've fixed the problem, raise this to try again'''
157 pass
160try:
161 WindowsError
162except NameError:
163 # On non-Windows systems, _ensure_remove just raises the exception
164 def _ensure_remove(remove, path, exc_info): # noqa -- redefine function
165 raise exc_info[1]
166else:
167 # On Windows systems, try harder
168 def _ensure_remove(function, path, exc_info):
169 '''onerror callback for rmtree that tries hard to delete files'''
170 if issubclass(exc_info[0], WindowsError): 170 ↛ 199line 170 didn't jump to line 199, because the condition on line 170 was never false
171 import winerror
172 # Delete read-only files
173 # https://bugs.python.org/issue19643
174 # https://bugs.python.org/msg218021
175 if exc_info[1].winerror == winerror.ERROR_ACCESS_DENIED:
176 os.chmod(path, stat.S_IWRITE)
177 return os.remove(path)
178 # Delay delete a bit if directory is used by another process.
179 # Typically happens on uninstall immediately after bower / npm / git
180 # (e.g. during testing.)
181 elif exc_info[1].winerror == winerror.ERROR_SHARING_VIOLATION: 181 ↛ 192line 181 didn't jump to line 192, because the condition on line 181 was never false
182 import time
183 delays = [0.001, 0.002, 0.005, 0.01, 0.02, 0.05, 0.1, 0.2, 0.5, 1.0]
184 for delay in delays:
185 time.sleep(delay)
186 try:
187 return os.remove(path)
188 except WindowsError:
189 pass
190 # npm creates windows shortcuts that shutil.rmtree cannot delete.
191 # os.listdir failes with a PATH_NOT_FOUND. Delete these and try again
192 elif function == os.listdir and exc_info[1].winerror == winerror.ERROR_PATH_NOT_FOUND:
193 app_log.error('Cannot delete %s', path)
194 from win32com.shell import shell, shellcon
195 options = shellcon.FOF_NOCONFIRMATION | shellcon.FOF_NOERRORUI
196 code, err = shell.SHFileOperation((0, shellcon.FO_DELETE, path, None, options))
197 if code == 0:
198 raise TryAgainError()
199 raise exc_info[1]
202def safe_rmtree(target):
203 '''
204 A replacement for shutil.rmtree that removes directories within $GRAMEXDATA.
205 It tries to remove the target multiple times, recovering from errors.
206 '''
207 if not os.path.exists(target):
208 return True
209 # TODO: check case insensitive in Windows, but case sensitive on other OS
210 elif target.lower().startswith(variables['GRAMEXDATA'].lower()):
211 # Try multiple times to recover from errors, since we have no way of
212 # auto-resuming rmtree: https://bugs.python.org/issue8523
213 for count in range(100): 213 ↛ 220line 213 didn't jump to line 220, because the loop on line 213 didn't complete
214 try:
215 shutil.rmtree(target, onerror=_ensure_remove)
216 except TryAgainError:
217 pass
218 else:
219 break
220 return True
221 else:
222 app_log.warning('Not removing directory %s (outside $GRAMEXDATA)', target)
223 return False
226def zip_prefix_filter(members, prefix):
227 '''
228 Return only ZIP file members starting with the directory prefix, with the
229 prefix stripped out.
230 '''
231 if not prefix: 231 ↛ 232line 231 didn't jump to line 232, because the condition on line 231 was never true
232 return members
233 offset = len(prefix)
234 result = []
235 for zipinfo in members:
236 if zipinfo.filename.startswith(prefix): 236 ↛ 235line 236 didn't jump to line 235, because the condition on line 236 was never false
237 zipinfo.filename = zipinfo.filename[offset:]
238 if len(zipinfo.filename) > 0:
239 result.append(zipinfo)
240 return result
243def run_install(config):
244 '''
245 Download config.url into config.target.
246 If config.url is a directory, copy it.
247 If config.url is a file or a URL (http, https, ftp), unzip it.
248 If config.contentdir is True, skip parent folders with single subfolder.
249 If no files match, log a warning.
250 '''
251 url, target = config.url, config.target
253 # If the URL is a directory, copy it
254 if os.path.isdir(url):
255 if os.path.exists(target):
256 url = os.path.abspath(url).lower().rstrip(os.sep)
257 target = os.path.abspath(target).lower().rstrip(os.sep)
258 if url != target: 258 ↛ 261line 258 didn't jump to line 261, because the condition on line 258 was never false
259 if not safe_rmtree(target): 259 ↛ 260line 259 didn't jump to line 260, because the condition on line 259 was never true
260 return
261 if url != target: 261 ↛ 264line 261 didn't jump to line 264, because the condition on line 261 was never false
262 shutil.copytree(url, target)
263 app_log.info('Copied %s into %s', url, target)
264 config.url = url
265 return
267 # If it's a file, unzip it
268 if os.path.exists(url):
269 handle = url
270 else:
271 # Otherwise, assume that it's a URL containing a ZIP file
272 app_log.info('Downloading: %s', url)
273 response = requests.get(url)
274 response.raise_for_status()
275 handle = six.BytesIO(response.content)
277 # Identify relevant files from the ZIP file
278 zipfile = ZipFile(handle)
279 files = zipfile.infolist()
280 if config.get('contentdir', True):
281 prefix = os.path.commonprefix(zipfile.namelist())
282 if prefix.endswith('/'):
283 files = zip_prefix_filter(files, prefix)
285 # Extract relevant files from ZIP file
286 if safe_rmtree(target): 286 ↛ exitline 286 didn't return from function 'run_install', because the condition on line 286 was never false
287 zipfile.extractall(target, files)
288 app_log.info('Extracted %d files into %s', len(files), target)
291def run_command(config):
292 '''
293 Run config.cmd. If the command has a TARGET, replace it with config.target.
294 Else append config.target as an argument.
295 '''
296 appcmd = config.cmd
297 # Split the command into an array of words
298 if isinstance(appcmd, six.string_types): 298 ↛ 301line 298 didn't jump to line 301, because the condition on line 298 was never false
299 appcmd = shlex.split(appcmd)
300 # If the app is a Cygwin app, TARGET should be a Cygwin path too.
301 target = config.target
302 cygcheck, cygpath, kwargs = which('cygcheck'), which('cygpath'), {'universal_newlines': True}
303 if cygcheck is not None and cygpath is not None: 303 ↛ 309line 303 didn't jump to line 309, because the condition on line 303 was never false
304 app_path = check_output([cygpath, '-au', which(appcmd[0])], **kwargs).strip() # nosec
305 is_cygwin_app = check_output([cygcheck, '-f', app_path], **kwargs).strip() # nosec
306 if is_cygwin_app: 306 ↛ 309line 306 didn't jump to line 309, because the condition on line 306 was never false
307 target = check_output([cygpath, '-au', target], **kwargs).strip() # n osec
308 # Replace TARGET with the actual target
309 if 'TARGET' in appcmd:
310 appcmd = [target if arg == 'TARGET' else arg for arg in appcmd]
311 else:
312 appcmd.append(target)
313 app_log.info('Running %s', ' '.join(appcmd))
314 if not safe_rmtree(config.target): 314 ↛ 315line 314 didn't jump to line 315, because the condition on line 314 was never true
315 app_log.error('Cannot delete target %s. Aborting installation', config.target)
316 return
317 proc = Popen(appcmd, bufsize=-1, stdout=sys.stdout, stderr=sys.stderr, **kwargs) # nosec
318 proc.communicate()
319 return proc.returncode
322# Setup file configurations.
323# Structure: {File: {exe: cmd}}
324# If File exists, then if exe exists, run cmd.
325# For example, if package.json exists:
326# then if yarn exists, run yarn install
327# else if npm exists, run npm install
328setup_paths = '''
329Makefile:
330 make: '"{EXE}"'
331setup.ps1:
332 powershell: '"{EXE}" -File "{FILE}"'
333setup.sh:
334 bash: '"{EXE}" "{FILE}"'
335requirements.txt:
336 pip: '"{EXE}" install -r "{FILE}"'
337setup.py:
338 python: '"{EXE}" "{FILE}"'
339package.json:
340 yarn: '"{EXE}" install --prefer-offline'
341 npm: '"{EXE}" install'
342bower.json:
343 bower: '"{EXE}" --allow-root install'
344'''
345setup_paths = yaml.load(setup_paths, Loader=AttrDictYAMLLoader) # nosec
348def run_setup(target):
349 '''
350 Install any setup file in target directory. Target directory can be:
352 - An absolute path
353 - A relative path to current directory
354 - A relative path to the Gramex apps/ folder
356 Returns the absolute path of the final target path.
358 This supports:
360 - ``make`` (if Makefile exists)
361 - ``powershell -File setup.ps1``
362 - ``bash setup.sh``
363 - ``pip install -r requirements.txt``
364 - ``python setup.py``
365 - ``yarn install`` else ``npm install``
366 - ``bower --allow-root install``
367 '''
368 if not os.path.exists(target): 368 ↛ 369line 368 didn't jump to line 369, because the condition on line 368 was never true
369 app_target = os.path.join(variables['GRAMEXPATH'], 'apps', target)
370 if not os.path.exists(app_target):
371 raise OSError('No directory %s' % target)
372 target = app_target
373 target = os.path.abspath(target)
374 app_log.info('Setting up %s', target)
375 for file, runners in setup_paths.items():
376 setup_file = os.path.join(target, file)
377 if not os.path.exists(setup_file):
378 continue
379 for exe, cmd in runners.items(): 379 ↛ 387line 379 didn't jump to line 387, because the loop on line 379 didn't complete
380 exe_path = which(exe)
381 if exe_path is not None: 381 ↛ 379line 381 didn't jump to line 379, because the condition on line 381 was never false
382 cmd = cmd.format(FILE=setup_file, EXE=exe_path)
383 app_log.info('Running %s', cmd)
384 _run_console(cmd, cwd=target)
385 break
386 else:
387 app_log.warning('Skipping %s. No %s found', setup_file, exe)
390app_dir = Path(variables.get('GRAMEXDATA')) / 'apps'
391if not app_dir.exists(): 391 ↛ 392line 391 didn't jump to line 392, because the condition on line 391 was never true
392 app_dir.mkdir(parents=True)
394# Get app configuration by chaining apps.yaml in gramex + app_dir + command line
395apps_config = ChainConfig()
396apps_config['base'] = PathConfig(gramex.paths['source'] / 'apps.yaml')
397user_conf_file = app_dir / 'apps.yaml'
398apps_config['user'] = PathConfig(user_conf_file) if user_conf_file.exists() else AttrDict()
400app_keys = {
401 'url': 'URL / filename of a ZIP file to install',
402 'cmd': 'Command used to install file',
403 'dir': 'Sub-directory under "url" to run from (optional)',
404 'contentdir': 'Strip root directory with a single child (optional, default=True)',
405 'target': 'Local directory where the app is installed',
406 'installed': 'Additional installation information about the app',
407 'run': 'Runtime keyword arguments for the app',
408}
411def save_user_config(appname, value):
412 user_config = AttrDict()
413 if user_conf_file.exists(): 413 ↛ 416line 413 didn't jump to line 416, because the condition on line 413 was never false
414 with user_conf_file.open(encoding='utf-8') as handle:
415 user_config = yaml.safe_load(handle)
416 if value is None:
417 if appname in user_config: 417 ↛ 423line 417 didn't jump to line 423, because the condition on line 417 was never false
418 del user_config[appname]
419 else:
420 app_config = user_config.setdefault(appname, AttrDict())
421 app_config.update({key: value[key] for key in app_keys if key in value})
423 with user_conf_file.open(mode='w', encoding='utf-8') as handle:
424 yaml.safe_dump(user_config, handle, indent=4, default_flow_style=False)
427def get_app_config(appname, args):
428 '''
429 Get the stored configuration for appname, and override it with args.
430 ``.target`` defaults to $GRAMEXDATA/apps/<appname>.
431 '''
432 apps_config['cmd'] = {appname: args}
433 app_config = AttrDict((+apps_config).get(appname, {}))
434 app_config.setdefault('target', str(app_dir / app_config.get('target', appname)))
435 app_config.target = os.path.abspath(app_config.target)
436 return app_config
439def flatten_config(config, base=None):
440 'Get flattened configurations'
441 for key, value in config.items():
442 keystr = key if base is None else base + '.' + key
443 if hasattr(value, 'items'): 443 ↛ 444line 443 didn't jump to line 444, because the condition on line 443 was never true
444 for sub in flatten_config(value, keystr):
445 yield sub
446 else:
447 yield keystr, value
450def show_usage(command):
451 apps = (+apps_config).keys()
452 return 'gramex {command}\n\n{desc}'.format(
453 command=command,
454 desc=usage[command].strip().format(
455 apps='\n'.join('- ' + app for app in sorted(apps))
456 ))
459def install(cmd, args):
460 if len(cmd) < 1: 460 ↛ 461line 460 didn't jump to line 461, because the condition on line 460 was never true
461 app_log.error(show_usage('install'))
462 return
464 appname = cmd[0]
465 app_log.info('Installing: %s', appname)
466 app_config = get_app_config(appname, args)
467 if len(cmd) == 2:
468 app_config.url = cmd[1]
469 run_install(app_config)
470 elif 'url' in app_config:
471 run_install(app_config)
472 elif 'cmd' in app_config: 472 ↛ 478line 472 didn't jump to line 478, because the condition on line 472 was never false
473 returncode = run_command(app_config)
474 if returncode != 0: 474 ↛ 475line 474 didn't jump to line 475, because the condition on line 474 was never true
475 app_log.error('Command failed with return code %d. Aborting installation', returncode)
476 return
477 else:
478 app_log.error('Use --url=... or --cmd=... to specific source of %s', appname)
479 return
481 # Post-installation
482 app_config.target = run_setup(app_config.target)
483 app_config['installed'] = {'time': datetime.datetime.utcnow()}
484 save_user_config(appname, app_config)
485 app_log.info('Installed. Run `gramex run %s`', appname)
488def setup(cmd, args):
489 for target in cmd:
490 run_setup(target)
491 return
492 if 'all' in args:
493 root = os.path.join(variables['GRAMEXPATH'], 'apps')
494 for filename in os.listdir(root):
495 target = os.path.join(root, filename)
496 # Only run setup on directories. Ignore __pycache__, etc
497 if os.path.isdir(target) and not filename.startswith('_'):
498 run_setup(target)
499 return
500 app_log.error(show_usage('setup'))
503def uninstall(cmd, args):
504 if len(cmd) < 1: 504 ↛ 505line 504 didn't jump to line 505, because the condition on line 504 was never true
505 app_log.error(show_usage('uninstall'))
506 return
507 if len(cmd) > 1 and args: 507 ↛ 508line 507 didn't jump to line 508, because the condition on line 507 was never true
508 app_log.error('Arguments allowed only with single app. Ignoring %s', ', '.join(cmd[1:]))
509 cmd = cmd[:1]
511 for appname in cmd:
512 app_log.info('Uninstalling: %s', appname)
514 # Delete the target directory if it exists
515 app_config = get_app_config(appname, args)
516 if os.path.exists(app_config.target): 516 ↛ 519line 516 didn't jump to line 519, because the condition on line 516 was never false
517 safe_rmtree(app_config.target)
518 else:
519 app_log.error('No directory %s to remove', app_config.target)
520 save_user_config(appname, None)
523def run(cmd, args):
524 if len(cmd) < 1: 524 ↛ 525line 524 didn't jump to line 525, because the condition on line 524 was never true
525 app_log.error(show_usage('run'))
526 return
527 if len(cmd) > 1: 527 ↛ 528line 527 didn't jump to line 528, because the condition on line 527 was never true
528 app_log.error('Can only run one app. Ignoring %s', ', '.join(cmd[1:]))
530 appname = cmd.pop(0)
531 app_config = get_app_config(appname, args)
533 target = app_config.target
534 if 'dir' in app_config:
535 target = os.path.join(target, app_config.dir)
536 if os.path.isdir(target): 536 ↛ 550line 536 didn't jump to line 550, because the condition on line 536 was never false
537 os.chdir(target)
538 gramex.paths['base'] = Path('.')
539 # If we run with updated parameters, save for next run under the .run config
540 run_config = app_config.setdefault('run', {})
541 for key, val in args.items():
542 if key not in app_keys:
543 run_config[key] = app_config.pop(key)
544 save_user_config(appname, app_config)
545 # Tell the user what configs are used
546 cline = ' '.join('--%s=%s' % arg for arg in flatten_config(app_config.get('run', {})))
547 app_log.info('Gramex %s | %s %s | %s | Python %s', gramex.__version__, appname, cline,
548 os.getcwd(), sys.version.replace('\n', ' '))
549 gramex.init(cmd=AttrDict(app=app_config['run']))
550 elif appname in apps_config['user']:
551 # The user configuration has a wrong path. Inform user
552 app_log.error('%s: no directory %s', appname, app_config.target)
553 app_log.error('Run "gramex uninstall %s" and try again.', appname)
554 else:
555 app_log.error('%s: no directory %s', appname, app_config.target)
558def service(cmd, args):
559 try:
560 import gramex.winservice
561 except ImportError:
562 app_log.error('Unable to load winservice. Is this Windows?')
563 raise
564 if len(cmd) < 1:
565 app_log.error(show_usage('service'))
566 return
567 gramex.winservice.GramexService.setup(cmd, **args)
570def _check_output(cmd, default=b'', **kwargs):
571 '''Run cmd and return output. Return default in case the command fails'''
572 try:
573 return check_output(shlex.split(cmd), **kwargs).strip() # nosec
574 # OSError is raised if the cmd is not found.
575 # CalledProcessError is raised if the cmd returns an error.
576 except (OSError, CalledProcessError):
577 return default
580def _run_console(cmd, **kwargs):
581 '''Run cmd and pipe output to console (sys.stdout / sys.stderr)'''
582 cmd = shlex.split(cmd)
583 try:
584 proc = Popen(cmd, bufsize=-1, stdout=sys.stdout, stderr=sys.stderr,
585 universal_newlines=True, **kwargs)
586 except OSError:
587 app_log.error('Cannot find command: %s', cmd[0])
588 raise
589 proc.communicate()
592def _mkdir(path):
593 '''Create directory tree up to path if path does not exist'''
594 if not os.path.exists(path): 594 ↛ exitline 594 didn't return from function '_mkdir', because the condition on line 594 was never false
595 os.makedirs(path)
598def _copy(source, target, template_data=None):
599 '''
600 Copy single directory or file (as binary) from source to target.
601 Warn if target exists, or source is not file/directory, and exit.
602 If template_data is specified, treat source as a Tornado template.
603 '''
604 if os.path.exists(target): 604 ↛ 605line 604 didn't jump to line 605, because the condition on line 604 was never true
605 app_log.warning('Skip existing %s', target)
606 elif os.path.isdir(source):
607 _mkdir(target)
608 elif os.path.isfile(source): 608 ↛ 619line 608 didn't jump to line 619, because the condition on line 608 was never false
609 with io.open(source, 'rb') as handle:
610 result = handle.read()
611 from mimetypes import guess_type
612 filetype = guess_type(source)[0]
613 basetype = 'text' if filetype is None else filetype.split('/')[0]
614 if template_data is not None and basetype in {'text', 'application'}:
615 result = Template(result).generate(**template_data)
616 with io.open(target, 'wb') as handle:
617 handle.write(result)
618 else:
619 app_log.warning('Skip unknown file %s', source)
622def init(cmd, args):
623 '''Create Gramex scaffolding files.'''
624 if len(cmd) > 1: 624 ↛ 625line 624 didn't jump to line 625, because the condition on line 624 was never true
625 app_log.error(show_usage('init'))
626 return
627 args.setdefault('target', os.getcwd())
628 app_log.info('Initializing Gramex project at %s', args.target)
629 data = {
630 'appname': os.path.basename(args.target),
631 'author': _check_output('git config user.name', default='Author'),
632 'email': _check_output('git config user.email', default='user@example.org'),
633 'date': datetime.datetime.today().strftime('%Y-%m-%d'),
634 'version': gramex.__version__,
635 }
636 # Ensure that appname is a valid Python module name
637 appname = re.sub(r'[^a-z0-9_]+', '_', data['appname'].lower())
638 if appname[0] not in string.ascii_lowercase: 638 ↛ 639line 638 didn't jump to line 639, because the condition on line 638 was never true
639 appname = 'app' + appname
641 # Copy all directories & files (as templates)
642 source_dir = os.path.join(variables['GRAMEXPATH'], 'apps', 'init')
643 for root, dirs, files in os.walk(source_dir):
644 for name in dirs + files:
645 source = os.path.join(root, name)
646 relpath = os.path.relpath(root, start=source_dir)
647 target = os.path.join(args.target, relpath, name.replace('appname', appname))
648 _copy(source, target, template_data=data)
649 for empty_dir in ('img', 'data'):
650 _mkdir(os.path.join(args.target, 'assets', empty_dir))
651 # Copy error files as-is (not as templates)
652 error_dir = os.path.join(args.target, 'error')
653 _mkdir(error_dir)
654 for source in glob(os.path.join(variables['GRAMEXPATH'], 'handlers', '?0?.html')):
655 target = os.path.join(error_dir, os.path.basename(source))
656 _copy(source, target)
658 # Create a git repo if none exists.
659 # But if git is not installed, do not stop. Continue with the rest.
660 if not os.path.exists(os.path.join(args.target, '.git')): 660 ↛ 665line 660 didn't jump to line 665, because the condition on line 660 was never false
661 try:
662 _run_console('git init')
663 except OSError:
664 pass
665 run_setup(args.target)
668default_mail_config = r'''# Gramex mail configuration at
669# List keys with "gramex mail --list --conf={confpath}"
671# See https://learn.gramener.com/guide/email/ for help
672email:
673 default-email:
674 type: gmail
675 email: $GRAMEXMAILUSER
676 password: $GRAMEXMAILPASSWORD
677 # Uncomment the next line to test the application without sending mails
678 # stub: log
680# See https://learn.gramener.com/guide/alert/
681alert:
682 hello-world:
683 to: admin@example.org
684 subject: Alert from Gramex
685 body: |
686 This is a test email
687'''
690def mail(cmd, args):
691 # Get config file location
692 default_dir = os.path.join(variables['GRAMEXDATA'], 'mail')
693 _mkdir(default_dir)
694 if 'conf' in args:
695 confpath = args.conf
696 elif os.path.exists('gramex.yaml'):
697 confpath = os.path.abspath('gramex.yaml')
698 else:
699 confpath = os.path.join(default_dir, 'gramexmail.yaml')
701 if not os.path.exists(confpath):
702 if 'init' in args:
703 with io.open(confpath, 'w', encoding='utf-8') as handle:
704 handle.write(default_mail_config.format(confpath=confpath))
705 app_log.info('Initialized %s', confpath)
706 elif not cmd and not args:
707 app_log.error(show_usage('mail'))
708 else:
709 app_log.error('Missing config %s. Use --init to generate skeleton', confpath)
710 return
712 conf = PathConfig(confpath)
713 if 'list' in args:
714 for key, alert in conf.get('alert', {}).items():
715 to = alert.get('to', '')
716 if isinstance(to, list):
717 to = ', '.join(to)
718 gramex.console('{:15}\t"{}" to {}'.format(key, alert.get('subject'), to))
719 return
721 if 'init' in args:
722 app_log.error('Config already exists at %s', confpath)
723 return
725 if len(cmd) < 1:
726 app_log.error(show_usage('mail'))
727 return
729 from gramex.services import email as setup_email, create_alert
730 alert_conf = conf.get('alert', {})
731 email_conf = conf.get('email', {})
732 setup_email(email_conf)
733 sys.path += os.path.dirname(confpath)
734 for key in cmd:
735 if key not in alert_conf:
736 app_log.error('Missing key %s in %s', key, confpath)
737 continue
738 alert = create_alert(key, alert_conf[key])
739 alert()
742def license(cmd, args):
743 if len(cmd) == 0:
744 gramex.console(gramex.license.EULA)
745 if gramex.license.is_accepted():
746 gramex.console('License already ACCEPTED. Run "gramex license reject" to reject')
747 else:
748 gramex.console('License NOT YET accepted. Run "gramex license accept" to accept')
749 elif cmd[0] == 'accept':
750 gramex.license.accept(force=True)
751 elif cmd[0] == 'reject':
752 gramex.license.reject()
753 else:
754 app_log.error('Invalid command license %s', cmd[0])