-
Notifications
You must be signed in to change notification settings - Fork 30
Expand file tree
/
Copy pathcli.py
More file actions
1108 lines (1034 loc) · 39.6 KB
/
Copy pathcli.py
File metadata and controls
1108 lines (1034 loc) · 39.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
import importlib
import logging
import logging.handlers
import sys
import click
import click_log
import shutil
import os
import zipfile
import requests
import io
import tempfile
import platform
import webbrowser
from pathlib import Path
import subprocess
from pydantic import ValidationError
from typing import Any, Callable, List, Literal, Optional
from functools import update_wrapper
from . import (
utils,
resources,
constants,
logger,
plastex,
server,
VERSION,
CORE_COMMIT,
)
from .project import Project
log = logging.getLogger("ptxlogger")
logger.add_log_stream_handler()
error_flush_handler = logger.get_log_error_flush_handler()
# Add a decorator to provide nice exception handling for validation errors for all commands. It avoids printing a confusing traceback, and also nicely formats validation errors.
def nice_errors(f: Callable[..., None]) -> Any:
@click.pass_context
def try_except(ctx: click.Context, *args: Any, **kwargs: Any) -> Any:
try:
return ctx.invoke(f, *args, **kwargs)
except ValidationError as e:
log.critical(
"Failed to parse project.ptx. Check the entire file, including all targets, and fix the following errors:"
)
for error in e.errors():
if error["type"] == "missing":
log.error(
f"One of the targets has a missing required attribute: {error['loc'][0]}; look for the target with {error['input']}."
)
elif error["type"] == "enum":
log.error(
f"One of the targets has an attribute with illegal value: @{error['loc'][0]}=\"{error['input']}\" is not allowed. Pick from the values:{error['msg'].split(': ')[-1].replace('Input should be', '')}."
)
elif error["type"] == "extra_forbidden":
log.error(
f"Either one of the targets or the root project element has an extra attribute it shouldn't: {error['loc'][0]}=\"{error['input']}\""
)
elif error["type"] == "value_error":
log.error(
f"In at least one target, you cannot have @{error['loc'][0]}=\"{error['input']}\". {error['msg'].replace('Value error, ', '')}"
)
else:
log.error(f"{error['msg']} ({error['loc']}; {error['type']})")
log.debug(
"\n------------------------\nException info:\n------------------------\n",
exc_info=True,
)
return
except Exception as e:
log.error(e)
log.debug("Exception info:\n------------------------\n", exc_info=True)
return
return update_wrapper(try_except, f)
CONTEXT_SETTINGS = dict(help_option_names=["-h", "--help"])
# Click command-line interface
@click.group(invoke_without_command=True, context_settings=CONTEXT_SETTINGS)
@click.pass_context
# Allow a verbosity command:
@click_log.simple_verbosity_option(
log,
help="Sets the severity of log messaging: DEBUG for all, INFO (default) for most, then WARNING, ERROR, and CRITICAL for decreasing verbosity.",
)
@click.version_option(VERSION, message=VERSION)
@click.option(
"-t",
"--targets",
is_flag=True,
help='Display list of build/view "targets" available in the project manifest.',
)
@nice_errors
def main(ctx: click.Context, targets: bool) -> None:
"""
Command line tools for quickly creating, authoring, and building PreTeXt projects.
PreTeXt Guide for authors and publishers:
- https://pretextbook.org/documentation.html
PreTeXt CLI README for developers:
- https://github.com/PreTeXtBook/pretext-cli/
Use the `--help` option on any CLI command to learn more, for example,
`pretext build --help`.
"""
# the targets option just lists targets in the current project
if targets:
if (pp := utils.project_path()) is not None:
project = Project.parse(pp)
for target in project.target_names():
print(target)
else:
log.warning("Not inside a project, cannot list targets")
return
# Check for updates
utils.check_for_updates()
# If no subcommand is given, just hint for --help.
if ctx.invoked_subcommand is None:
log.info("Run `pretext --help` for help.")
return
# If the subcommand is "upgrade", we don't need to load a project.
if ctx.invoked_subcommand == "upgrade":
log.debug("Upgrading project now")
return
if ctx.invoked_subcommand == "update":
log.debug("Updating project now")
return
# In all other cases we need to know whether we are in a directory for a project.
if (pp := utils.project_path()) is not None:
project = Project.parse(pp)
log.info(f"PreTeXt project found in `{utils.project_path()}`.")
logger.add_log_file_handler(pp / "logs")
# permanently change working directory for rest of process
os.chdir(pp)
if utils.requirements_version() is None:
log.warning(
"Project's CLI version could not be detected from `requirements.txt`."
)
log.warning("Try `pretext update` to produce a compatible file.")
elif utils.requirements_version() != VERSION:
log.warning(f"Using CLI version {VERSION} but project's `requirements.txt`")
log.warning(
f"is configured to use {utils.requirements_version()}. Consider either installing"
)
log.warning(
f"CLI version {utils.requirements_version()} or running `pretext update`"
)
log.warning(
f"to update `requirements.txt` and other managed files to match {VERSION}."
)
else:
log.debug(
f"CLI version {VERSION} matches requirements.txt {utils.requirements_version()}."
)
else:
if ctx.invoked_subcommand == "new":
# Creating a new command, so don't need default build
return
# Otherwise we are not in a project and not creating a project, so we should use the default manifest for building etc.
log.info(f"PreTeXt-CLI version: {VERSION}\n")
utils.ensure_default_project_manifest()
default_project_path = resources.resource_base_path() / "project.ptx"
project = Project.parse(default_project_path, global_manifest=True)
log.warning(
f"No project.ptx manifest found in current workspace. Using global configuration specified in '~/.ptx/{VERSION}/project.ptx'."
)
# Add project to context so it can be used in subcommands
ctx.obj = {"project": project}
@main.result_callback()
def exit(*_, **__): # type: ignore
# Exit gracefully:
utils.exit_command(error_flush_handler)
# pretext upgrade
@main.command(
short_help="Upgrade PreTeXt-CLI to the latest version using pip.",
context_settings=CONTEXT_SETTINGS,
)
def upgrade() -> None:
"""
Upgrade PreTeXt-CLI to the latest version using pip.
"""
extras = []
if importlib.util.find_spec("prefig") is not None:
log.debug(
"The 'prefig' package is installed; will attempt to upgrade it as well."
)
extras.append("prefigure")
if importlib.util.find_spec("pelican") is not None:
log.debug(
"The 'pelican' package is installed; will attempt to upgrade it as well."
)
extras.append("homepage")
if len(extras) > 0:
log.info(f"Upgrading PreTeXt (with extras: {', '.join(extras)})")
pretext_cmd = "pretext[" + ",".join(extras) + "]"
else:
log.info("Upgrading PreTeXt.")
pretext_cmd = "pretext"
subprocess.run([sys.executable, "-m", "pip", "install", "--upgrade", pretext_cmd])
log.info(
"Upgrade complete. Individual projects can be updated to align with the latest version of the CLI by running `pretext update` from their project folder."
)
# pretext update
@main.command(
short_help="Update the current project to match the installed version of PreTeXt. Note: to upgrade the installed version of pretext, use `pretext upgrade`.",
context_settings=CONTEXT_SETTINGS,
)
@click.option(
"-b", "--backup", is_flag=True, help="Backup project files before updating."
)
@click.option("-f", "--force", is_flag=True, help="Force update of project files.")
def update(backup: bool, force: bool) -> None:
"""
Update the current project to match the installed version of PreTeXt.
"""
if utils.cannot_find_project(task="update"):
log.info(
"Did you mean to run `pretext upgrade` to upgrade to the latest installed version of PreTeXt, or are you trying to update a particular project?"
)
return
project = Project.parse()
project.update_boilerplate(backup=backup, force=force)
log.info("Project updated successfully.")
# pretext support
@main.command(
short_help="Use when communicating with PreTeXt support.",
context_settings=CONTEXT_SETTINGS,
)
@nice_errors
def support() -> None:
"""
Outputs useful information about your installation needed by
PreTeXt volunteers when requesting help on the pretext-support
Google Group.
"""
log.debug("Running pretext support.")
log.info("")
log.info("Please share the following information when posting to the")
log.info("pretext-support Google Group.")
log.info("")
log.info(f"PreTeXt-CLI version: {VERSION}")
log.info(f" PyPI link: https://pypi.org/project/pretextbook/{VERSION}/")
log.info(f"PreTeXt core resources commit: {CORE_COMMIT}")
# Temporarily removing; this is handled by core differently now.
# log.info(f"Runestone Services version: {core.get_runestone_services_version()}")
log.info(f"OS: {platform.platform()}")
log.info(
f"Python version: {platform.python_version()}, running from {sys.executable}"
)
log.info(f"Current working directory: {Path.cwd()}")
if utils.project_path() is not None:
log.info(f"PreTeXt project path: {utils.project_path()}")
log.info("")
log.info("Contents of project.ptx:")
log.info("------------------------")
log.info(utils.project_xml_string())
log.info("------------------------")
# Create a project from the project.ptx file
project = Project.parse()
project.init_core()
for exec_name in project.get_executables().model_dump():
if utils.check_executable(exec_name) is None:
log.warning(
f"Unable to locate the command for `{exec_name}` on your system."
)
else:
log.info("No project.ptx found.")
# pretext devscript
@main.command(
short_help="Alias for the developer pretext/pretext script.",
context_settings={"help_option_names": [], "ignore_unknown_options": True},
)
@click.argument("args", nargs=-1)
def devscript(args: List[str]) -> None:
"""
Aliases the core pretext script.
"""
subprocess.run(
[
sys.executable,
str(resources.resource_base_path() / "core" / "pretext" / "pretext"),
]
+ list(args)
)
# pretext new
@main.command(
short_help="Generates all the necessary files for a new PreTeXt project.",
context_settings=CONTEXT_SETTINGS,
)
@click.argument(
"template",
default="book",
type=click.Choice(constants.NEW_TEMPLATES, case_sensitive=False),
)
@click.option(
"-d",
"--directory",
type=click.Path(),
default="new-pretext-project",
help="Directory to create/use for the project.",
)
@click.option(
"-u",
"--url-template",
type=click.STRING,
help="Download a zipped template from its URL.",
)
@nice_errors
def new(template: str, directory: Path, url_template: str) -> None:
"""
Generates all the necessary files for a new PreTeXt project.
Supports `pretext new book` (default) and `pretext new article`,
or generating from URL with `pretext new --url-template [URL]`.
"""
directory_fullpath = Path(directory).resolve()
if utils.project_path(directory_fullpath) is not None:
log.error(
f"A project already exists in `{utils.project_path(directory_fullpath)}` (it contains the file `project.ptx`)."
)
log.error("No new project will be generated.")
return
log.info(f"Generating new PreTeXt project in `{directory_fullpath}`")
directory_fullpath.mkdir(exist_ok=True)
if url_template is not None:
log.info(f"Using template at `{url_template}`")
# get project and extract to directory
r = requests.get(url_template)
archive = zipfile.ZipFile(io.BytesIO(r.content))
with tempfile.TemporaryDirectory(prefix="ptxcli_") as tmpdirname:
archive.extractall(tmpdirname)
content_path = [Path(tmpdirname) / i for i in os.listdir(tmpdirname)][0]
shutil.copytree(content_path, directory_fullpath, dirs_exist_ok=True)
else:
log.info(f"Using `{template}` template.")
# copy project from installed resources
template_path = resources.resource_base_path() / "templates" / f"{template}"
shutil.copytree(template_path, directory_fullpath, dirs_exist_ok=True)
# generate missing boilerplate
with utils.working_directory(directory_fullpath):
project_path = utils.project_path()
if project_path is None:
project = Project()
else:
project = Project.parse(project_path)
# Ensure that all boilerplate is included.
project.update_boilerplate()
# pretext init
@main.command(
short_help="Generates/updates CLI-specific files for the current version of PreTeXt-CLI.",
context_settings=CONTEXT_SETTINGS,
)
@click.option(
"-r",
"--refresh",
is_flag=True,
help="Refresh initialization of project even if project.ptx exists. [This will be deprecated in the future; use `pretext update -f` instead.] ",
)
@click.option(
"-f",
"--file",
"files",
help="Specify file to refresh.",
multiple=True,
type=click.Choice([r for r in constants.PROJECT_RESOURCES], case_sensitive=False),
)
@click.option(
"-s",
"--system",
is_flag=True,
help="Reinstall's the system's pretext resources, including the core pretext script, templates, and other resources (including npm installs). Useful if something is broken or after initial installation or upgrade.",
)
@nice_errors
def init(refresh: bool, files: List[str], system: bool) -> None:
"""
Generates/updates CLI-specific files for the current version of PreTeXt-CLI.
This feature is mainly intended for updating existing PreTeXt projects to use this CLI,
or to update project files generated from earlier CLI versions.
If --refresh or --file is used, files will be generated even if the project has already been initialized.
Existing files will be backed-up (as `*.bak`); the fresh initialized file will be created
at the original path.
Note: `pretext init -r` is does the same thing as `pretext update -f`.
"""
if system:
log.info("Reinstalling system resources...")
resources.install(reinstall=True, npm_install=True)
log.info("System resources reinstalled successfully.")
return
project_path = utils.project_path()
if project_path is None:
project = Project()
else:
if refresh or len(files) > 0:
project = Project.parse(project_path)
if refresh:
log.warning(
"The `pretext init --refresh` command will be deprecated in a future version. You can use `pretext update --force` instead."
)
else:
log.warning(f"A project already exists in `{project_path}`.")
log.warning(
"Use `pretext update --force` to refresh initialization of an existing project"
)
log.warning("or `pretext init --file FILENAME` to refresh a specific file.")
return
if len(files) > 0:
for file in files:
if file.lower() in constants.PROJECT_RESOURCES:
project.add_boilerplate(file.lower(), backup=True)
else:
log.error(f"File {file} is not a valid project resource.")
return
project.update_boilerplate(backup=True, force=True)
if project_path is None:
log.info("Success! Open project.ptx to edit your project manifest.")
log.info(
"Edit your `target` elements to point to the location of your PreTeXt source files."
)
else:
log.info(
"Success! Your project files have been refreshed. If you manage any of these"
)
log.info(
"manually, be sure to compare these new versions with your old .bak files."
)
# pretext build
@main.command(short_help="Build specified target", context_settings=CONTEXT_SETTINGS)
@click.argument("target_name", required=False, metavar="target")
@click.option(
"--clean",
is_flag=True,
help="Destroy output's target directory before build to clean up previously built files",
)
@click.option(
"-g",
"--generate",
is_flag=True,
help="Force (re)generates assets for targets, even if they haven't changed since they were last generated. (Use `pretext generate` for more fine-grained control of manual asset generation.)",
)
@click.option(
"-q",
"--no-generate",
is_flag=True,
help="Do not generate assets for target, even if their source has changed since the last time they were generated.",
)
@click.option(
"-t",
"--theme",
is_flag=True,
help="Only build the theme for the target, without performing any other build or generate steps. (Themes are automatically built when building a target.)",
)
@click.option(
"-l",
"--latex",
is_flag=True,
help="When building a PDF, also place LaTeX source files in the output directory for inspection or manual compilation.",
)
@click.option(
"-x",
"--xmlid",
type=click.STRING,
help="xml:id of the root of the subtree to be built.",
)
@click.option(
"--no-knowls",
is_flag=True,
help="Use hyperlinks instead of knowls (e.g. for previewing individual sections when knowl files from other sections may not exist)",
)
@click.option(
"--deploys",
is_flag=True,
help="Build all targets configured to be deployed.",
)
@click.option(
"-i",
"--input",
"source_file",
type=click.Path(),
help="Override the source file from the manifest by providing a path to the input.",
)
@click.pass_context
@nice_errors
def build(
ctx: click.Context,
target_name: Optional[str],
clean: bool,
generate: bool,
no_generate: bool,
theme: bool,
latex: bool,
xmlid: Optional[str],
no_knowls: bool,
deploys: bool,
source_file: Optional[str],
) -> None:
"""
Build [TARGET], which can be the name of a target specified by project.ptx or the name of a pretext file.
If using elements that require separate generation of assets (e.g., webwork, latex-image, etc.) then these will be generated automatically if their source has changed since the last build. You can suppress this with the `--no-generate` flag, or force a regeneration with the `--generate` flag.
Certain builds may require installations not included with the CLI, or internet
access to external servers. Command-line paths
to non-Python executables may be set in project.ptx. For more details,
consult the PreTeXt Guide: https://pretextbook.org/documentation.html
"""
# Set up project and target based on command line arguments and project.ptx
# Supply help if not in project subfolder
# NOTE: we no longer need the following since we have added support for building without a manifest.
# if utils.cannot_find_project(task="build"):
# return
# Create a new project, apply overlay, and get target. Note, the CLI always finds changes to the root folder of the project, so we don't need to specify a path to the project.ptx file.
# Use the project discovered in the main command.
project = ctx.obj["project"]
# Check to see whether target_name is a path to a file:
if target_name and Path(target_name).is_file():
log.debug(
f"target is a source file {Path(target_name).resolve()}. Using this to override input."
)
log.warning(
"Building standalone documents is an experimental feature and the interface may change."
)
# set the source_file to that target_name and reset target_name to None
source_file = target_name
target_name = None
# Now create the target if the target_name is not missing.
try:
# deploys flag asks to build multiple targets: all that have deploy set.
if deploys and len(project.deploy_targets()) > 0:
targets = project.deploy_targets()
elif target_name is None and source_file is not None:
# We are in the case where we are building a standalone document but no target_name was provided. We build a default html target if there are no standalone targets or find the first target with standalone="yes".
if len(project.standalone_targets()) > 0:
targets = [project.standalone_targets()[0]]
log.debug(f"Building standalone document with target {targets[0].name}")
else:
log.debug("Did not find a standalone project manifest.")
target = project.new_target(
name="standalone",
format="pdf",
standalone="yes",
output_dir=Path(source_file).resolve().parent,
)
targets = [target]
log.debug(f"Building standalone document with target {target.name}")
else:
targets = [project.get_target(name=target_name)]
except AssertionError as e:
log.warning("Assertion error in getting target.")
utils.show_target_hints(target_name, project, task="build")
log.critical("Exiting without completing build.")
log.debug(e, exc_info=True)
return
# If theme flag is set, only build the theme
if theme:
try:
for t in targets:
t.ensure_webwork_reps()
t.ensure_myopenmath_xml()
t.build_theme()
except Exception as e:
log.error(f"Failed to build theme: {e}")
log.debug("Exception info:\n------------------------\n", exc_info=True)
finally:
# Theme flag means to only build the theme, so we...
return
# If input/source_file is given, override the source file for the target
if source_file is not None:
for t in targets:
t.source = Path(source_file).resolve()
log.warning(f"Overriding source file for target with: {t.source}")
# Call generate if flag is set
if generate and not no_generate:
try:
for t in targets:
log.info(f"Generating assets for {t.name}")
t.generate_assets(
only_changed=False, xmlid=xmlid, clean=clean, skip_cache=True
)
no_generate = True
except Exception as e:
log.error(f"Failed to generate assets: {e} \n")
log.debug("Debug Info:\n", exc_info=True)
elif generate and no_generate:
log.warning(
"Using the `-g/--generate` flag together with `-q/--no-generate` doesn't make sense. Proceeding as if neither flag was set."
)
generate = False
no_generate = False
# Call build
try:
for t in targets:
log.info(f"Building target {t.name}")
if xmlid is not None:
log.info(f"with root of tree below {xmlid}")
t.build(
clean=clean,
generate=not no_generate,
xmlid=xmlid,
no_knowls=no_knowls,
latex=latex,
)
if t.format == "html" and t.compression is None:
log.info(
f"\nFinished build for target {t.name}. Run `pretext view {t.name}` to see the results.\n"
)
else:
log.info(
f"\nFinished build for target {t.name}. The output is in {t.output_dir_abspath()}.\n"
)
# Check if there are errors reported by the build by looking at the error_flush_handler.
if utils.has_errors(error_flush_handler):
raise Exception("There were errors during the build. See above.")
# Otherwise, we can report success.
log.info("\nSuccess! Built requested target(s) without errors.\n")
except ValidationError as e:
# A validation error at this point must be because the publication file is invalid, which only happens if the /source/directories/@generated|@external attributes are missing.
log.critical(
"It appears there is an error with your publication file. Are you missing the required source/directories/@external and @generated attributes?"
)
log.critical("Failed to build without errors. Exiting...")
log.debug(e)
log.debug(
"\n------------------------\nException info:\n------------------------\n",
exc_info=True,
)
return
except Exception as e:
log.critical(e)
log.debug("Exception info:\n------------------------\n", exc_info=True)
log.info("------------------------")
log.critical("Failed to build without errors. Exiting...")
return
# pretext generate
@main.command(
short_help="Generate specified assets for default target or targets specified by `-t`",
context_settings=CONTEXT_SETTINGS,
)
@click.argument(
"assets", type=click.Choice(constants.ASSETS, case_sensitive=False), nargs=-1
)
@click.option(
"-t",
"--target",
"target_name",
type=click.STRING,
help="Name of target to generate assets for (if not specified, first target from manifest is used).",
)
@click.option(
"-x", "--xmlid", type=click.STRING, help="xml:id of element to be generated."
)
@click.option(
"-q",
"--only-changed",
is_flag=True,
default=False,
help="Limit generation of assets to only those that have changed since last call to pretext.",
)
@click.option(
"--all-formats",
is_flag=True,
default=False,
help="Generate all possible asset formats rather than just the defaults for the specified target.",
)
@click.option(
"--clean",
is_flag=True,
default=False,
help="Remove all generated assets, including the cache, before generating new ones.",
)
@click.option(
"-f",
"--force",
is_flag=True,
help="Force generation of assets; do not rely on assets in the cache.",
)
@click.option(
"-s",
"--slow",
is_flag=True,
default=False,
help="Increase the timeout for capturing screenshots of interactive elements. Only needed if some interactive previews are not rendering correctly.",
)
@click.pass_context
@nice_errors
def generate(
ctx: click.Context,
assets: List[str],
target_name: Optional[str],
all_formats: bool,
only_changed: bool,
xmlid: Optional[str],
clean: bool,
force: bool,
slow: bool,
) -> None:
"""
Generate specified (or all) assets for the default target (first target in "project.ptx"). Asset "generation" is typically
slower and performed less frequently than "building" a project, but is
required for many PreTeXt features such as webwork and latex-image.
Certain assets may require installations not included with the CLI, or internet
access to external servers. Command-line paths
to non-Python executables may be set in project.ptx. For more details,
consult the PreTeXt Guide: https://pretextbook.org/documentation.html
"""
# If no assets are given as arguments, then assume 'ALL'
if assets == ():
assets = ["ALL"]
if utils.cannot_find_project(task="generate assets for"):
return
project = ctx.obj["project"]
# Now create the target if the target_name is not missing.
try:
target = project.get_target(name=target_name)
except AssertionError as e:
utils.show_target_hints(target_name, project, task="generating assets for")
log.critical("Exiting without completing build.")
log.debug(e, exc_info=True)
return
try:
log.debug(f'Generating assets in for the target "{target.name}".')
target.generate_assets(
requested_asset_types=assets,
all_formats=all_formats,
only_changed=only_changed, # Unless requested, generate all assets, so don't check the cache.
xmlid=xmlid,
clean=clean,
skip_cache=force,
slow=slow,
)
# Check if there are errors reported by the build by looking at the error_flush_handler.
if utils.has_errors(error_flush_handler):
raise Exception(
"There were errors during the generate process. See above."
)
# Otherwise, we can report success.
log.info("Finished generating assets successfully.\n")
except ValidationError as e:
# A validation error at this point must be because the publication file is invalid, which only happens if the /source/directories/@generated|@external attributes are missing.
log.critical(
"It appears there is an error with your publication file. Are you missing the required source/directories/@external and @generated attributes?"
)
log.critical("Failed to build. Exiting...")
log.debug(e)
log.debug(
"\n------------------------\nException info:\n------------------------\n",
exc_info=True,
)
return
except Exception as e:
log.critical(e)
log.debug("Exception info:\n------------------------\n", exc_info=True)
log.info("------------------------")
log.critical("Generating assets as failed. Exiting...")
return
# pretext view
@main.command(
short_help="Preview specified target based on its format.",
context_settings=CONTEXT_SETTINGS,
)
@click.argument("target_name", metavar="target", required=False)
@click.option(
"-a",
"--access",
type=click.Choice(["public", "private"], case_sensitive=False),
default="private",
show_default=True,
help="""
If running a local server,
choose whether or not to allow other computers on your local network
to access your documents using your IP address.
""",
)
@click.option(
"-p",
"--port",
type=click.INT,
default=8128,
help="""
If running a local server,
choose which port to use.
(Ignored when used
in CoCalc, which works automatically.)
""",
)
@click.option(
"-b",
"--build",
is_flag=True,
help="""
Run a build before viewing.
""",
)
@click.option(
"-g",
"--generate",
is_flag=True,
help="Generate all assets before viewing",
)
@click.option(
"--no-launch",
is_flag=True,
help="By default, pretext view tries to launch the default application to view the specified target. Setting this suppresses this behavior.",
)
@click.option(
"-r",
"--restart-server",
is_flag=True,
default=False,
help="Force restart the local http server in case it is already running.",
)
@click.option(
"-s",
"--stop-server",
is_flag=True,
default=False,
help="Stop the local http server if running.",
)
@click.option(
"-d",
"--stage",
is_flag=True,
default=False,
help="View the staged deployment.",
)
@click.option(
"--default-server",
is_flag=True,
default=False,
help="Use the standard python server, even if in a codespace (for debugging)",
)
@click.pass_context
@nice_errors
def view(
ctx: click.Context,
target_name: str,
access: Literal["public", "private"],
port: int,
build: bool,
generate: bool,
no_launch: bool,
restart_server: bool,
stop_server: bool,
stage: bool,
default_server: str,
) -> None:
"""
Starts a local server to preview built PreTeXt documents in your browser.
TARGET is the name of a <target/> defined in `project.ptx` (defaults to the first target).
After running this command, you can switch to a new terminal to rebuild your project and see the changes automatically reflected in your browser.
If a server is already running, no new server will be started (nor will it need to be), unless you pass the `--restart-server` flag. You can stop a running server with CTRL+C or by passing the `--stop-server` flag.
"""
# pretext view -s should immediately stop the server and do nothing else.
if utils.cannot_find_project(task="view the output for"):
return
project = Project.parse()
project_hash = utils.hash_path(project.abspath())
current_server = server.active_server_for_path_hash(project_hash)
if stop_server:
try:
log.info("\nStopping server.")
if current_server:
current_server.terminate()
except Exception as e:
log.warning("Failed to stop server.")
log.debug(e, exc_info=True)
finally:
return
try:
target = project.get_target(name=target_name, log_info_for_none=not stage)
except AssertionError as e:
utils.show_target_hints(target_name, project, task="view")
log.critical("Exiting.")
log.debug(e, exc_info=True)
return
# Call generate if flag is set
if generate:
try:
target.generate_assets(only_changed=False)
except Exception as e:
log.warning(f"Failed to generate assets: {e}")
log.debug("", exc_info=True)
# Call build if flag is set
if build:
try:
target.build()
except Exception as e:
log.warning(f"Failed to build: {e}")
log.debug("Exception info:\n------------------------\n", exc_info=True)
# Set up the url path and target name
if stage:
target_name = "staged deployment"
url_path = "/" + project.stage.as_posix()
else:
target_name = f"target `{target.name}`"
url_path = "/" + target.output_dir_relpath().as_posix()
in_codespace = os.environ.get("CODESPACES")
# Start server if there isn't one running already:
if restart_server and current_server is not None:
log.info(
f"Terminating existing server {current_server.pid} on port {current_server.port}"
)
current_server.terminate()
current_server = None
# Double check that the current server really is active:
if current_server is not None and current_server.is_active_server():
url_base = utils.url_for_access(access=access, port=current_server.port)
url = url_base + url_path
log.info(f"Server is already available at {url_base}")
if no_launch:
log.info(f"The {target_name} is available at {url}")
else:
log.info(f"Now opening browser for {target_name} at {url}")
webbrowser.open(url)
# otherwise, start a server
else:
log.info(
f"Now preparing local server to preview your project directory `{project.abspath()}`."
)
log.info(
" (Reminder: use `pretext deploy` to deploy your built project to a public"
)
log.info(
" GitHub Pages site that can be shared with readers who cannot access your"
)
log.info(" personal computer.)")
log.info("")
def callback(actual_port: int) -> None:
url_base = utils.url_for_access(access=access, port=actual_port)
url = url_base + url_path
log.info(f"Server will soon be available at {url_base}")
if no_launch:
log.info(f"The {target_name} will be available at {url}")
else:
log.info(f"Opening browser for {target_name} at {url}")
webbrowser.open(url)
log.info("starting server ...")
if in_codespace and not default_server:
log.info("Running in a codespace, so using a subprocess server.")
server.start_codespace_server(project.abspath(), access, port, callback)