pb-tool 3.2.0__py2.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.
pb_tool/pb_tool.py ADDED
@@ -0,0 +1,1061 @@
1
+ """
2
+ /***************************************************************************
3
+ pb_tool
4
+ A tool for building and deploying QGIS plugins
5
+ -------------------
6
+ begin : 2014-09-24
7
+ copyright : (C) 2014 by GeoApt LLC
8
+ email : gsherman@geoapt.com
9
+ ***************************************************************************/
10
+
11
+ /***************************************************************************
12
+ * *
13
+ * This program is free software; you can redistribute it and/or modify *
14
+ * it under the terms of the GNU General Public License as published by *
15
+ * the Free Software Foundation; either version 2 of the License, or *
16
+ * (at your option) any later version. *
17
+ * *
18
+ ***************************************************************************/
19
+ """
20
+
21
+ __author__ = "gsherman"
22
+
23
+ import os
24
+ import sys
25
+ import subprocess
26
+ import shutil
27
+ import errno
28
+ import fnmatch
29
+ import glob
30
+ import urllib.request
31
+ import urllib.error
32
+ import configparser
33
+ from string import Template
34
+
35
+ import click
36
+
37
+
38
+ class AliasedGroup(click.Group):
39
+ def get_command(self, ctx, cmd_name):
40
+ rv = click.Group.get_command(self, ctx, cmd_name)
41
+ if rv is not None:
42
+ return rv
43
+ matches = [x for x in self.list_commands(ctx) if x.startswith(cmd_name)]
44
+ if not matches:
45
+ return None
46
+ elif len(matches) == 1:
47
+ return click.Group.get_command(self, ctx, matches[0])
48
+ ctx.fail("Too many matches: %s" % ", ".join(sorted(matches)))
49
+
50
+
51
+ # @click.group()
52
+ @click.command(cls=AliasedGroup)
53
+ def cli():
54
+ """Simple Python tool to compile and deploy a QGIS plugin.
55
+ For help on a command use --help after the command:
56
+ pb_tool deploy --help.
57
+
58
+ pb_tool requires a configuration file (default: pb_tool.cfg) that
59
+ declares the files and resources used in your plugin. Plugin Builder
60
+ 2.6.0 creates a config file when you generate a new plugin template.
61
+
62
+ See http://g-sherman.github.io/plugin_build_tool for for an example config
63
+ file. You can also use the create command to generate a best-guess config
64
+ file for an existing project, then tweak as needed.
65
+
66
+ Bugs and enhancement requests, see:
67
+ https://github.com/g-sherman/plugin_build_tool
68
+ """
69
+ pass
70
+
71
+
72
+ def __version():
73
+ """return the current version and date"""
74
+ # TODO update this with each release
75
+ return ("2.0.2", "2018-12-26")
76
+
77
+
78
+ def get_install_files(cfg):
79
+ python_files = cfg.get("files", "python_files").split()
80
+ main_dialog = cfg.get("files", "main_dialog").split()
81
+ extras = cfg.get("files", "extras").split()
82
+ install_files = (
83
+ python_files + main_dialog + compiled_ui(cfg) + compiled_resource(cfg) + extras
84
+ )
85
+ exclusions = cfg.get("files", "excluded_files", fallback="").split()
86
+ if exclusions:
87
+ install_files = [
88
+ f for f in install_files
89
+ if not any(fnmatch.fnmatch(f, pat) for pat in exclusions)
90
+ ]
91
+ return install_files
92
+
93
+
94
+ @cli.command()
95
+ def version():
96
+ """Return the version of pb_tool and exit"""
97
+ click.echo("{} {}".format(__version()[0], __version()[1]))
98
+
99
+
100
+ @cli.command()
101
+ @click.option(
102
+ "--config_file",
103
+ default="pb_tool.cfg",
104
+ help="Name of the config file to use if other than pb_tool.cfg",
105
+ )
106
+ @click.option(
107
+ "--plugin_path",
108
+ "-p",
109
+ default=None,
110
+ help="Specify the directory where to deploy your plugin if not using the standard location",
111
+ )
112
+ @click.option(
113
+ "--quick",
114
+ "-q",
115
+ is_flag=True,
116
+ help="Do a quick install without compiling ui, resource, docs, \
117
+ and translation files",
118
+ )
119
+ @click.option(
120
+ "--no-confirm",
121
+ "-y",
122
+ is_flag=True,
123
+ help="Don't ask for confirmation to overwrite existing files",
124
+ )
125
+ @click.option(
126
+ "--no-docs",
127
+ "-n",
128
+ is_flag=True,
129
+ help="Skip building the Sphinx help documentation",
130
+ )
131
+ def deploy(config_file, plugin_path, quick, no_confirm, no_docs):
132
+ """Deploy the plugin to QGIS plugin directory using parameters in pb_tool.cfg"""
133
+ deploy_files(config_file, plugin_path, quick=quick, confirm=not no_confirm, build_help=not no_docs)
134
+
135
+
136
+ def deploy_files(config_file, plugin_path, confirm=True, quick=False, build_help=True):
137
+ """Deploy the plugin using parameters in pb_tool.cfg"""
138
+ # check for the config file
139
+ if not os.path.exists(config_file):
140
+ click.secho("Configuration file {0} is missing.".format(config_file), fg="red")
141
+ else:
142
+ cfg = get_config(config_file)
143
+ if not plugin_path:
144
+ plugin_path = get_plugin_directory()
145
+ if not plugin_path:
146
+ click.secho("Unable to determine where to deploy your plugin", fg="red")
147
+ return
148
+
149
+ plugin_dir = os.path.join(plugin_path, cfg.get("plugin", "name"))
150
+
151
+ if quick:
152
+ click.secho("Doing quick deployment", fg="green")
153
+ install_files(plugin_dir, cfg)
154
+ click.secho(
155
+ "Quick deployment complete---if you have problems with your"
156
+ " plugin, try doing a full deploy.",
157
+ fg="green",
158
+ )
159
+
160
+ else:
161
+ if confirm:
162
+ docs_line = " * Build the help docs\n" if build_help else ""
163
+ print("""Deploying will:
164
+ * Remove your currently deployed version
165
+ * Compile the ui and resource files
166
+ {0} * Copy everything to your {1} directory
167
+ """.format(docs_line, plugin_dir))
168
+
169
+ proceed = click.confirm("Proceed?")
170
+ else:
171
+ proceed = True
172
+
173
+ if proceed:
174
+ # clean the deployment
175
+ clean_deployment(False, config_file, plugin_dir)
176
+ click.secho("Deploying to {0}".format(plugin_dir), fg="green")
177
+ # compile to make sure everything is fresh
178
+ click.secho("Compiling to make sure install is clean", fg="green")
179
+ compile_files(cfg)
180
+ if build_help:
181
+ build_docs()
182
+ install_files(plugin_dir, cfg)
183
+
184
+
185
+ def install_files(plugin_dir, cfg):
186
+ errors = []
187
+ install_files = get_install_files(cfg)
188
+ os.makedirs(plugin_dir, exist_ok=True)
189
+
190
+ fail = False
191
+ for file in install_files:
192
+ click.secho("Copying {0}".format(file), fg="magenta", nl=False)
193
+ try:
194
+ dest = os.path.join(plugin_dir, file)
195
+ os.makedirs(os.path.dirname(dest), exist_ok=True)
196
+ shutil.copy(file, dest)
197
+ print()
198
+ except Exception as oops:
199
+ errors.append("Error copying files: {0}, {1}".format(file, oops.strerror))
200
+ click.echo(click.style(" ----> ERROR", fg="red"))
201
+ fail = True
202
+ extra_dirs = cfg.get("files", "extra_dirs").split()
203
+ # print "EXTRA DIRS: {}".format(extra_dirs)
204
+ for xdir in extra_dirs:
205
+ click.secho(
206
+ "Copying contents of {0} to {1}".format(xdir, plugin_dir),
207
+ fg="magenta",
208
+ nl=False,
209
+ )
210
+ try:
211
+ shutil.copytree(
212
+ xdir, "{0}/{1}".format(plugin_dir, xdir), dirs_exist_ok=True
213
+ )
214
+ print()
215
+ except Exception as oops:
216
+ errors.append("Error copying directory: {0}, {1}".format(xdir, str(oops)))
217
+ click.echo(click.style(" ----> ERROR", fg="red"))
218
+ fail = True
219
+ help_src = cfg.get("help", "dir")
220
+ if os.path.exists(help_src):
221
+ help_target = os.path.join(plugin_dir, cfg.get("help", "target"))
222
+ click.secho(
223
+ "Copying {0} to {1}".format(help_src, help_target), fg="magenta", nl=False
224
+ )
225
+ try:
226
+ shutil.copytree(help_src, help_target, dirs_exist_ok=True)
227
+ print()
228
+ except Exception as oops:
229
+ errors.append("Error copying help files: {0}, {1}".format(help_src, str(oops)))
230
+ click.echo(click.style(" ----> ERROR", fg="red"))
231
+ fail = True
232
+ else:
233
+ click.secho(
234
+ "No help found at {0}, skipping".format(help_src), fg="yellow"
235
+ )
236
+ if fail:
237
+ print("\nERRORS:")
238
+ for error in errors:
239
+ print(error)
240
+ print()
241
+ print(
242
+ "One or more files/directories specified in your config file\n"
243
+ "failed to deploy---make sure they exist or if not needed remove\n"
244
+ "them from the config. To ensure proper deployment, make sure your\n"
245
+ "UI and resource files are compiled. Using dclean to delete the\n"
246
+ "plugin before deploying may also help."
247
+ )
248
+ sys.exit(1)
249
+
250
+
251
+ def clean_deployment(ask_first=True, config="pb_tool.cfg", plugin_dir=None):
252
+ """Remove the deployed plugin from the .qgis2/python/plugins directory"""
253
+ if not plugin_dir:
254
+ name = get_config(config).get("plugin", "name")
255
+ plugin_dir = os.path.join(get_plugin_directory(), name)
256
+ if ask_first:
257
+ proceed = click.confirm(
258
+ "Delete the deployed plugin from {0}?".format(plugin_dir)
259
+ )
260
+ else:
261
+ proceed = True
262
+
263
+ if proceed:
264
+ click.echo("Removing plugin from {0}".format(plugin_dir))
265
+ try:
266
+ shutil.rmtree(plugin_dir)
267
+ return True
268
+ except OSError as oops:
269
+ print("Plugin was not deleted: {0}".format(oops.strerror))
270
+ else:
271
+ click.echo("Plugin was not deleted")
272
+ return False
273
+
274
+
275
+ @cli.command()
276
+ def clean_docs():
277
+ """
278
+ Remove the built HTML help files from the build directory
279
+ """
280
+ if os.path.exists("help"):
281
+ click.echo("Removing built HTML from the help documentation")
282
+ if sys.platform == "win32":
283
+ makeprg = "make.bat"
284
+ else:
285
+ makeprg = "make"
286
+ cwd = os.getcwd()
287
+ os.chdir("help")
288
+ subprocess.check_call([makeprg, "clean"])
289
+ os.chdir(cwd)
290
+ else:
291
+ print("No help directory exists in the current directory")
292
+
293
+
294
+ @cli.command()
295
+ @click.option(
296
+ "--config",
297
+ default="pb_tool.cfg",
298
+ help="Name of the config file to use if other than pb_tool.cfg",
299
+ )
300
+ def dclean(config):
301
+ """Remove the deployed plugin from the .qgis2/python/plugins directory"""
302
+ clean_deployment(True, config)
303
+
304
+
305
+ @cli.command()
306
+ @click.option(
307
+ "--config",
308
+ default="pb_tool.cfg",
309
+ help="Name of the config file to use if other than pb_tool.cfg",
310
+ )
311
+ def clean(config):
312
+ """Remove compiled resource and ui files"""
313
+ cfg = get_config(config)
314
+ files = compiled_ui(cfg) + compiled_resource(cfg)
315
+ click.echo("Cleaning resource and ui files")
316
+ for file in files:
317
+ try:
318
+ os.unlink(file)
319
+ print("Deleted: {0}".format(file))
320
+ except OSError as oops:
321
+ print("Couldn't delete {0}: {1}".format(file, oops.strerror))
322
+
323
+
324
+ @cli.command()
325
+ @click.option(
326
+ "--config",
327
+ default="pb_tool.cfg",
328
+ help="Name of the config file to use if other than pb_tool.cfg",
329
+ )
330
+ def compile(config):
331
+ """
332
+ Compile the resource and ui files
333
+ """
334
+ compile_files(get_config(config))
335
+
336
+
337
+ @cli.command()
338
+ def doc():
339
+ """Build HTML version of the help files using sphinx"""
340
+ build_docs()
341
+
342
+
343
+ def build_docs():
344
+ """Build the docs using sphinx"""
345
+ if os.path.exists("help"):
346
+ click.echo("Building the help documentation")
347
+ if sys.platform == "win32":
348
+ makeprg = "make.bat"
349
+ else:
350
+ makeprg = "make"
351
+ env = os.environ.copy()
352
+ env["PYTHON"] = sys.executable
353
+ cwd = os.getcwd()
354
+ os.chdir("help")
355
+ subprocess.check_call([makeprg, "html"], env=env)
356
+ os.chdir(cwd)
357
+ else:
358
+ print("No help directory exists in the current directory")
359
+
360
+
361
+ @cli.command()
362
+ @click.option(
363
+ "--config",
364
+ default="pb_tool.cfg",
365
+ help="Name of the config file to use if other than pb_tool.cfg",
366
+ )
367
+ def translate(config):
368
+ """Build translations using lrelease. Locales must be specified
369
+ in the config file and the corresponding .ts file must exist in
370
+ the i18n directory of your plugin."""
371
+ possibles = ["lrelease", "lrelease-qt4"]
372
+ for binary in possibles:
373
+ cmd = check_path(binary)
374
+ if cmd:
375
+ break
376
+ if not cmd:
377
+ print(
378
+ "Unable to find the lrelease command. Make sure it is installed"
379
+ " and in your path."
380
+ )
381
+ if sys.platform == "win32":
382
+ print(
383
+ "You can get lrelease by installing"
384
+ " the qt4-devel package in the Libs"
385
+ "\nsection of the OSGeo4W Advanced Install."
386
+ )
387
+ else:
388
+ cfg = get_config(config)
389
+ if check_cfg(cfg, "files", "locales"):
390
+ locales = cfg.get("files", "locales").split()
391
+ if locales:
392
+ for locale in locales:
393
+ name, ext = os.path.splitext(locale)
394
+ if ext != ".ts":
395
+ print("no ts extension")
396
+ locale = name + ".ts"
397
+ print(cmd, locale)
398
+ subprocess.check_call([cmd, os.path.join("i18n", locale)])
399
+ else:
400
+ print("No translations are specified in {0}".format(config))
401
+
402
+
403
+ @cli.command()
404
+ @click.option(
405
+ "--config",
406
+ default="pb_tool.cfg",
407
+ help="Name of the config file to use if other than pb_tool.cfg",
408
+ )
409
+ @click.option(
410
+ "--quick",
411
+ "-q",
412
+ is_flag=True,
413
+ help="Do a quick packaging without dclean and deploy (plugin must have been previously deployed)",
414
+ )
415
+ def zip(config_file, quick):
416
+ """Package the plugin into a zip file
417
+ suitable for uploading to the QGIS
418
+ plugin repository"""
419
+
420
+ # check to see if we can find zip or 7z
421
+ use_7z = False
422
+ zip = check_path("zip")
423
+ if not zip:
424
+ # check for 7z
425
+ zip = check_path("7z")
426
+ if not zip:
427
+ click.secho("zip or 7z not found. Unable to package the plugin", fg="red")
428
+ click.secho("Check your path or install a zip program", fg="red")
429
+ return
430
+ else:
431
+ use_7z = True
432
+ click.secho("Found zip: %s" % zip, fg="green")
433
+
434
+ name = get_config(config_file).get("plugin", "name", fallback=None)
435
+ if not quick:
436
+ proceed = click.confirm("This requires a dclean and deploy first. Proceed?")
437
+ if proceed:
438
+ # clean_deployment(False, config)
439
+ deploy_files(config_file, plugin_path=None, confirm=False)
440
+ else:
441
+ # Check to see if the plugin directory exists, otherwise we can't
442
+ # do a quick zip
443
+ if not os.path.exists(os.path.join(get_plugin_directory(), name)):
444
+ # click.secho(
445
+ # "You must deploy the plugin before you can package it using -q",
446
+ # fg='red')
447
+ # proceed = click.confirm(
448
+ # 'Do you want to deploy and proceed with packaging?')
449
+ # if proceed:
450
+ deploy_files(config_file, plugin_path=None, confirm=False)
451
+ proceed = True
452
+
453
+ # confirm = click.confirm(
454
+ # 'Create a packaged plugin ({0}.zip) from the deployed files?'.format(name))
455
+ # confirm = True
456
+ if proceed:
457
+ # delete the zip if it exists
458
+ if os.path.exists("{0}.zip".format(name)):
459
+ os.unlink("{0}.zip".format(name))
460
+ if name:
461
+ cwd = os.getcwd()
462
+ os.chdir(get_plugin_directory())
463
+ # click.secho("Current directory is {}".format(os.getcwd()), fg='magenta')
464
+ if use_7z:
465
+ subprocess.check_call(
466
+ [zip, "a", "-r", os.path.join(cwd, "{0}.zip".format(name)), name]
467
+ )
468
+ else:
469
+ subprocess.check_call(
470
+ [zip, "-r", os.path.join(cwd, "{0}.zip".format(name)), name]
471
+ )
472
+
473
+ print(
474
+ "The {0}.zip archive has been created in the current directory".format(
475
+ name
476
+ )
477
+ )
478
+ else:
479
+ click.echo("Your config file is missing the plugin name (name=parameter)")
480
+
481
+
482
+ @cli.command()
483
+ @click.option(
484
+ "--config_file",
485
+ default="pb_tool.cfg",
486
+ help="Name of the config file to use if other than pb_tool.cfg",
487
+ )
488
+ def validate(config_file):
489
+ """
490
+ Check the pb_tool.cfg file for mandatory sections/files
491
+ """
492
+ valid = True
493
+ cfg = get_config(config_file)
494
+ if not check_cfg(cfg, "plugin", "name"):
495
+ valid = False
496
+ if not check_cfg(cfg, "files", "python_files"):
497
+ valid = False
498
+ if not check_cfg(cfg, "files", "main_dialog"):
499
+ valid = False
500
+ if not check_cfg(cfg, "files", "resource_files"):
501
+ valid = False
502
+ if not check_cfg(cfg, "files", "extras"):
503
+ valid = False
504
+ if not check_cfg(cfg, "help", "dir"):
505
+ valid = False
506
+ if not check_cfg(cfg, "help", "target"):
507
+ valid = False
508
+
509
+ click.secho("Using Python {}".format(sys.version), fg="green")
510
+ if valid:
511
+ click.secho(
512
+ "Your {0} file is valid and contains all mandatory items".format(
513
+ config_file
514
+ ),
515
+ fg="green",
516
+ )
517
+ else:
518
+ click.secho("Your {0} file is invalid".format(config_file), fg="red")
519
+ try:
520
+ from qgis.PyQt.QtCore import QStandardPaths, QDir
521
+
522
+ path = QStandardPaths.standardLocations(QStandardPaths.AppDataLocation)[0]
523
+ plugin_path = os.path.join(
524
+ QDir.homePath(), path, "QGIS/QGIS3/profiles/default/python/plugins"
525
+ )
526
+ click.secho("Plugin path: {}".format(plugin_path), fg="green")
527
+ except Exception:
528
+ click.secho(
529
+ """Unable to determine location of your QGIS Plugin directory.
530
+ Make sure your QGIS environment is setup properly for development and Python
531
+ has access to the qgis.PyQt.QtCore module.""",
532
+ fg="red",
533
+ )
534
+
535
+ zipbin = find_zip()
536
+ a7z = find_7z()
537
+ if zipbin:
538
+ zip_utility = zipbin
539
+ elif a7z:
540
+ zip_utility = a7z
541
+ else:
542
+ zip_utility = None
543
+ if not zip_utility:
544
+ click.secho("zip or 7z not found. Unable to package the plugin", fg="red")
545
+ click.secho("Check your path or install a zip program", fg="red")
546
+ else:
547
+ click.secho("Found suitable zip utility: {}".format(zip_utility), fg="green")
548
+ # check for templates - uncomment next 4 after create function is done
549
+ # print(__file__)
550
+ # print("Module: {}".format (sys.modules['pb_tool']))
551
+ # basic_tmpl = pkgutil.get_data('pb_tool', 'templates/basic.tmpl')
552
+ # print("Read basic template: {}".format(str(basic_tmpl, 'utf-8')))
553
+
554
+ # f = open('pb_tool/templates/basic.tmpl')
555
+ # if f:
556
+ # print("opened basic.tmpl")
557
+ # else:
558
+ # print("unable to find basic.tmpl")
559
+
560
+
561
+ @cli.command()
562
+ @click.option(
563
+ "--config_file",
564
+ default="pb_tool.cfg",
565
+ help="Name of the config file to use if other than pb_tool.cfg",
566
+ )
567
+ def list(config_file):
568
+ """List the contents of the configuration file"""
569
+ if os.path.exists(config_file):
570
+ with open(config_file) as cfg:
571
+ for line in cfg:
572
+ print(line[:-1])
573
+ else:
574
+ click.secho(
575
+ "There is no {0} file in the current directory".format(config_file),
576
+ fg="red",
577
+ )
578
+ click.secho("We can't do anything without it", fg="red")
579
+
580
+
581
+ @cli.command()
582
+ @click.option(
583
+ "--name",
584
+ default="pb_tool.cfg",
585
+ help="Name of the config file to create if other than pb_tool.cfg",
586
+ )
587
+ @click.option(
588
+ "--package",
589
+ default=None,
590
+ help="Name of package (lower case). This will be used as the directory name for deployment",
591
+ )
592
+ def xxconfig(name, package):
593
+ """
594
+ Create a config file based on source files in the current directory
595
+ """
596
+ click.secho(
597
+ "Create a config file based on source files in the current directory",
598
+ fg="green",
599
+ )
600
+ if name == "pb_tool.cfg":
601
+ click.secho(
602
+ "This will overwrite any existing pb_tool.cfg in the current directory",
603
+ fg="red",
604
+ )
605
+ proceed = click.confirm("Proceed?")
606
+ if not proceed:
607
+ return
608
+ template = Template(config_template())
609
+
610
+ # get the plugin package name
611
+ if not package:
612
+ cfg_name = click.prompt(
613
+ "Name of package (lower case). This will be used as the directory name for deployment"
614
+ )
615
+
616
+ # get the list of python files
617
+ py_files = glob.glob("*.py")
618
+
619
+ # guess the main dialog ui file
620
+ main_dlg = glob.glob("*_dialog_base.ui")
621
+
622
+ # get the other ui files
623
+ other_ui = glob.glob("*.ui")
624
+ # remove the main dialog file
625
+ try:
626
+ for ui in main_dlg:
627
+ other_ui.remove(ui)
628
+ except ValueError:
629
+ # don't care if we didn't find it
630
+ pass
631
+
632
+ # get the resource files (.qrc)
633
+ resources = glob.glob("*.qrc")
634
+
635
+ extras = glob.glob("*.png") + glob.glob("metadata.txt")
636
+
637
+ locale_list = glob.glob("i18n/*.ts")
638
+ locales = []
639
+ for locale in locale_list:
640
+ locales.append(os.path.basename(locale))
641
+
642
+ cfg = template.substitute(
643
+ Name=cfg_name,
644
+ PythonFiles=" ".join(py_files),
645
+ MainDialog=" ".join(main_dlg),
646
+ CompiledUiFiles=" ".join(other_ui),
647
+ Resources=" ".join(resources),
648
+ Extras=" ".join(extras),
649
+ Locales=" ".join(locales),
650
+ )
651
+
652
+ fname = name
653
+ if os.path.exists(fname):
654
+ confirm = click.confirm("{0} exists. Overwrite?".format(name))
655
+ if not confirm:
656
+ fname = click.prompt("Enter a name for the config file:")
657
+
658
+ with open(fname, "w") as f:
659
+ f.write(cfg)
660
+
661
+ print("Created new config file in {0}".format(fname))
662
+
663
+
664
+ @cli.command()
665
+ @click.option(
666
+ "--type",
667
+ "plugin_type",
668
+ type=click.Choice(["processing", "dialog"], case_sensitive=False),
669
+ default="processing",
670
+ help="Type of plugin skeleton to create",
671
+ )
672
+ @click.option("--name", default=None, help="Plugin module name (snake_case)")
673
+ @click.option("--class_name", default=None, help="Plugin class name (CamelCase)")
674
+ @click.option("--description", default=None, help="Short plugin description")
675
+ @click.option("--author", default=None, help="Author name")
676
+ @click.option("--email", default=None, help="Author email")
677
+ def create(plugin_type, name, class_name, description, author, email):
678
+ """Create a new plugin skeleton from a template"""
679
+ from datetime import date
680
+
681
+ name = name or click.prompt("Module name (snake_case, used as directory name)")
682
+ class_name = class_name or click.prompt("Class name (CamelCase)")
683
+ description = description or click.prompt("Description")
684
+ author = author or click.prompt("Author")
685
+ email = email or click.prompt("Email")
686
+
687
+ subs = {
688
+ "TemplateModuleName": name,
689
+ "TemplateClass": class_name,
690
+ "TemplateDescription": description,
691
+ "TemplateAuthor": author,
692
+ "TemplateEmail": email,
693
+ "TemplateYear": str(date.today().year),
694
+ "TemplateBuildDate": date.today().isoformat(),
695
+ }
696
+
697
+ tmpl_dir = os.path.join(os.path.dirname(__file__), "templates", plugin_type)
698
+ if not os.path.exists(tmpl_dir):
699
+ click.secho("No templates found for type '{0}'".format(plugin_type), fg="red")
700
+ return
701
+
702
+ out_dir = name
703
+ if os.path.exists(out_dir):
704
+ if not click.confirm("Directory '{0}' exists. Overwrite?".format(out_dir)):
705
+ return
706
+ os.makedirs(out_dir, exist_ok=True)
707
+
708
+ file_map = {
709
+ "__init__.tmpl": "__init__.py",
710
+ "module_name.tmpl": "{0}.py".format(name),
711
+ "module_name_provider.tmpl": "{0}_provider.py".format(name),
712
+ "module_name_algorithm.tmpl": "{0}_algorithm.py".format(name),
713
+ "module_name_dialog.tmpl": "{0}_dialog.py".format(name),
714
+ "module_name_dialog_base.ui.tmpl": "{0}_dialog_base.ui".format(name),
715
+ "resources.tmpl": "resources.qrc",
716
+ "readme.tmpl": "README.md",
717
+ "results.tmpl": "results.py",
718
+ }
719
+
720
+ created_py = []
721
+ for tmpl_file, out_file in file_map.items():
722
+ tmpl_path = os.path.join(tmpl_dir, tmpl_file)
723
+ if not os.path.exists(tmpl_path):
724
+ continue
725
+ with open(tmpl_path) as f:
726
+ content = Template(f.read()).safe_substitute(subs)
727
+ out_path = os.path.join(out_dir, out_file)
728
+ with open(out_path, "w") as f:
729
+ f.write(content)
730
+ click.secho("Created {0}".format(out_path), fg="green")
731
+ if out_file.endswith(".py"):
732
+ created_py.append(out_file)
733
+
734
+ # generate pb_tool.cfg with the correct python_files already populated
735
+ pb_tool_tmpl_path = os.path.join(os.path.dirname(__file__), "templates", "pb_tool.tmpl")
736
+ if os.path.exists(pb_tool_tmpl_path):
737
+ with open(pb_tool_tmpl_path) as f:
738
+ cfg_content = Template(f.read()).safe_substitute(dict(
739
+ subs,
740
+ TemplateModuleName=name,
741
+ ))
742
+ # replace the stub python_files line with the actual generated files
743
+ py_files_line = "python_files: {0}".format(" ".join(created_py))
744
+ cfg_content = cfg_content.replace(
745
+ "python_files: __init__.py {0}.py".format(name),
746
+ py_files_line,
747
+ )
748
+ cfg_path = os.path.join(out_dir, "pb_tool.cfg")
749
+ with open(cfg_path, "w") as f:
750
+ f.write(cfg_content)
751
+ click.secho("Created {0}".format(cfg_path), fg="green")
752
+
753
+ click.secho(
754
+ "\nPlugin skeleton created in '{0}/'. "
755
+ "Add a metadata.txt and run pb_tool deploy to get started.".format(out_dir),
756
+ fg="green",
757
+ )
758
+
759
+
760
+ @cli.command()
761
+ def update():
762
+ """Check for update to pb_tool"""
763
+ try:
764
+ u = urllib.request.urlopen("http://geoapt.net/pb_tool/current_version.txt")
765
+ version = u.read()[:-1]
766
+ click.secho("Latest version is %s" % version, fg="green")
767
+ # convert version numbers to int
768
+ this_version = int(__version()[0].replace(".", ""))
769
+ current_version = int(version.replace(".", ""))
770
+
771
+ if this_version == current_version:
772
+ click.secho("Your version is up to date", fg="green")
773
+ elif current_version > this_version:
774
+ click.secho("You have Version %s" % __version()[0], fg="green")
775
+ click.secho("You can upgrade by running this command:")
776
+ cmd = "pip install --upgrade pb_tool"
777
+ print(" %s" % cmd)
778
+ elif this_version > current_version:
779
+ click.secho("You have development Version %s" % __version()[0], fg="green")
780
+
781
+ except urllib.error.URLError as uoops:
782
+ click.secho("Unable to check for update.")
783
+ click.secho("%s" % uoops.reason)
784
+
785
+
786
+ def check_cfg(cfg, section, name):
787
+ try:
788
+ cfg.get(section, name)
789
+ return True
790
+ except configparser.NoOptionError as oops:
791
+ print(str(oops))
792
+ except configparser.NoSectionError:
793
+ print(
794
+ "Missing section '{0}' when looking for option '{1}'".format(section, name)
795
+ )
796
+ return False
797
+
798
+
799
+ def get_config(config="pb_tool.cfg"):
800
+ """
801
+ Read the config file pb_tools.cfg and return it
802
+ """
803
+ if os.path.exists(config):
804
+ cfg = configparser.ConfigParser()
805
+ cfg.read(config)
806
+ # click.echo(cfg.sections())
807
+ return cfg
808
+ else:
809
+ print("There is no {0} file in the current directory".format(config))
810
+ print("We can't do anything without it")
811
+ sys.exit(1)
812
+
813
+
814
+ def compiled_ui(cfg):
815
+ # cfg = get_config(config)
816
+ try:
817
+ uis = cfg.get("files", "compiled_ui_files").split()
818
+ compiled = []
819
+ for ui in uis:
820
+ base, ext = os.path.splitext(ui)
821
+ compiled.append("{0}.py".format(base))
822
+ # print("Compiled UI files: {}".format(compiled))
823
+ return compiled
824
+ except configparser.NoSectionError as oops:
825
+ print(str(oops))
826
+ sys.exit(1)
827
+
828
+
829
+ def compiled_resource(cfg):
830
+ # cfg = get_config(config)
831
+ try:
832
+ res_files = cfg.get("files", "resource_files").split()
833
+ compiled = []
834
+ for res in res_files:
835
+ base, ext = os.path.splitext(res)
836
+ compiled.append("{0}.py".format(base))
837
+ # print("Compiled resource files: {}".format(compiled))
838
+ return compiled
839
+ except configparser.NoSectionError as oops:
840
+ print(str(oops))
841
+ sys.exit(1)
842
+
843
+
844
+ def compile_files(cfg):
845
+ # Compile all ui and resource files
846
+ # TODO add changed detection
847
+ # cfg = get_config(config)
848
+
849
+ # determine Qt version and select appropriate uic tool
850
+ if check_path("pyuic6"):
851
+ pyuic = check_path("pyuic6")
852
+ qt_version = 6
853
+ elif check_path("pyuic5"):
854
+ pyuic = check_path("pyuic5")
855
+ qt_version = 5
856
+ else:
857
+ pyuic = None
858
+ qt_version = None
859
+
860
+ if not pyuic:
861
+ print("pyuic5/pyuic6 is not in your path---unable to compile your ui files")
862
+ else:
863
+ print("Using Qt{0} ({1})".format(qt_version, pyuic))
864
+ ui_files = cfg.get("files", "compiled_ui_files").split()
865
+ ui_count = 0
866
+ for ui in ui_files:
867
+ if os.path.exists(ui):
868
+ base, ext = os.path.splitext(ui)
869
+ output = "{0}.py".format(base)
870
+ if file_changed(ui, output):
871
+ print("Compiling {0} to {1}".format(ui, output))
872
+ subprocess.check_call([pyuic, "-o", output, ui])
873
+ ui_count += 1
874
+ else:
875
+ print("Skipping {0} (unchanged)".format(ui))
876
+ else:
877
+ print("{0} does not exist---skipped".format(ui))
878
+ print("Compiled {0} UI files".format(ui_count))
879
+
880
+ # check to see if we have rcc
881
+ rcc = check_path("rcc")
882
+
883
+ if not rcc:
884
+ click.secho(
885
+ "rcc is not in your path---unable to compile your resource file(s)",
886
+ fg="red",
887
+ )
888
+ else:
889
+ res_files = cfg.get("files", "resource_files").split()
890
+ res_count = 0
891
+ for res in res_files:
892
+ if os.path.exists(res):
893
+ base, ext = os.path.splitext(res)
894
+ output = "{0}.py".format(base)
895
+ if file_changed(res, output):
896
+ print("Compiling {0} to {1}".format(res, output))
897
+ cmd = []
898
+ if qt_version == 6:
899
+ cmd += ["rcc", "-g", "python"]
900
+ if qt_version == 5:
901
+ pyrcc5 = check_path("pyrcc5")
902
+ if pyrcc5:
903
+ cmd += [pyrcc5]
904
+ else:
905
+ cmd += [sys.executable, "-m", "PyQt5.pyrcc_main"]
906
+ cmd += ["-o", output, res]
907
+ subprocess.check_call(cmd)
908
+ with open(output, "r") as f:
909
+ content = f.read()
910
+ content = content.replace(
911
+ "from PySide6 import QtCore", "from qgis.PyQt import QtCore"
912
+ )
913
+ with open(output, "w") as f:
914
+ f.write(content)
915
+ res_count += 1
916
+ else:
917
+ print("Skipping {0} (unchanged)".format(res))
918
+ else:
919
+ print("{0} does not exist---skipped".format(res))
920
+ print("Compiled {0} resource files".format(res_count))
921
+
922
+
923
+ def copy(source, destination):
924
+ """Copy files recursively.
925
+
926
+ Taken from: http://www.pythoncentral.io/
927
+ how-to-recursively-copy-a-directory-folder-in-python/
928
+
929
+ :param source: Source directory.
930
+ :type source: str
931
+
932
+ :param destination: Destination directory.
933
+ :type destination: str
934
+
935
+ """
936
+ try:
937
+ shutil.copytree(source, destination, dirs_exist_ok=True)
938
+ except OSError as e:
939
+ # If the error was caused because the source wasn't a directory
940
+ if e.errno == errno.ENOTDIR:
941
+ shutil.copy(source, destination)
942
+ else:
943
+ print("Directory not copied. Error: %s" % e)
944
+
945
+
946
+ def get_plugin_directory():
947
+ home = os.path.expanduser("~")
948
+ qgis4 = os.path.join(".local", "share", "QGIS", "QGIS4", "profiles", "default", "python", "plugins")
949
+ qgis3 = os.path.join(".local", "share", "QGIS", "QGIS3", "profiles", "default", "python", "plugins")
950
+ for candidate in [qgis4, qgis3]:
951
+ path = os.path.join(home, candidate)
952
+ if os.path.exists(path):
953
+ return path
954
+ return os.path.join(home, qgis4)
955
+
956
+
957
+ def config_template():
958
+ """
959
+ :return: the template for a pb_tool.cfg file
960
+ """
961
+ template = """# Configuration file for plugin builder tool
962
+ # Sane defaults for your plugin generated by the Plugin Builder are
963
+ # already set below.
964
+ #
965
+ # As you add Python source files and UI files to your plugin, add
966
+ # them to the appropriate [files] section below.
967
+
968
+ [plugin]
969
+ # Name of the plugin. This is the name of the directory that will
970
+ # be created in the QGIS3/QGIS4 python/plugins directory
971
+ name: $Name
972
+
973
+ # Full path to where you want your plugin directory copied. If empty,
974
+ # the QGIS default path will be used. Don't include the plugin name in
975
+ # the path.
976
+ plugin_path:
977
+
978
+ [files]
979
+ # Python files that should be deployed with the plugin
980
+ python_files: $PythonFiles
981
+
982
+ # The main dialog file that is loaded (not compiled)
983
+ main_dialog: $MainDialog
984
+
985
+ # Other ui files for your dialogs (these will be compiled)
986
+ compiled_ui_files: $CompiledUiFiles
987
+
988
+ # Resource file(s) that will be compiled
989
+ resource_files: $Resources
990
+
991
+ # Other files required for the plugin
992
+ extras: $Extras
993
+
994
+ # Files to exclude from deployment (glob patterns, space-separated)
995
+ excluded_files:
996
+
997
+ # Other directories to be deployed with the plugin.
998
+ # These must be subdirectories under the plugin directory
999
+ extra_dirs:
1000
+
1001
+ # ISO code(s) for any locales (translations), separated by spaces.
1002
+ # Corresponding .ts files must exist in the i18n directory
1003
+ locales: $Locales
1004
+
1005
+ [help]
1006
+ # the built help directory that should be deployed with the plugin
1007
+ dir: help/build/html
1008
+ # the name of the directory to target in the deployed plugin
1009
+ target: help
1010
+ """
1011
+
1012
+ return template
1013
+
1014
+
1015
+ def check_path(app):
1016
+ """Adapted from StackExchange:
1017
+ http://stackoverflow.com/questions/377017
1018
+ """
1019
+ import os
1020
+
1021
+ def is_exe(fpath):
1022
+ return os.path.exists(fpath) and os.access(fpath, os.X_OK)
1023
+
1024
+ def ext_candidates(fpath):
1025
+ yield fpath
1026
+ for ext in os.environ.get("PATHEXT", "").split(os.pathsep):
1027
+ yield fpath + ext
1028
+
1029
+ fpath, fname = os.path.split(app)
1030
+ if fpath:
1031
+ if is_exe(app):
1032
+ return app
1033
+ else:
1034
+ for path in os.environ["PATH"].split(os.pathsep):
1035
+ exe_file = os.path.join(path, app)
1036
+ for candidate in ext_candidates(exe_file):
1037
+ if is_exe(candidate):
1038
+ return candidate
1039
+
1040
+ return None
1041
+
1042
+
1043
+ def file_changed(infile, outfile):
1044
+ try:
1045
+ infile_s = os.stat(infile)
1046
+ outfile_s = os.stat(outfile)
1047
+ return infile_s.st_mtime > outfile_s.st_mtime
1048
+ except OSError:
1049
+ return True
1050
+
1051
+
1052
+ def find_zip():
1053
+ # check to see if we can find zip
1054
+ zip = check_path("zip")
1055
+ return zip
1056
+
1057
+
1058
+ def find_7z():
1059
+ # check for 7z
1060
+ zip = check_path("7z")
1061
+ return zip