StrictDoc Documentation
tasks.py
Source file coverage
Path:
tasks.py
Lines:
1260
Non-empty lines:
1105
Non-empty lines covered with requirements:
1105 / 1105 (100.0%)
Functions:
43
Functions covered by requirements:
43 / 43 (100.0%)
1
import os
2
import re
3
import shutil
4
import sys
5
import tempfile
6
from enum import Enum
7
from pathlib import Path
8
from typing import Dict, Optional
9
 
10
import invoke
11
from invoke import task
12
 
13
from developer.git.commit_validator import (
14
    validate_commits_locally_or_ci,
15
)
16
from strictdoc.core.environment import (
17
    BINARY_HTML_TEMPLATES_DIR,
18
    HTML_TEMPLATE_DIRS,
19
)
20
 
21
# Specifying encoding because Windows crashes otherwise when running Invoke
22
# tasks below:
23
# UnicodeEncodeError: 'charmap' codec can't encode character '\ufffd'
24
# in position 16: character maps to <undefined>
25
# People say, it might also be possible to export PYTHONIOENCODING=utf8 but this
26
# seems to work.
27
# FIXME: If you are a Windows user and expert, please advise on how to do this
28
# properly.
29
sys.stdout = open(1, "w", encoding="utf-8", closefd=False, buffering=1)
30
 
31
STRICTDOC_TMP_DIR = os.path.join(tempfile.gettempdir(), "strictdoc_tmp_dir")
32
TEST_REPORTS_DIR = "build/test_reports"
33
 
34
 
35
def get_pyinstaller_html_template_data_options() -> str:
36
    return "\n".join(
37
        f'--add-data "{template_dir}{os.pathsep}{BINARY_HTML_TEMPLATES_DIR}"'
38
        for template_dir in HTML_TEMPLATE_DIRS
39
    )
40
 
41
 
42
def get_nuitka_html_template_data_options() -> str:
43
    return "\n".join(
44
        f'--include-data-dir="{template_dir}={BINARY_HTML_TEMPLATES_DIR}"'
45
        for template_dir in HTML_TEMPLATE_DIRS
46
    )
47
 
48
 
49
# To prevent all tasks from building to the same virtual environment.
50
# All values correspond to the configuration in the tox.ini config file.
51
class ToxEnvironment(str, Enum):
52
    DEVELOPMENT = "development"
53
    CHECK = "check"
54
    DOCUMENTATION = "documentation"
55
    RELEASE = "release"
56
    RELEASE_LOCAL = "release-local"
57
    PYINSTALLER = "pyinstaller"
58
 
59
 
60
def run_invoke(
61
    context,
62
    cmd,
63
    environment: Optional[dict] = None,
64
    pty: bool = False,
65
    warn: bool = False,
66
) -> invoke.runners.Result:
67
    def one_line_command(string):
68
        return re.sub("\\s+", " ", string).strip()
69
 
70
    return context.run(
71
        one_line_command(cmd),
72
        env=environment,
73
        hide=False,
74
        warn=warn,
75
        pty=pty,
76
        echo=True,
77
    )
78
 
79
 
80
def run_invoke_with_tox(
81
    context,
82
    environment_type: ToxEnvironment,
83
    command: str,
84
    environment: Optional[Dict] = None,
85
    pty: bool = False,
86
) -> invoke.runners.Result:
87
    assert isinstance(environment_type, ToxEnvironment)
88
    assert isinstance(command, str)
89
 
90
    tox_py_version = f"py{sys.version_info.major}{sys.version_info.minor}"
91
 
92
    return run_invoke(
93
        context,
94
        f"""
95
            tox
96
                -e {tox_py_version}-{environment_type.value} --
97
                {command}
98
        """,
99
        environment=environment,
100
        pty=pty,
101
    )
102
 
103
 
104
@task(default=True)
105
def list_tasks(context):
106
    clean_command = """
107
        invoke --list
108
    """
109
    run_invoke(context, clean_command)
110
 
111
 
112
@task
113
def clean(context):
114
    # https://unix.stackexchange.com/a/689930/77389
115
    clean_command = """
116
        rm -rf output/ docs/sphinx/build/
117
    """
118
    run_invoke(context, clean_command)
119
 
120
 
121
@task(aliases=["s"])
122
def server(context, input_path=".", config=None):
123
    assert os.path.isdir(input_path), input_path
124
    if config is not None:
125
        assert os.path.isfile(config), config
126
    config_argument = f"--config {config}" if config is not None else ""
127
    run_invoke_with_tox(
128
        context,
129
        ToxEnvironment.DEVELOPMENT,
130
        f"""
131
            python -m strictdoc.cli.main
132
                --debug
133
                server {input_path} {config_argument}
134
                    --host 127.0.0.1
135
                    --reload
136
        """,
137
    )
138
 
139
 
140
@task(aliases=["d"])
141
def docs(context):
142
    run_invoke_with_tox(
143
        context,
144
        ToxEnvironment.DOCUMENTATION,
145
        """
146
            python3 -m strictdoc.cli.main
147
                export .
148
                    --formats=html
149
                    --output-dir output/strictdoc_website
150
                    --project-title "StrictDoc"
151
        """,
152
    )
153
 
154
    run_invoke_with_tox(
155
        context,
156
        ToxEnvironment.DOCUMENTATION,
157
        """
158
            python3 -m strictdoc.cli.main
159
                export ./
160
                    --formats=rst
161
                    --output-dir output/sphinx
162
                    --project-title "StrictDoc"
163
        """,
164
    )
165
 
166
    run_invoke_with_tox(
167
        context,
168
        ToxEnvironment.DOCUMENTATION,
169
        """
170
            cp -r output/sphinx/rst/docs/* docs/sphinx/source/ &&
171
            cp -r output/sphinx/rst/docs_extra/* docs/sphinx/source/ &&
172
            mkdir -p docs/sphinx/source/_assets/ &&
173
            cp -v docs/_assets/* docs/sphinx/source/_assets/
174
        """,
175
    )
176
 
177
    run_invoke_with_tox(
178
        context,
179
        ToxEnvironment.DOCUMENTATION,
180
        """
181
            make --directory docs/sphinx html latexpdf SPHINXOPTS="-W --keep-going"
182
        """,
183
    )
184
 
185
    run_invoke(
186
        context,
187
        (
188
            """
189
                open docs/sphinx/build/latex/strictdoc.pdf
190
            """
191
        ),
192
    )
193
 
194
 
195
@task(aliases=["tus"])
196
def test_unit_server(context, focus=None):
197
    focus_argument = f"-k {focus}" if focus is not None else ""
198
 
199
    Path(TEST_REPORTS_DIR).mkdir(parents=True, exist_ok=True)
200
 
201
    cwd = os.getcwd()
202
 
203
    path_to_coverage_file = f"{cwd}/build/coverage/unit_server/.coverage"
204
 
205
    run_invoke_with_tox(
206
        context,
207
        ToxEnvironment.CHECK,
208
        f"""
209
            coverage run
210
            --rcfile=.coveragerc.unit_server
211
            --data-file={path_to_coverage_file}
212
            -m pytest
213
            tests/unit_server/
214
                {focus_argument}
215
                --junit-xml={TEST_REPORTS_DIR}/tests_unit_server.pytest.junit.xml
216
                -o junit_suite_name="StrictDoc Web Server Unit Tests"
217
                -o cache_dir=build/pytest_unit_server
218
        """,
219
    )
220
 
221
 
222
@task(test_unit_server, aliases=["tusc"])
223
def test_unit_server_report(context):
224
    cwd = os.getcwd()
225
 
226
    path_to_coverage_file = f"{cwd}/build/coverage/unit_server/.coverage"
227
 
228
    run_invoke_with_tox(
229
        context,
230
        ToxEnvironment.CHECK,
231
        f"""
232
            coverage html
233
                --rcfile=.coveragerc.unit_server
234
                --data-file={path_to_coverage_file}
235
        """,
236
    )
237
 
238
 
239
@task(aliases=["te"])
240
def test_end2end(
241
    context,
242
    *,
243
    focus=None,
244
    exit_first=False,
245
    parallelize=False,
246
    long_timeouts=False,
247
    headless=False,
248
    headed=False,
249
    shard=None,
250
    test_path=None,
251
    coverage: bool = False,
252
):
253
    """
254
    @relation(SDOC-SRS-46, scope=function)
255
    """
256
 
257
    environment = {}
258
 
259
    coverage_command_or_none = ""
260
    coverage_argument_or_none = ""
261
 
262
    if coverage:
263
        cwd = os.getcwd()
264
        coverage_file_dir = f"{cwd}/build/coverage/end2end/"
265
        coverage_file_dir2 = f"{cwd}/build/coverage/end2end_strictdoc/"
266
        coverage_file = os.path.join(coverage_file_dir, ".coverage")
267
        coverage_rc = os.path.join(cwd, ".coveragerc.end2end")
268
        shutil.rmtree(coverage_file_dir, ignore_errors=True)
269
        shutil.rmtree(coverage_file_dir2, ignore_errors=True)
270
        coverage_command_or_none = f"""
271
            coverage run
272
                --rcfile={coverage_rc}
273
                --data-file={coverage_file}
274
                -m
275
        """
276
        coverage_argument_or_none = "--strictdoc-coverage"
277
 
278
    long_timeouts_argument = (
279
        "--strictdoc-long-timeouts" if long_timeouts else ""
280
    )
281
 
282
    parallelize_argument = ""
283
    if parallelize:
284
        print(  # noqa: T201
285
            "warning: "
286
            "Running parallelized end-2-end tests is supported "
287
            "but is not stable."
288
        )
289
        parallelize_argument = "--numprocesses=2 --strictdoc-parallelize"
290
 
291
    assert shard is None or re.match(r"[1-9][0-9]*/[1-9][0-9]*", shard), (
292
        f"--shard argument has an incorrect format: {shard}."
293
    )
294
    shard_argument = f"--strictdoc-shard={shard}" if shard else ""
295
 
296
    focus_argument = f"-k {focus}" if focus is not None else ""
297
    exit_first_argument = "--exitfirst" if exit_first else ""
298
    headless_argument = "--headless2" if headless and not headed else "--gui"
299
    test_command = f"""
300
            {coverage_command_or_none}
301
            pytest
302
            --failed-first
303
            --capture=no
304
            --reuse-session
305
            {parallelize_argument}
306
            {shard_argument}
307
            {coverage_argument_or_none}
308
            {focus_argument}
309
            {exit_first_argument}
310
            {long_timeouts_argument}
311
            {headless_argument}
312
            --junit-xml={TEST_REPORTS_DIR}/tests_end2end.pytest.junit.xml
313
            -o junit_suite_name="StrictDoc End-to-End Tests"
314
            -o cache_dir=build/pytest_end2end
315
            tests/end2end
316
    """
317
    if test_path:
318
        test_command = test_command.rstrip() + f"/{test_path}"
319
 
320
    Path(TEST_REPORTS_DIR).mkdir(parents=True, exist_ok=True)
321
 
322
    # On Windows, GitHub Actions fails with:
323
    # response = {'status': 500, 'value':
324
    # '{"value":{"error":"unknown error",
325
    # "message":"unknown error: cannot find Chrome binary",  # noqa: ERA001
326
    # This very likely has to do with PATH isolation that Tox does.
327
    # FIXME: If you are a Windows expert, please fix this to run on Tox.
328
    if os.name == "nt":
329
        run_invoke(context, test_command)
330
        return
331
 
332
    run_invoke_with_tox(
333
        context,
334
        ToxEnvironment.CHECK,
335
        test_command,
336
        environment=environment,
337
    )
338
 
339
 
340
@task(aliases=["tu"])
341
def test_unit(context, coverage=False, focus=None, path=None, output=False):
342
    """
343
    @relation(SDOC-SRS-44, scope=function)
344
    """
345
 
346
    Path(TEST_REPORTS_DIR).mkdir(parents=True, exist_ok=True)
347
 
348
    focus_argument = f"-k {focus}" if focus is not None else ""
349
    output_argument = "--capture=no" if output else ""
350
 
351
    cwd = os.getcwd()
352
 
353
    if path is None:
354
        path = "tests/unit"
355
    else:
356
        assert "tests/unit" in path, path
357
 
358
    path_to_coverage_file = f"{cwd}/build/coverage/unit/.coverage"
359
 
360
    pytest_command = (
361
        """
362
        coverage run
363
            --rcfile=.coveragerc.unit
364
            --data-file={path_to_coverage_file}
365
            -m pytest
366
    """
367
        if coverage
368
        else "pytest"
369
    )
370
 
371
    run_invoke_with_tox(
372
        context,
373
        ToxEnvironment.CHECK,
374
        f"""
375
            {pytest_command}
376
            {focus_argument}
377
            {output_argument}
378
            --junit-xml={TEST_REPORTS_DIR}/tests_unit.pytest.junit.xml
379
            -o cache_dir=build/pytest_unit_with_coverage
380
            -o junit_suite_name="StrictDoc Unit Tests"
381
            -p no:seleniumbase
382
            {path}
383
        """,
384
    )
385
    if coverage and not focus and path == "tests/unit":
386
        run_invoke_with_tox(
387
            context,
388
            ToxEnvironment.CHECK,
389
            f"""
390
                coverage report
391
                    --sort=cover
392
                    --rcfile=.coveragerc.unit
393
                    --data-file={path_to_coverage_file}
394
            """,
395
        )
396
 
397
 
398
@task(test_unit, aliases=["tuc"])
399
def test_unit_report(context):
400
    cwd = os.getcwd()
401
 
402
    path_to_coverage_file = f"{cwd}/build/coverage/unit/.coverage"
403
 
404
    run_invoke_with_tox(
405
        context,
406
        ToxEnvironment.CHECK,
407
        f"""
408
            coverage html
409
                --rcfile=.coveragerc.unit
410
                --data-file={path_to_coverage_file}
411
        """,
412
    )
413
 
414
 
415
@task(aliases=["ti"])
416
def test_integration(
417
    context,
418
    focus=None,
419
    debug=False,
420
    no_parallelization=False,
421
    fail_first=False,
422
    coverage=False,
423
    strictdoc=None,
424
    html2pdf=False,
425
    shard=None,
426
    environment=ToxEnvironment.CHECK,
427
):
428
    """
429
    @relation(SDOC-SRS-45, scope=function)
430
    """
431
 
432
    cwd = os.getcwd()
433
 
434
    if strictdoc is None:
435
        strictdoc_exec = "python3 -m strictdoc.cli.main"
436
    else:
437
        strictdoc_exec = strictdoc
438
 
439
    coverage_path_argument = ""
440
    if coverage:
441
        path_to_coverage_rc = f"{cwd}/.coveragerc.integration"
442
        strictdoc_exec = (
443
            f"coverage run --rcfile={path_to_coverage_rc} -m strictdoc.cli.main"
444
        )
445
        if html2pdf:
446
            path_to_coverage_dir = f"{cwd}/build/coverage/integration_html2pdf/"
447
        else:
448
            path_to_coverage_dir = f"{cwd}/build/coverage/integration/"
449
        path_to_coverage = os.path.join(path_to_coverage_dir, ".coverage")
450
        shutil.rmtree(path_to_coverage_dir, ignore_errors=True)
451
        coverage_path_argument = (
452
            f'--param COVERAGE_FILE="{path_to_coverage}" '
453
            f'--param COVERAGE_PROCESS_START="{path_to_coverage_rc}"'
454
        )
455
 
456
    debug_opts = "-vv --show-all" if debug else ""
457
    focus_or_none = f"--filter {focus}" if focus else ""
458
    fail_first_argument = "--max-failures 1" if fail_first else ""
459
    junit_xml_report_argument = (
460
        "--xunit-xml-output build/test_reports/tests_integration_html2pdf.lit.junit.xml"
461
        if html2pdf
462
        else "--xunit-xml-output build/test_reports/tests_integration.lit.junit.xml"
463
    )
464
 
465
    # Allow partitioning of integration and html2pdf tests
466
    partition_opts = ""
467
    if shard is not None:
468
        match = re.match(r"([1-9][0-9]*)/([1-9][0-9]*)", shard)
469
        assert match, f"--shard argument has an incorrect format: {shard}."
470
        run_shard = int(match.group(1))
471
        num_shards = int(match.group(2))
472
        partition_opts = f"--num-shards={num_shards} --run-shard={run_shard}"
473
 
474
    # HTML2PDF tests are running Chrome Driver which does not seem to be
475
    # parallelizable, or at least not in the way StrictDoc uses it.
476
    # If HTML2PDF option is provided, do not parallelize and only run the
477
    # HTML2PDF-specific tests.
478
    # HTML2PDF tests can be safely partitioned.
479
    chromedriver_param = ""
480
    if not html2pdf:
481
        parallelize_opts = "" if not no_parallelization else "--threads 1"
482
        html2pdf_param = ""
483
        test_folder = f"{cwd}/tests/integration"
484
        test_output_dir = "build/tests_integration"
485
    else:
486
        parallelize_opts = "--threads 1"
487
        html2pdf_param = "--param TEST_HTML2PDF=1"
488
        chromedriver_path = os.environ.get("CHROMEWEBDRIVER")
489
        if chromedriver_path is not None:
490
            # NOTE: isfile() check does not work on GitHub Actions / Linux,
491
            #       the exists() check works.
492
            assert os.path.exists(chromedriver_path), chromedriver_path
493
            chromedriver_param = f"--param CHROMEDRIVER={os.path.join(chromedriver_path, 'chromedriver')}"
494
            if os.name == "nt":
495
                # On Windows, its chromdriver.exe
496
                chromedriver_param = chromedriver_param + ".exe"
497
        test_folder = f"{cwd}/tests/integration/features/html2pdf"
498
        test_output_dir = "build/tests_integration_html2pdf"
499
 
500
    # The command sometimes exits with 1 even if the files are deleted.
501
    # warn=True ensures that the execution continues.
502
    run_invoke(
503
        context,
504
        f"""
505
        rm -rf {test_output_dir}
506
        """,
507
        warn=True,
508
    )
509
 
510
    run_invoke(
511
        context,
512
        f"""
513
        rm -rf {STRICTDOC_TMP_DIR}
514
        """,
515
    )
516
 
517
    Path(STRICTDOC_TMP_DIR).mkdir(exist_ok=True)
518
    Path(TEST_REPORTS_DIR).mkdir(parents=True, exist_ok=True)
519
 
520
    itest_command = f"""
521
        lit
522
        --param STRICTDOC_EXEC="{strictdoc_exec}"
523
        --param STRICTDOC_TMP_DIR="{STRICTDOC_TMP_DIR}"
524
        --param TEST_OUTPUT_DIR="{test_output_dir}"
525
        --timeout 180
526
        --order smart
527
        {junit_xml_report_argument}
528
        {coverage_path_argument}
529
        {html2pdf_param}
530
        {chromedriver_param}
531
        -v
532
        {debug_opts}
533
        {focus_or_none}
534
        {fail_first_argument}
535
        {parallelize_opts}
536
        {partition_opts}
537
        {test_folder}
538
    """
539
 
540
    # It looks like LIT does not open the RUN: subprocesses in the same
541
    # environment from which it itself is run from. This issue has been known by
542
    # us for a couple of years by now. Not using Tox on Windows for the time
543
    # being.
544
    if os.name == "nt":
545
        run_invoke(context, itest_command)
546
        return
547
 
548
    run_invoke_with_tox(
549
        context,
550
        environment,
551
        itest_command,
552
        environment={"STRICTDOC_CACHE_DIR": "Output/_cache"},
553
    )
554
 
555
 
556
@task
557
def coverage_combine(context):
558
    run_invoke_with_tox(
559
        context,
560
        ToxEnvironment.CHECK,
561
        """
562
            coverage combine
563
                --data-file build/coverage/.coverage.combined
564
                --keep
565
                build/coverage/end2end_strictdoc/.coverage.*
566
                build/coverage/integration/.coverage.*
567
                build/coverage/integration_html2pdf/.coverage.*
568
                build/coverage/unit/.coverage
569
                build/coverage/unit_server/.coverage
570
        """,
571
    )
572
    run_invoke_with_tox(
573
        context,
574
        ToxEnvironment.CHECK,
575
        """
576
            coverage html
577
                --rcfile .coveragerc.combined
578
                --data-file build/coverage/.coverage.combined
579
        """,
580
    )
581
    run_invoke_with_tox(
582
        context,
583
        ToxEnvironment.CHECK,
584
        """
585
            coverage json
586
                --rcfile .coveragerc.combined
587
                --data-file build/coverage/.coverage.combined
588
                --pretty-print
589
                -o build/coverage/coverage.combined.json
590
        """,
591
    )
592
 
593
 
594
@task
595
def lint_ruff_format(context):
596
    """
597
    @relation(SDOC-SRS-42, scope=function)
598
    """
599
 
600
    result: invoke.runners.Result = run_invoke_with_tox(
601
        context,
602
        ToxEnvironment.CHECK,
603
        """
604
            ruff
605
                format
606
                --cache-dir build/ruff
607
                *.py
608
                developer/
609
                docs/
610
                strictdoc/
611
                tools/ecss
612
                tests/unit/
613
                tests/unit_server/
614
                tests/integration/*.py
615
                tests/end2end/
616
        """,
617
    )
618
    # Ruff always exits with 0, so we handle the output.
619
    if "reformatted" in result.stdout:
620
        print("invoke: ruff format found issues")  # noqa: T201
621
        result.exited = 1
622
        raise invoke.exceptions.UnexpectedExit(result)
623
 
624
 
625
@task(aliases=["lr"])
626
def lint_ruff(context):
627
    """
628
    @relation(SDOC-SRS-42, scope=function)
629
    """
630
 
631
    run_invoke_with_tox(
632
        context,
633
        ToxEnvironment.CHECK,
634
        """
635
            ruff check . --fix --exit-non-zero-on-fix --cache-dir build/ruff
636
        """,
637
    )
638
 
639
 
640
@task(aliases=["lm"])
641
def lint_mypy(context):
642
    """
643
    @relation(SDOC-SRS-41, SDOC-SRS-43, scope=function)
644
    """
645
 
646
    # These checks do not seem to be useful:
647
    # - import
648
    # --disallow-any-expr
649
    # --disallow-any-explicit
650
    # --disallow-any-unimported  # noqa: ERA001
651
    # --disallow-any-decorated
652
    # - type-abstract. It is ignored on purpose because of assert_cast()
653
    #   implementation. See https://stackoverflow.com/a/74073453/598057.
654
    run_invoke_with_tox(
655
        context,
656
        ToxEnvironment.CHECK,
657
        """
658
            mypy docs/
659
                 strictdoc/
660
                 tests/unit/strictdoc/backend/sdoc_source_code/test_marker_lexer.py
661
 
662
                --show-error-codes
663
                --disable-error-code=import
664
                --disable-error-code=type-abstract
665
                --cache-dir=build/mypy
666
                --extra-checks
667
 
668
                --strict
669
                --strict-optional
670
                --strict-equality
671
 
672
                --check-untyped-defs
673
                --disallow-any-generics
674
                --disallow-incomplete-defs
675
                --disallow-subclassing-any
676
                --disallow-untyped-calls
677
                --disallow-untyped-decorators
678
                --disallow-untyped-defs
679
                --no-implicit-optional
680
                --warn-no-return
681
                --warn-redundant-casts
682
                --warn-return-any
683
                --warn-unreachable
684
                --warn-unused-ignores
685
 
686
                --python-version=3.10
687
        """,
688
    )
689
 
690
 
691
@task
692
def lint_format_js(context):
693
    # NOTE: Could not find the '--' equivalent for -w80.
694
    result: invoke.runners.Result = run_invoke_with_tox(
695
        context,
696
        ToxEnvironment.CHECK,
697
        """
698
            js-beautify
699
                --indent-size=2
700
                --end-with-newline
701
                --replace
702
                -w100
703
                strictdoc/export/html/_static/static_html_search.js
704
                strictdoc/export/html/_static/stable_uri_forwarder.js
705
        """,
706
    )
707
    # Ruff always exits with 0, so we handle the output.
708
    if "reformatted" in result.stdout:
709
        print("invoke: ruff format found issues")  # noqa: T201
710
        result.exited = 1
711
        raise invoke.exceptions.UnexpectedExit(result)
712
 
713
 
714
@task(aliases=["lc"])
715
def lint_commit(context):  # noqa: ARG001
716
    try:
717
        validate_commits_locally_or_ci()
718
    except ValueError as e:
719
        raise invoke.exceptions.Exit(message=str(e), code=1) from None
720
 
721
 
722
@task(aliases=["lf"])
723
def lint_fixit(context, fix=False, auto=False, path="strictdoc/"):
724
    if fix:
725
        auto_argument = "--automatic" if auto else ""
726
        run_invoke_with_tox(
727
            context,
728
            ToxEnvironment.CHECK,
729
            f"""
730
                fixit fix {path} {auto_argument}
731
            """,
732
            pty=True,
733
        )
734
    else:
735
        run_invoke_with_tox(
736
            context,
737
            ToxEnvironment.CHECK,
738
            f"""
739
                fixit lint --diff {path}
740
            """,
741
        )
742
 
743
 
744
@task(aliases=["l"])
745
def lint(context):
746
    lint_commit(context)
747
    lint_ruff_format(context)
748
    lint_ruff(context)
749
    lint_mypy(context)
750
 
751
 
752
@task(aliases=["t"])
753
def test(context, shard=None):
754
    test_unit(context)
755
    test_unit_server(context)
756
    test_integration(context, shard=shard)
757
 
758
 
759
@task(aliases=["ta"])
760
def test_all(context, coverage=False, headless=False):
761
    test_unit(context, coverage=coverage)
762
    test_unit_server(context)
763
    test_integration(context, coverage=coverage)
764
    test_integration(context, coverage=coverage, html2pdf=True)
765
    test_end2end(context, coverage=coverage, headless=headless)
766
 
767
 
768
@task(aliases=["c"])
769
def check(context):
770
    lint(context)
771
    test(context)
772
 
773
 
774
# https://github.com/github-changelog-generator/github-changelog-generator
775
# gem install github_changelog_generator
776
@task
777
def changelog(context, github_token):
778
    # The alpha release tags are excluded from the changelog.
779
    command = f"""
780
        github_changelog_generator
781
        --token {github_token}
782
        --user strictdoc-project
783
        --exclude-tags-regex ".*a\\d+"
784
        --project strictdoc
785
        """
786
    run_invoke(context, command)
787
 
788
 
789
@task
790
def check_dead_links(context):
791
    run_invoke_with_tox(
792
        context,
793
        ToxEnvironment.CHECK,
794
        """
795
            python3 tools/link_health.py docs/strictdoc_01_user_guide.sdoc
796
        """,
797
    )
798
    run_invoke_with_tox(
799
        context,
800
        ToxEnvironment.CHECK,
801
        """
802
            python3 tools/link_health.py docs/strictdoc_02_feature_map.sdoc
803
        """,
804
    )
805
    run_invoke_with_tox(
806
        context,
807
        ToxEnvironment.CHECK,
808
        """
809
            python3 tools/link_health.py docs/strictdoc_03_faq.sdoc
810
        """,
811
    )
812
    run_invoke_with_tox(
813
        context,
814
        ToxEnvironment.CHECK,
815
        """
816
            python3 tools/link_health.py docs/strictdoc_04_release_notes.sdoc
817
        """,
818
    )
819
    run_invoke_with_tox(
820
        context,
821
        ToxEnvironment.CHECK,
822
        """
823
            python3 tools/link_health.py docs/strictdoc_05_troubleshooting.sdoc
824
        """,
825
    )
826
    run_invoke_with_tox(
827
        context,
828
        ToxEnvironment.CHECK,
829
        """
830
            python3 tools/link_health.py docs/strictdoc_10_contributing.sdoc
831
        """,
832
    )
833
    run_invoke_with_tox(
834
        context,
835
        ToxEnvironment.CHECK,
836
        """
837
            python3 tools/link_health.py docs/strictdoc_11_developer_guide.sdoc
838
        """,
839
    )
840
    run_invoke_with_tox(
841
        context,
842
        ToxEnvironment.CHECK,
843
        """
844
            python3 tools/link_health.py docs/strictdoc_24_development_plan.sdoc
845
        """,
846
    )
847
    run_invoke_with_tox(
848
        context,
849
        ToxEnvironment.CHECK,
850
        """
851
            python3 tools/link_health.py docs/strictdoc_20_l1_system_requirements.sdoc
852
        """,
853
    )
854
    run_invoke_with_tox(
855
        context,
856
        ToxEnvironment.CHECK,
857
        """
858
            python3 tools/link_health.py docs/strictdoc_21_l2_high_level_requirements.sdoc
859
        """,
860
    )
861
    run_invoke_with_tox(
862
        context,
863
        ToxEnvironment.CHECK,
864
        """
865
            python3 tools/link_health.py docs/strictdoc_25_design.sdoc
866
        """,
867
    )
868
    run_invoke_with_tox(
869
        context,
870
        ToxEnvironment.CHECK,
871
        """
872
            python3 tools/link_health.py CONTRIBUTING.md
873
        """,
874
    )
875
    run_invoke_with_tox(
876
        context,
877
        ToxEnvironment.CHECK,
878
        """
879
            python3 tools/link_health.py NOTICE
880
        """,
881
    )
882
    run_invoke_with_tox(
883
        context,
884
        ToxEnvironment.CHECK,
885
        """
886
            python3 tools/link_health.py README.md
887
        """,
888
    )
889
 
890
 
891
@task
892
def release_local(context):
893
    run_invoke(
894
        context,
895
        """
896
            rm -rfv dist/ build/
897
        """,
898
    )
899
    run_invoke(
900
        context,
901
        """
902
            pip uninstall strictdoc -y
903
        """,
904
    )
905
    run_invoke_with_tox(
906
        context,
907
        ToxEnvironment.RELEASE_LOCAL,
908
        """
909
            python -m build
910
        """,
911
    )
912
    run_invoke_with_tox(
913
        context,
914
        ToxEnvironment.RELEASE_LOCAL,
915
        """
916
            twine check dist/*
917
        """,
918
    )
919
    run_invoke_with_tox(
920
        context,
921
        ToxEnvironment.RELEASE_LOCAL,
922
        """
923
            pip install dist/*.tar.gz
924
        """,
925
    )
926
    test_integration(
927
        context, strictdoc="strictdoc", environment=ToxEnvironment.RELEASE_LOCAL
928
    )
929
 
930
 
931
@task
932
def release(context, test_pypi=False, username=None, password=None):
933
    """
934
    A release can be made to PyPI or test package index (TestPyPI):
935
    https://pypi.org/project/strictdoc/
936
    https://test.pypi.org/project/strictdoc/
937
    """
938
 
939
    env_user = os.environ.get("TWINE_USERNAME")
940
    env_pass = os.environ.get("TWINE_PASSWORD")
941
 
942
    assert not ((username or password) and (env_user or env_pass)), (
943
        username,
944
        password,
945
        env_user,
946
        env_pass,
947
    )
948
    assert (username and password) or (env_user and env_pass), (
949
        username,
950
        password,
951
        env_user,
952
        env_pass,
953
    )
954
    if env_user:
955
        assert env_user == "__token__"
956
 
957
    repository_argument_or_none = ""
958
    if username is not None and password is not None:
959
        repository_argument_or_none = (
960
            ""
961
            if username
962
            else (
963
                "--repository strictdoc_test"
964
                if test_pypi
965
                else "--repository strictdoc_release"
966
            )
967
        )
968
    user_password = f"-u{username} -p{password}" if username is not None else ""
969
 
970
    run_invoke(
971
        context,
972
        """
973
            rm -rfv dist/
974
        """,
975
    )
976
    run_invoke_with_tox(
977
        context,
978
        ToxEnvironment.RELEASE,
979
        """
980
            python3 -m build
981
        """,
982
    )
983
    run_invoke_with_tox(
984
        context,
985
        ToxEnvironment.RELEASE,
986
        """
987
            twine check dist/*
988
        """,
989
    )
990
    # The token is in a core developer's .pypirc file.
991
    # https://test.pypi.org/manage/account/token/
992
    # https://packaging.python.org/en/latest/specifications/pypirc/#pypirc
993
    run_invoke_with_tox(
994
        context,
995
        ToxEnvironment.RELEASE,
996
        f"""
997
            twine upload dist/strictdoc-*.tar.gz dist/strictdoc-*.whl
998
                {repository_argument_or_none}
999
                {user_password}
1000
        """,
1001
    )
1002
 
1003
 
1004
@task
1005
def release_pyinstaller(context):
1006
    path_to_pyi_dist = "/tmp/strictdoc"
1007
    html_template_data_options = get_pyinstaller_html_template_data_options()
1008
 
1009
    # The --hidden-import strictdoc.server.app flag is needed because without
1010
    # it, the following is produced:
1011
    # ERROR: Error loading ASGI app. Could not import
1012
    # module "strictdoc.server.app".
1013
    # Solution found here: https://stackoverflow.com/a/71340437/598057
1014
    # This behavior is not surprising because that's how the uvicorn loads the
1015
    # application separately from the parent process.
1016
    #
1017
    # Compatibility modules can be imported by user-provided statistics
1018
    # generators at runtime. PyInstaller cannot discover these imports
1019
    # statically because the generators live outside of StrictDoc's package.
1020
    command = f"""
1021
        pyinstaller
1022
            --clean
1023
            --name strictdoc
1024
            --noconfirm
1025
            --additional-hooks-dir developer/pyinstaller_hooks
1026
            --distpath {path_to_pyi_dist}
1027
            --hidden-import strictdoc.core.statistics.metric
1028
            --hidden-import strictdoc.export.html.generators.project_statistics
1029
            --hidden-import strictdoc.export.html.generators.view_objects.project_statistics_view_object
1030
            --hidden-import strictdoc.export.html.generators.view_objects.project_tree_stats
1031
            --hidden-import strictdoc.export.rst.strictdoc_lexer
1032
            --hidden-import strictdoc.server.app
1033
            {html_template_data_options}
1034
            --add-data strictdoc/export/rst/templates:templates/rst
1035
            --add-data strictdoc/export/html/_static:_static
1036
            --add-data strictdoc/export/html/_static_extra:_static_extra
1037
            strictdoc/cli/main.py
1038
    """
1039
 
1040
    run_invoke_with_tox(
1041
        context,
1042
        ToxEnvironment.PYINSTALLER,
1043
        """
1044
    pyinstaller --version
1045
    """,
1046
    )
1047
 
1048
    run_invoke_with_tox(context, ToxEnvironment.PYINSTALLER, command)
1049
 
1050
 
1051
@task
1052
def watch(context, sdocs_path="."):
1053
    strictdoc_command = f"""
1054
        python -m strictdoc.cli.main
1055
            export
1056
            {sdocs_path}
1057
            --output-dir output/
1058
    """
1059
 
1060
    run_invoke_with_tox(
1061
        context,
1062
        ToxEnvironment.DEVELOPMENT,
1063
        f"""
1064
            {strictdoc_command}
1065
        """,
1066
    )
1067
 
1068
    paths_to_watch = "."
1069
    run_invoke_with_tox(
1070
        context,
1071
        ToxEnvironment.DEVELOPMENT,
1072
        f"""
1073
            watchmedo shell-command
1074
            --patterns="*.py;*.sdoc;*.jinja;*.html;*.css;*.js"
1075
            --recursive
1076
            --ignore-pattern='output/;tests/integration'
1077
            --command='{strictdoc_command}'
1078
            --drop
1079
            {paths_to_watch}
1080
        """,
1081
    )
1082
 
1083
 
1084
@task
1085
def run(context, command):
1086
    run_invoke_with_tox(
1087
        context,
1088
        ToxEnvironment.DEVELOPMENT,
1089
        f"""
1090
            {command}
1091
        """,
1092
    )
1093
 
1094
 
1095
@task
1096
def nuitka(context):
1097
    html_template_data_options = get_nuitka_html_template_data_options()
1098
 
1099
    run_invoke(
1100
        context,
1101
        f"""
1102
        PYTHONPATH="{os.getcwd()}"
1103
        python -m nuitka
1104
            --static-libpython=no
1105
            --standalone
1106
            --include-module=textx
1107
            --include-module=strictdoc.server.app
1108
            --include-module=docutils
1109
            --include-module=docutils.readers.standalone
1110
            --include-module=docutils.parsers.rst
1111
            {html_template_data_options}
1112
            --include-data-dir=strictdoc/export/rst/templates=templates/rst
1113
            --include-data-dir=strictdoc/export/html/_static=_static
1114
            --include-data-dir=strictdoc/export/html/_static_extra/mathjax=_static_extra/mathjax
1115
            --include-package-data=docutils
1116
            strictdoc/cli/main.py
1117
        """,
1118
    )
1119
 
1120
 
1121
# https://github.com/jrfonseca/gprof2dot
1122
# pip install gprof2dot
1123
@task()
1124
def performance(context):
1125
    command = """
1126
        python -m cProfile -o output/profile.prof
1127
            -m strictdoc.cli.main export . --no-parallelization &&
1128
        gprof2dot -f pstats output/profile.prof | dot -Tpng -o output/output.png
1129
    """
1130
    run_invoke(context, command)
1131
 
1132
 
1133
@task(performance)
1134
def performance_snakeviz(context):
1135
    command = """
1136
        snakeviz output/profile.prof
1137
    """
1138
    run_invoke(context, command)
1139
 
1140
 
1141
@task(aliases=["bd"])
1142
def build_docker(
1143
    context,
1144
    image: str = "strictdoc:latest",
1145
    no_cache: bool = False,
1146
    source="pypi",
1147
):
1148
    no_cache_argument = "--no-cache" if no_cache else ""
1149
    run_invoke(
1150
        context,
1151
        f"""
1152
        docker build .
1153
            --build-arg STRICTDOC_SOURCE={source}
1154
            -t {image}
1155
            {no_cache_argument}
1156
        """,
1157
    )
1158
 
1159
 
1160
@task(aliases=["rd"])
1161
def run_docker(
1162
    context, image: str = "strictdoc:latest", command: Optional[str] = None
1163
):
1164
    command_argument = (
1165
        f'/bin/bash -c "{command}"' if command is not None else ""
1166
    )
1167
 
1168
    run_invoke(
1169
        context,
1170
        f"""
1171
        docker run
1172
            --name strictdoc
1173
            --rm
1174
            -it
1175
            -e HOST_UID=$(id -u) -e HOST_GID=$(id -g)
1176
            -v "$(pwd):/data"
1177
            {image}
1178
            {command_argument}
1179
        """,
1180
        pty=True,
1181
    )
1182
 
1183
 
1184
@task(aliases=["td"])
1185
def test_docker(context, image: str = "strictdoc:latest"):
1186
    run_invoke(
1187
        context,
1188
        """
1189
        rm -rf output/ && mkdir -p output/ && chmod 777 output/
1190
        """,
1191
    )
1192
    run_docker(
1193
        context,
1194
        image=image,
1195
        command="strictdoc export --formats=html,html2pdf .",
1196
    )
1197
 
1198
    def check_file_owner(filepath):
1199
        import pwd  # noqa: PLC0415
1200
 
1201
        file_owner = pwd.getpwuid(os.stat(filepath).st_uid).pw_name
1202
        current_user = os.environ.get("USER", "")
1203
        return file_owner == current_user
1204
 
1205
    assert check_file_owner(
1206
        "output/html2pdf/pdf/docs/strictdoc_01_user_guide.pdf"
1207
    )
1208
 
1209
 
1210
@task(aliases=["q"])
1211
def qualification(context):
1212
    test_all(context, coverage=True, headless=True)
1213
    coverage_combine(context)
1214
 
1215
 
1216
@task()
1217
def drawio(context):
1218
    if sys.platform == "darwin":
1219
        path_to_drawio = "/Applications/draw.io.app/Contents/MacOS/draw.io"
1220
    elif sys.platform.startswith("linux"):
1221
        path_to_drawio = "drawio"
1222
    else:
1223
        raise NotImplementedError(
1224
            "drawio task is supported only on macOS and Linux."
1225
        )
1226
 
1227
    artifacts = [
1228
        (
1229
            "developer/drawio/Architecture.drawio",
1230
            "docs/_assets/StrictDoc_Workspace-Architecture.drawio.png",
1231
        ),
1232
        (
1233
            "developer/drawio/Backlog.drawio",
1234
            "docs/_assets/StrictDoc_Workspace-Backlog.drawio.png",
1235
        ),
1236
        (
1237
            "developer/drawio/Roadmap.drawio",
1238
            "docs/_assets/StrictDoc_Workspace-Roadmap.drawio.png",
1239
        ),
1240
    ]
1241
 
1242
    for path_to_drawio_, path_to_png_ in artifacts:
1243
        print(f"Copying: {path_to_drawio_} -> {path_to_png_}")  # noqa: T201
1244
 
1245
        # Basic safety for now to avoid writing wrong files.
1246
        assert os.path.isfile(path_to_drawio_), path_to_drawio_
1247
        assert os.path.isfile(path_to_png_), path_to_png_
1248
 
1249
        run_invoke(
1250
            context,
1251
            f"""
1252
            {path_to_drawio}
1253
                --export
1254
                --format png
1255
                -o {path_to_png_}
1256
                --page-index 0
1257
                {path_to_drawio_}
1258
            """,
1259
            pty=True,
1260
        )