Coverage for gramex\install.py: 63%

# Coverage for gramex\install.py : 63%

## 389 statements 263 run 126 missing 0 excluded 35 partial

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'''

2 Defines command line services to install, setup and run apps.

3'''

4 import io

5 import os

6 import re

7 import six

8 import sys

9 import yaml

10 import stat

11 import shlex

12 import string

13 import shutil

14 import datetime

15 import requests

16 from glob import glob

17 from shutilwhich import which

18 from pathlib import Path

19 from subprocess import Popen, check_output, CalledProcessError # nosec

20 from orderedattrdict import AttrDict

21 from orderedattrdict.yamlutils import AttrDictYAMLLoader

22 from zipfile import ZipFile

23 from tornado.template import Template

24 from orderedattrdict.yamlutils import from_yaml # noqa

25 import gramex

26 import gramex.license

27 from gramex.config import ChainConfig, PathConfig, variables, app_log

28

29 usage = '''

30 install: |

31 usage:

32 gramex install <app> <url> [--target=DIR]

33 gramex install <app> --cmd="COMMAND" [--target=DIR]

34

35 "app" is any name you want to locally call the application.

36

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)

41

42 target is the directory to install at (defaults to user data directory.)

43

44 cmd is a shell command to run. If it has the word "TARGET" in caps, it is

45 replaced by the target directory.

46

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.

49

50 Installed apps:

51 {apps}

52

53 setup: |

54 usage: gramex setup <target> [<target> ...] [--all]

55

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/.

58

59 gramex setup --all sets up all apps under $GRAMEXPATH/apps/

60

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

69

70 run: |

71 usage: gramex run <app> [--target=DIR] [--dir=DIR] [--<options>=<value>]

72

73 "app" is the name of the locally installed application.

74

75 If "app" is not installed, specify --target=DIR to run from DIR. The next

76 "gramex run app" will automatically run from DIR.

77

78 "dir" is a *sub-directory* under "target" to run from. This is useful if

79 "app" has multiple sub-applications.

80

81 All Gramex command line options can be used. These are saved. For example:

82

83 gramex run app --target=/path/to/dir --listen.port=8899 --browser=true

84

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.

88

89 Installed apps:

90 {apps}

91

92 uninstall: |

93 usage: gramex uninstall <app> [<app> ...]

94

95 "app" is the name of the locally installed application. You can uninstall

96 multiple applications in one command.

97

98 All information about the application is lost. You cannot undo this.

99

100 Installed apps:

101 {apps}

102

103 service: |

104 usage: gramex service <cmd> [--options]

105

106 Install a Gramex application as a Windows service:

107

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

113

114 Update:

115

116 gramex service update <same parameters as install>

117

118 Remove:

119

120 gramex service remove # or gramex service uninstall

121

122 Start / stop commands

123

124 gramex service start

125 gramex service stop

126

127 init: |

128 usage: gramex init [--target=DIR]

129

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)

134

135 mail: |

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

139

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.

143

144 Options:

145 --conf <path> # Specify a different conf file location

146

147 license: |

148 gramex license # Show Gramex license

149 gramex license accept # Accept Gramex license

150 gramex license reject # Reject Gramex license

151'''

152 usage = yaml.load(usage, Loader=AttrDictYAMLLoader) # nosec

153

154

155 class TryAgainError(Exception):

156 '''If shutil.rmtree fails, and we've fixed the problem, raise this to try again'''

157 pass

158

159

160 try:

161 WindowsError

162 except 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]

166 else:

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]

200

201

202 def 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

224

225

226 def 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

241

242

243 def 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

252

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

266

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)

276

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)

284

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)

289

290

291 def 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

320

321

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

328 setup_paths = '''

329 Makefile:

330 make: '"{EXE}"'

331 setup.ps1:

332 powershell: '"{EXE}" -File "{FILE}"'

333 setup.sh:

334 bash: '"{EXE}" "{FILE}"'

335 requirements.txt:

336 pip: '"{EXE}" install -r "{FILE}"'

337 setup.py:

338 python: '"{EXE}" "{FILE}"'

339 package.json:

340 yarn: '"{EXE}" install --prefer-offline'

341 npm: '"{EXE}" install'

342 bower.json:

343 bower: '"{EXE}" --allow-root install'

344'''

345 setup_paths = yaml.load(setup_paths, Loader=AttrDictYAMLLoader) # nosec

346

347

348 def run_setup(target):

349 '''

350 Install any setup file in target directory. Target directory can be:

351

352 - An absolute path

353 - A relative path to current directory

354 - A relative path to the Gramex apps/ folder

355

356 Returns the absolute path of the final target path.

357

358 This supports:

359

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)

388

389

390 app_dir = Path(variables.get('GRAMEXDATA')) / 'apps'

391 if 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)

393

394# Get app configuration by chaining apps.yaml in gramex + app_dir + command line

395 apps_config = ChainConfig()

396 apps_config['base'] = PathConfig(gramex.paths['source'] / 'apps.yaml')

397 user_conf_file = app_dir / 'apps.yaml'

398 apps_config['user'] = PathConfig(user_conf_file) if user_conf_file.exists() else AttrDict()

399

400 app_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}

409

410

411 def 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})

422

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)

425

426

427 def 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

437

438

439 def 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

448

449

450 def 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 ))

457

458

459 def 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

463

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

480

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)

486

487

488 def 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'))

501

502

503 def 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]

510

511 for appname in cmd:

512 app_log.info('Uninstalling: %s', appname)

513

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)

521

522

523 def 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:]))

529

530 appname = cmd.pop(0)

531 app_config = get_app_config(appname, args)

532

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)

556

557

558 def 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)

568

569

570 def _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

578

579

580 def _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()

590

591

592 def _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)

596

597

598 def _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)

620

621

622 def 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

640

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)

657

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)

666

667

668 default_mail_config = r'''# Gramex mail configuration at

669# List keys with "gramex mail --list --conf={confpath}"

670

671# See https://learn.gramener.com/guide/email/ for help

672 email:

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

679

680# See https://learn.gramener.com/guide/alert/

681 alert:

682 hello-world:

683 to: admin@example.org

684 subject: Alert from Gramex

685 body: |

686 This is a test email

687'''

688

689

690 def 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')

700

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

711

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

720

721 if 'init' in args:

722 app_log.error('Config already exists at %s', confpath)

723 return

724

725 if len(cmd) < 1:

726 app_log.error(show_usage('mail'))

727 return

728

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()

740

741

742 def 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])