File: conf.py

package info (click to toggle)
ubelt 1.4.0-1
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid
  • size: 2,180 kB
  • sloc: python: 15,487; sh: 807; makefile: 24
file content (1039 lines) | stat: -rw-r--r-- 35,792 bytes parent folder | download
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
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
"""
Notes:
    Based on template code in:
        ~/code/xcookie/xcookie/builders/docs.py
        ~/code/xcookie/xcookie/rc/conf_ext.py

    http://docs.readthedocs.io/en/latest/getting_started.html

    pip install sphinx sphinx-autobuild sphinx_rtd_theme sphinxcontrib-napoleon

    cd ~/code/ubelt
    mkdir -p docs
    cd docs

    sphinx-quickstart

    # need to edit the conf.py

    # Remove any old auto docs folder and regenerate it.
    rm -rf ~/code/ubelt/docs/source/auto
    cd ~/code/ubelt/docs
    sphinx-apidoc --private --separate --force --output-dir ~/code/ubelt/docs/source/auto ~/code/ubelt/ubelt
    git add source/auto/*.rst

    # Note: the module should importable before running this
    # (e.g. install it in developer mode or munge the PYTHONPATH)
    make html

    Also:
        To turn on PR checks

        https://docs.readthedocs.io/en/stable/guides/autobuild-docs-for-pull-requests.html

        https://readthedocs.org/dashboard/ubelt/advanced/

        ensure your github account is connected to readthedocs
        https://readthedocs.org/accounts/social/connections/

        ### For gitlab

        To enable the read-the-docs go to https://readthedocs.org/dashboard/ and login

        The user will need to enable the repo on their readthedocs account:
        https://readthedocs.org/dashboard/import/manual/?

        Enter the following information:
            Set the Repository NAME: ubelt
            Set the Repository URL: https://github.com/Erotemic/ubelt

        Make sure you have a .readthedocs.yml file

        For gitlab you also need to setup an integrations. Navigate to:

            https://readthedocs.org/dashboard/ubelt/integrations/create/

        Then add gitlab incoming webhook and copy the URL (make sure
        you copy the real url and not the text so https is included),
        specifically:

            In the "Integration type:" dropdown menu, select
            "Gitlab incoming webhook"

            Click "Add integration"

            Copy the text in the "Webhook URL" box to be used later.

            Copy the text in the "Secret" box to be used later.

        Then go to

            https://github.com/Erotemic/ubelt/hooks

            Click "Add new webhook".

            Copy the text previously saved from the "Webhook URL" box
            in the readthedocs form into the "URL" box in the gitlab
            form.

            Copy the text previously saved from the "Secret" box
            in the readthedocs form into the "Secret token" box in the
            gitlab form.

            For trigger permissions select the following checkboxes:
                push events,
                tag push events,
                merge request events
                release events

            Click the "Add webhook" button.

        See Docs for more details https://docs.readthedocs.io/en/stable/integrations.html

        Will also need to activate the main branch:
            https://readthedocs.org/projects/ubelt/versions/
"""
#
# Configuration file for the Sphinx documentation builder.
#
# This file does only contain a selection of the most common options. For a
# full list see the documentation:
# http://www.sphinx-doc.org/en/stable/config

# -- Path setup --------------------------------------------------------------

# If extensions (or modules to document with autodoc) are in another directory,
# add these directories to sys.path here. If the directory is relative to the
# documentation root, use os.path.abspath to make it absolute, like shown here.
#
# import os
# import sys
# sys.path.insert(0, os.path.abspath('.'))


# -- Project information -----------------------------------------------------
import sphinx_rtd_theme
from os.path import exists
from os.path import dirname
from os.path import join


def parse_version(fpath):
    """
    Statically parse the version number from a python file
    """
    import ast
    if not exists(fpath):
        raise ValueError('fpath={!r} does not exist'.format(fpath))
    with open(fpath, 'r') as file_:
        sourcecode = file_.read()
    pt = ast.parse(sourcecode)
    class VersionVisitor(ast.NodeVisitor):
        def visit_Assign(self, node):
            for target in node.targets:
                if getattr(target, 'id', None) == '__version__':
                    self.version = node.value.s
    visitor = VersionVisitor()
    visitor.visit(pt)
    return visitor.version

project = 'ubelt'
copyright = '2025, Jon Crall'
author = 'Jon Crall'
modname = 'ubelt'

repo_dpath = dirname(dirname(dirname(__file__)))
mod_dpath = join(repo_dpath, 'ubelt')
src_dpath = dirname(mod_dpath)
modpath = join(mod_dpath, '__init__.py')
release = parse_version(modpath)
version = '.'.join(release.split('.')[0:2])

# Hack to ensure the module is importable
# sys.path.insert(0, os.path.abspath(src_dpath))

# -- General configuration ---------------------------------------------------

# If your documentation needs a minimal Sphinx version, state it here.
#
# needs_sphinx = '1.0'

# Add any Sphinx extension module names here, as strings. They can be
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
# ones.
extensions = [
    # 'autoapi.extension',
    'sphinx.ext.autodoc',
    'sphinx.ext.autosummary',
    'sphinx.ext.intersphinx',
    'sphinx.ext.napoleon',
    'sphinx.ext.todo',
    'sphinx.ext.viewcode',
    'myst_parser',  # For markdown docs
    'sphinx.ext.imgconverter',  # For building latexpdf
    'sphinx.ext.githubpages',
    # 'sphinxcontrib.redirects',
    'sphinxcontrib.jquery',  # Fix for search
    'sphinx_reredirects',
]

todo_include_todos = True
napoleon_google_docstring = True
napoleon_use_param = False
napoleon_use_ivar = True

#autoapi_type = 'python'
#autoapi_dirs = [mod_dpath]

autodoc_inherit_docstrings = False

# Hack for geowatch, todo configure
autosummary_mock_imports = [
    'geowatch.utils.lightning_ext._jsonargparse_ext_ge_4_24_and_lt_4_xx',
    'geowatch.utils.lightning_ext._jsonargparse_ext_ge_4_22_and_lt_4_24',
    'geowatch.utils.lightning_ext._jsonargparse_ext_ge_4_21_and_lt_4_22',
    'geowatch.tasks.fusion.datamodules.temporal_sampling.affinity_sampling',
    'geowatch.tasks.depth_pcd.model',
    'geowatch.tasks.cold.export_change_map',
]

autodoc_default_options = {  # Document callable classes
    'special-members': '__call__'}

autodoc_member_order = 'bysource'
autoclass_content = 'both'
# autodoc_mock_imports = ['torch', 'torchvision', 'visdom']

# autoapi_modules = {
#     modname: {
#         'override': False,
#         'output': 'auto'
#     }
# }
# autoapi_dirs = [f'../../src/{modname}']
# autoapi_keep_files = True

# References:
# https://stackoverflow.com/questions/21538983/specifying-targets-for-intersphinx-links-to-numpy-scipy-and-matplotlib

intersphinx_mapping = {
    # 'pytorch': ('http://pytorch.org/docs/master/', None),
    'python': ('https://docs.python.org/3', None),
    'click': ('https://click.palletsprojects.com/', None),
    # 'xxhash': ('https://pypi.org/project/xxhash/', None),
    # 'pygments': ('https://pygments.org/docs/', None),
    # 'tqdm': ('https://tqdm.github.io/', None),
    # Requires that the repo have objects.inv
    'kwarray': ('https://kwarray.readthedocs.io/en/latest/', None),
    'kwimage': ('https://kwimage.readthedocs.io/en/latest/', None),
    # 'kwplot': ('https://kwplot.readthedocs.io/en/latest/', None),
    'ndsampler': ('https://ndsampler.readthedocs.io/en/latest/', None),
    'ubelt': ('https://ubelt.readthedocs.io/en/latest/', None),
    'xdoctest': ('https://xdoctest.readthedocs.io/en/latest/', None),
    'networkx': ('https://networkx.org/documentation/stable/', None),
    'scriptconfig': ('https://scriptconfig.readthedocs.io/en/latest/', None),
    'rich': ('https://rich.readthedocs.io/en/latest/', None),

    'numpy': ('https://numpy.org/doc/stable/', None),
    'sympy': ('https://docs.sympy.org/latest/', None),
    'scikit-learn': ('https://scikit-learn.org/stable/', None),
    'pandas': ('https://pandas.pydata.org/docs/', None),
    'matplotlib': ('https://matplotlib.org/stable/', None),

    'pytest': ('https://docs.pytest.org/en/latest/', None),
    'platformdirs': ('https://platformdirs.readthedocs.io/en/latest/', None),

    'timerit': ('https://timerit.readthedocs.io/en/latest/', None),
    'progiter': ('https://progiter.readthedocs.io/en/latest/', None),
    'dateutil': ('https://dateutil.readthedocs.io/en/latest/', None),
    # 'pytest._pytest.doctest': ('https://docs.pytest.org/en/latest/_modules/_pytest/doctest.html', None),
    # 'colorama': ('https://pypi.org/project/colorama/', None),
    # 'cv2' : ('http://docs.opencv.org/2.4/', None),
    # 'h5py' : ('http://docs.h5py.org/en/latest/', None)
}
__dev_note__ = """
python -m sphinx.ext.intersphinx https://docs.python.org/3/objects.inv
python -m sphinx.ext.intersphinx https://kwcoco.readthedocs.io/en/latest/objects.inv
python -m sphinx.ext.intersphinx https://networkx.org/documentation/stable/objects.inv
python -m sphinx.ext.intersphinx https://kwarray.readthedocs.io/en/latest/objects.inv
python -m sphinx.ext.intersphinx https://kwimage.readthedocs.io/en/latest/objects.inv
python -m sphinx.ext.intersphinx https://ubelt.readthedocs.io/en/latest/objects.inv
python -m sphinx.ext.intersphinx https://networkx.org/documentation/stable/objects.inv

sphobjinv suggest -t 90 -u https://readthedocs.org/projects/pytest/reference/objects.inv
"signal.convolve2d"

python -m sphinx.ext.intersphinx https://pygments-doc.readthedocs.io/en/latest/objects.inv
"""


# Add any paths that contain templates here, relative to this directory.
templates_path = ['_templates']

# The suffix(es) of source filenames.
# You can specify multiple suffix as a list of string:
#
source_suffix = ['.rst', '.md']

# The master toctree document.
master_doc = 'index'

# The language for content autogenerated by Sphinx. Refer to documentation
# for a list of supported languages.
#
# This is also used if you do content translation via gettext catalogs.
# Usually you set "language" from the command line for these cases.
language = 'en'

# List of patterns, relative to source directory, that match files and
# directories to ignore when looking for source files.
# This pattern also affects html_static_path and html_extra_path .
exclude_patterns = []

# The name of the Pygments (syntax highlighting) style to use.
pygments_style = 'sphinx'


# -- Options for HTML output -------------------------------------------------

# The theme to use for HTML and HTML Help pages.  See the documentation for
# a list of builtin themes.
#
html_theme = 'sphinx_rtd_theme'
html_theme_path = [sphinx_rtd_theme.get_html_theme_path()]

# Theme options are theme-specific and customize the look and feel of a theme
# further.  For a list of options available for each theme, see the
# documentation.
#
html_theme_options = {
    'collapse_navigation': False,
    'display_version': True,
    'navigation_depth': -1,
    # 'logo_only': True,
}
# html_logo = '.static/ubelt.svg'
# html_favicon = '.static/ubelt.ico'

# Add any paths that contain custom static files (such as style sheets) here,
# relative to this directory. They are copied after the builtin static files,
# so a file named "default.css" will overwrite the builtin "default.css".
html_static_path = ['_static']

# Custom sidebar templates, must be a dictionary that maps document names
# to template names.
#
# The default sidebars (for documents that don't match any pattern) are
# defined by theme itself.  Builtin themes are using these templates by
# default: ``['localtoc.html', 'relations.html', 'sourcelink.html',
# 'searchbox.html']``.
#
# html_sidebars = {}


# -- Options for HTMLHelp output ---------------------------------------------

# Output file base name for HTML help builder.
htmlhelp_basename = project + 'doc'


# -- Options for LaTeX output ------------------------------------------------

# References:
# https://tex.stackexchange.com/questions/546246/centos-8-the-font-freeserif-cannot-be-found

"""
# https://www.sphinx-doc.org/en/master/usage/builders/index.html#sphinx.builders.latex.LaTeXBuilder
# https://tex.stackexchange.com/a/570691/83399
sudo apt install fonts-freefont-otf texlive-luatex texlive-latex-extra texlive-fonts-recommended texlive-latex-recommended tex-gyre latexmk
make latexpdf LATEXMKOPTS="-shell-escape --synctex=-1 -src-specials -interaction=nonstopmode"
make latexpdf LATEXMKOPTS="-lualatex -interaction=nonstopmode"
make LATEXMKOPTS="-lualatex -interaction=nonstopmode"

"""
# latex_engine = 'lualatex'
# latex_engine = 'xelatex'

latex_elements = {
    # The paper size ('letterpaper' or 'a4paper').
    #
    # 'papersize': 'letterpaper',

    # The font size ('10pt', '11pt' or '12pt').
    #
    # 'pointsize': '10pt',

    # Additional stuff for the LaTeX preamble.
    #
    # 'preamble': '',

    # Latex figure (float) alignment
    #
    # 'figure_align': 'htbp',
}

# Grouping the document tree into LaTeX files. List of tuples
# (source start file, target name, title,
#  author, documentclass [howto, manual, or own class]).
latex_documents = [
    (master_doc, 'ubelt.tex', 'ubelt Documentation',
     'Jon Crall', 'manual'),
]


# -- Options for manual page output ------------------------------------------

# One entry per manual page. List of tuples
# (source start file, name, description, authors, manual section).
man_pages = [
    (master_doc, 'ubelt', 'ubelt Documentation',
     [author], 1)
]


# -- Options for Texinfo output ----------------------------------------------

# Grouping the document tree into Texinfo files. List of tuples
# (source start file, target name, title, author,
#  dir menu entry, description, category)
texinfo_documents = [
    (master_doc, 'ubelt', 'ubelt Documentation',
     author, 'ubelt', 'One line description of project.',
     'Miscellaneous'),
]


# -- Extension configuration -------------------------------------------------
from sphinx.domains.python import PythonDomain  # NOQA
# from sphinx.application import Sphinx  # NOQA
from typing import Any, List  # NOQA


# HACK TO PREVENT EXCESSIVE TIME.
# TODO: FIXME FOR REAL
MAX_TIME_MINUTES = None
if MAX_TIME_MINUTES:
    import ubelt  # NOQA
    TIMER = ubelt.Timer()
    TIMER.tic()


class PatchedPythonDomain(PythonDomain):
    """
    References:
        https://github.com/sphinx-doc/sphinx/issues/3866
    """
    def resolve_xref(self, env, fromdocname, builder, typ, target, node, contnode):
        """
        Helps to resolves cross-references
        """
        if target.startswith('ub.'):
            target = 'ubelt.' + target[3]
        if target.startswith('xdoc.'):
            target = 'xdoctest.' + target[3]
        return_value = super(PatchedPythonDomain, self).resolve_xref(
            env, fromdocname, builder, typ, target, node, contnode)
        return return_value


class GoogleStyleDocstringProcessor:
    """
    A small extension that runs after napoleon and reformats erotemic-flavored
    google-style docstrings for sphinx.
    """

    def __init__(self, autobuild=1):
        self.debug = 0
        self.registry = {}
        if autobuild:
            self._register_builtins()

    def register_section(self, tag, alias=None):
        """
        Decorator that adds a custom processing function for a non-standard
        google style tag. The decorated function should accept a list of
        docstring lines, where the first one will be the google-style tag that
        likely needs to be replaced, and then return the appropriate sphinx
        format (TODO what is the name? Is it just RST?).
        """
        alias = [] if alias is None else alias
        alias = [alias] if not isinstance(alias, (list, tuple, set)) else alias
        alias.append(tag)
        alias = tuple(alias)
        # TODO: better tag patterns
        def _wrap(func):
            self.registry[tag] = {
                'tag': tag,
                'alias': alias,
                'func': func,
            }
            return func
        return _wrap

    def _register_builtins(self):
        """
        Adds definitions I like of CommandLine, TextArt, and Ignore
        """

        @self.register_section(tag='CommandLine')
        def commandline(lines):
            new_lines = []
            new_lines.append('.. rubric:: CommandLine')
            new_lines.append('')
            new_lines.append('.. code-block:: bash')
            new_lines.append('')
            new_lines.extend(lines[1:])
            return new_lines

        @self.register_section(tag='SpecialExample', alias=['Benchmark', 'Sympy', 'Doctest'])
        def benchmark(lines):
            import textwrap
            new_lines = []
            tag = lines[0].replace(':', '').strip()
            # new_lines.append(lines[0])  # TODO: it would be nice to change the tagline.
            # new_lines.append('')
            new_lines.append('.. rubric:: {}'.format(tag))
            new_lines.append('')
            new_text = textwrap.dedent('\n'.join(lines[1:]))
            redone = new_text.split('\n')
            new_lines.extend(redone)
            # import ubelt as ub
            # print('new_lines = {}'.format(ub.urepr(new_lines, nl=1)))
            # new_lines.append('')
            return new_lines

        @self.register_section(tag='TextArt', alias=['Ascii'])
        def text_art(lines):
            new_lines = []
            new_lines.append('.. rubric:: TextArt')
            new_lines.append('')
            new_lines.append('.. code-block:: bash')
            new_lines.append('')
            new_lines.extend(lines[1:])
            return new_lines

        # @self.register_section(tag='TODO', alias=['.. todo::'])
        # def todo_section(lines):
        #     """
        #     Fixup todo sections
        #     """
        #     import xdev
        #     xdev.embed()
        #     import ubelt as ub
        #     print('lines = {}'.format(ub.urepr(lines, nl=1)))
        #     return new_lines

        @self.register_section(tag='Ignore')
        def ignore(lines):
            return []

    def process(self, lines):
        """
        Example:
            >>> import ubelt as ub
            >>> self = GoogleStyleDocstringProcessor()
            >>> lines = ['Hello world',
            >>>              '',
            >>>              'CommandLine:',
            >>>              '    hi',
            >>>              '',
            >>>              'CommandLine:',
            >>>              '',
            >>>              '    bye',
            >>>              '',
            >>>              'TextArt:',
            >>>              '',
            >>>              '    1',
            >>>              '    2',
            >>>              '',
            >>>              '    345',
            >>>              '',
            >>>              'Foobar:',
            >>>              '',
            >>>              'TextArt:']
            >>> new_lines = self.process(lines[:])
            >>> print(chr(10).join(new_lines))
        """
        orig_lines = lines[:]
        new_lines = []
        curr_mode = '__doc__'
        accum = []

        def accept():
            """ called when we finish reading a section """
            if curr_mode == '__doc__':
                # Keep the lines as-is
                new_lines.extend(accum)
            else:
                # Process this section with the given function
                regitem = self.registry[curr_mode]
                func = regitem['func']
                fixed = func(accum)
                new_lines.extend(fixed)
            # Reset the accumulator for the next section
            accum[:] = []

        for line in orig_lines:

            found = None
            for regitem in self.registry.values():
                if line.startswith(regitem['alias']):
                    found = regitem['tag']
                    break
            if not found and line and not line.startswith(' '):
                # if the line startswith anything but a space, we are no longer
                # in the previous nested scope. NOTE: This assumption may not
                # be general, but it works for my code.
                found = '__doc__'

            if found:
                # New section is found, accept the previous one and start
                # accumulating the new one.
                accept()
                curr_mode = found

            accum.append(line)

        # Finalize the last section
        accept()

        lines[:] = new_lines
        # make sure there is a blank line at the end
        if lines and lines[-1]:
            lines.append('')

        return lines

    def process_docstring_callback(self, app, what_: str, name: str, obj: Any,
                                   options: Any, lines: List[str]) -> None:
        """
        Callback to be registered to autodoc-process-docstring

        Custom process to transform docstring lines Remove "Ignore" blocks

        Args:
            app (sphinx.application.Sphinx): the Sphinx application object

            what (str):
                the type of the object which the docstring belongs to (one of
                "module", "class", "exception", "function", "method", "attribute")

            name (str): the fully qualified name of the object

            obj: the object itself

            options: the options given to the directive: an object with
                attributes inherited_members, undoc_members, show_inheritance
                and noindex that are true if the flag option of same name was
                given to the auto directive

            lines (List[str]): the lines of the docstring, see above

        References:
            https://www.sphinx-doc.org/en/1.5.1/_modules/sphinx/ext/autodoc.html
            https://www.sphinx-doc.org/en/master/usage/extensions/autodoc.html
        """
        if self.debug:
            print(f'ProcessDocstring: name={name}, what_={what_}, num_lines={len(lines)}')

        # print('BEFORE:')
        # import ubelt as ub
        # print('lines = {}'.format(ub.urepr(lines, nl=1)))

        self.process(lines)

        # docstr = '\n'.join(lines)
        # if 'Convert the Mask' in docstr:
        #     import xdev
        #     xdev.embed()

        # if 'keys in this dictionary ' in docstr:
        #     import xdev
        #     xdev.embed()

        render_doc_images = 0

        if MAX_TIME_MINUTES and TIMER.toc() > (60 * MAX_TIME_MINUTES):
            render_doc_images = False  # FIXME too slow on RTD

        if render_doc_images:
            # DEVELOPING
            if any('REQUIRES(--show)' in line for line in lines):
                # import xdev
                # xdev.embed()
                create_doctest_figure(app, obj, name, lines)

        FIX_EXAMPLE_FORMATTING = 1
        if FIX_EXAMPLE_FORMATTING:
            for idx, line in enumerate(lines):
                if line == "Example:":
                    lines[idx] = "**Example:**"
                    lines.insert(idx + 1, "")

        REFORMAT_SECTIONS = 0
        if REFORMAT_SECTIONS:
            REFORMAT_RETURNS = 0
            REFORMAT_PARAMS = 0

            docstr = SphinxDocstring(lines)

            if REFORMAT_PARAMS:
                for found in docstr.find_tagged_lines('Parameters'):
                    print(found['text'])
                    edit_slice = found['edit_slice']

                    # TODO: figure out how to do this.

                    # # file = 'foo.rst'
                    # import rstparse
                    # rst = rstparse.Parser()
                    # import io
                    # rst.read(io.StringIO(found['text']))
                    # rst.parse()
                    # for line in rst.lines:
                    #     print(line)

                    # # found['text']
                    # import docutils

                    # settings = docutils.frontend.OptionParser(
                    #     components=(docutils.parsers.rst.Parser,)
                    #     ).get_default_values()
                    # document = docutils.utils.new_document('<tempdoc>', settings)
                    # from docutils.parsers import rst
                    # rst.Parser().parse(found['text'], document)

            if REFORMAT_RETURNS:
                for found in docstr.find_tagged_lines('returns'):
                    # FIXME: account for new slice with -2 offset
                    edit_slice = found['edit_slice']
                    text = found['text']
                    new_lines = []
                    for para in text.split('\n\n'):
                        indent = para[:len(para) - len(para.lstrip())]
                        new_paragraph = indent + paragraph(para)
                        new_lines.append(new_paragraph)
                        new_lines.append('')
                    new_lines = new_lines[:-1]
                    lines[edit_slice] = new_lines

        # print('AFTER:')
        # print('lines = {}'.format(ub.urepr(lines, nl=1)))

        # if name == 'kwimage.Affine.translate':
        #     import sys
        #     sys.exit(1)


class SphinxDocstring:
    """
    Helper to parse and modify sphinx docstrings
    """
    def __init__(docstr, lines):
        docstr.lines = lines

        # FORMAT THE RETURNS SECTION A BIT NICER
        import re
        tag_pat = re.compile(r'^:(\w*):')
        directive_pat = re.compile(r'^.. (\w*)::\s*(\w*)')

        # Split by sphinx types, mark the line offset where they start / stop
        sphinx_parts = []
        for idx, line in enumerate(lines):
            tag_match = tag_pat.search(line)
            directive_match = directive_pat.search(line)
            if tag_match:
                tag = tag_match.groups()[0]
                sphinx_parts.append({
                    'tag': tag, 'start_offset': idx,
                    'type': 'tag',
                })
            elif directive_match:
                tag = directive_match.groups()[0]
                sphinx_parts.append({
                    'tag': tag, 'start_offset': idx,
                    'type': 'directive',
                })

        prev_offset = len(lines)
        for part in sphinx_parts[::-1]:
            part['end_offset'] = prev_offset
            prev_offset = part['start_offset']

        docstr.sphinx_parts = sphinx_parts

        if 0:
            for line in lines:
                print(line)

    def find_tagged_lines(docstr, tag):
        for part in docstr.sphinx_parts[::-1]:
            if part['tag'] == tag:
                edit_slice = slice(part['start_offset'], part['end_offset'])
                return_section = docstr.lines[edit_slice]
                text = '\n'.join(return_section)
                found = {
                    'edit_slice': edit_slice,
                    'text': text,
                }
                yield found


def paragraph(text):
    r"""
    Wraps multi-line strings and restructures the text to remove all newlines,
    heading, trailing, and double spaces.

    Useful for writing log messages

    Args:
        text (str): typically a multiline string

    Returns:
        str: the reduced text block
    """
    import re
    out = re.sub(r'\s\s*', ' ', text).strip()
    return out


def create_doctest_figure(app, obj, name, lines):
    """
    The idea is that each doctest that produces a figure should generate that
    and then that figure should be part of the docs.
    """
    import xdoctest
    import sys
    import types
    if isinstance(obj, types.ModuleType):
        module = obj
    else:
        module = sys.modules[obj.__module__]
    # TODO: read settings from pyproject.toml?
    if '--show' not in sys.argv:
        sys.argv.append('--show')
    if '--nointeract' not in sys.argv:
        sys.argv.append('--nointeract')
    modpath = module.__file__

    # print(doctest.format_src())
    import pathlib
    # HACK: write to the srcdir
    doc_outdir = pathlib.Path(app.outdir)
    doc_srcdir = pathlib.Path(app.srcdir)
    doc_static_outdir = doc_outdir / '_static'
    doc_static_srcdir = doc_srcdir / '_static'
    src_fig_dpath = (doc_static_srcdir / 'images')
    src_fig_dpath.mkdir(exist_ok=True, parents=True)
    out_fig_dpath = (doc_static_outdir / 'images')
    out_fig_dpath.mkdir(exist_ok=True, parents=True)

    # fig_dpath = (doc_outdir / 'autofigs' / name).mkdir(exist_ok=True)

    fig_num = 1

    import kwplot
    kwplot.autompl(force='agg')
    plt = kwplot.autoplt()

    docstr = '\n'.join(lines)

    # TODO: The freeform parser does not work correctly here.
    # We need to parse out the sphinx (epdoc)? individual examples
    # so we can get different figures. But we can hack it for now.

    import re
    split_parts = re.split('({}\\s*\n)'.format(re.escape('.. rubric:: Example')), docstr)
    # split_parts = docstr.split('.. rubric:: Example')

    # import xdev
    # xdev.embed()

    def doctest_line_offsets(doctest):
        # Where the doctests starts and ends relative to the file
        start_line_offset = doctest.lineno - 1
        last_part = doctest._parts[-1]
        last_line_offset = start_line_offset + last_part.line_offset + last_part.n_lines - 1
        offsets = {
            'start': start_line_offset,
            'end': last_line_offset,
            'stop': last_line_offset + 1,
        }
        return offsets

    # from xdoctest import utils
    # part_lines = utils.add_line_numbers(docstr.split('\n'), n_digits=3, start=0)
    # print('\n'.join(part_lines))

    to_insert_fpaths = []
    curr_line_offset = 0
    for part in split_parts:
        num_lines = part.count('\n')

        doctests = list(xdoctest.core.parse_docstr_examples(
            part, modpath=modpath, callname=name,
            # style='google'
        ))
        # print(doctests)

        # doctests = list(xdoctest.core.parse_docstr_examples(
        #     docstr, modpath=modpath, callname=name))

        for doctest in doctests:
            if '--show' in part:
                ...
                # print('-- SHOW TEST---')/)
                # kwplot.close_figures()
                try:
                    import pytest  # NOQA
                except ImportError:
                    pass
                try:
                    from xdoctest.exceptions import Skipped
                except ImportError:  # nocover
                    # Define dummy skipped exception if pytest is not available
                    class Skipped(Exception):
                        pass
                try:
                    doctest.mode = 'native'
                    doctest.run(verbose=0, on_error='raise')
                    ...
                except Skipped:
                    print(f'Skip doctest={doctest}')
                except Exception as ex:
                    print(f'ex={ex}')
                    print(f'Error in doctest={doctest}')

                offsets = doctest_line_offsets(doctest)
                doctest_line_end = curr_line_offset + offsets['stop']
                insert_line_index = doctest_line_end

                figures = kwplot.all_figures()
                for fig in figures:
                    fig_num += 1
                    # path_name = path_sanatize(name)
                    path_name = (name).replace('.', '_')
                    fig_fpath = src_fig_dpath / f'fig_{path_name}_{fig_num:03d}.jpeg'
                    fig.savefig(fig_fpath)
                    print(f'Wrote figure: {fig_fpath}')
                    to_insert_fpaths.append({
                        'insert_line_index': insert_line_index,
                        'fpath': fig_fpath,
                    })

                for fig in figures:
                    plt.close(fig)
                # kwplot.close_figures(figures)

        curr_line_offset += (num_lines)

    # if len(doctests) > 1:
    #     doctests
    #     import xdev
    #     xdev.embed()

    INSERT_AT = 'end'
    INSERT_AT = 'inline'

    end_index = len(lines)
    # Reverse order for inserts
    import shutil
    for info in to_insert_fpaths[::-1]:
        src_abs_fpath = info['fpath']

        rel_to_static_fpath = src_abs_fpath.relative_to(doc_static_srcdir)
        # dst_abs_fpath = doc_static_outdir / rel_to_static_fpath
        # dst_abs_fpath.parent.mkdir(parents=True, exist_ok=True)

        rel_to_root_fpath = src_abs_fpath.relative_to(doc_srcdir)

        dst_abs_fpath1 = doc_outdir / rel_to_root_fpath
        dst_abs_fpath1.parent.mkdir(parents=True, exist_ok=True)
        shutil.copy(src_abs_fpath, dst_abs_fpath1)

        dst_abs_fpath2 = doc_outdir / rel_to_static_fpath
        dst_abs_fpath2.parent.mkdir(parents=True, exist_ok=True)
        shutil.copy(src_abs_fpath, dst_abs_fpath2)

        dst_abs_fpath3 = doc_srcdir / rel_to_static_fpath
        dst_abs_fpath3.parent.mkdir(parents=True, exist_ok=True)
        shutil.copy(src_abs_fpath, dst_abs_fpath3)

        if INSERT_AT == 'inline':
            # Try to insert after test
            insert_index = info['insert_line_index']
        elif INSERT_AT == 'end':
            insert_index = end_index
        else:
            raise KeyError(INSERT_AT)
        lines.insert(insert_index, '.. image:: {}'.format('..' / rel_to_root_fpath))
        # lines.insert(insert_index, '.. image:: {}'.format(rel_to_root_fpath))
        # lines.insert(insert_index, '.. image:: {}'.format(rel_to_static_fpath))
        lines.insert(insert_index, '')


def postprocess_hyperlinks(app, doctree, docname):
    """
    Extension to fixup hyperlinks.
    This should be connected to the Sphinx application's
    "autodoc-process-docstring" event.
    """
    # Your hyperlink postprocessing logic here
    from docutils import nodes
    import pathlib
    for node in doctree.traverse(nodes.reference):
        if 'refuri' in node.attributes:
            refuri = node.attributes['refuri']
            if '.rst' in refuri:
                if 'source' in node.document:
                    fpath = pathlib.Path(node.document['source'])
                    parent_dpath = fpath.parent
                    if (parent_dpath / refuri).exists():
                        node.attributes['refuri'] = refuri.replace('.rst', '.html')
                else:
                    raise AssertionError


def fix_rst_todo_section(lines):
    new_lines = []
    for line in lines:
        ...
    ...


def setup(app):
    import sphinx
    app : sphinx.application.Sphinx = app
    app.add_domain(PatchedPythonDomain, override=True)

    app.connect("doctree-resolved", postprocess_hyperlinks)

    docstring_processor = GoogleStyleDocstringProcessor()
    # https://stackoverflow.com/questions/26534184/can-sphinx-ignore-certain-tags-in-python-docstrings
    app.connect('autodoc-process-docstring', docstring_processor.process_docstring_callback)

    def copy(src, dst):
        import shutil
        print(f'Copy {src} -> {dst}')
        assert src.exists()
        if not dst.parent.exists():
            dst.parent.mkdir()
        shutil.copy(src, dst)

    ### Hack for kwcoco: TODO: figure out a way for the user to configure this.
    HACK_FOR_KWCOCO = 0
    if HACK_FOR_KWCOCO:
        import pathlib
        doc_outdir = pathlib.Path(app.outdir) / 'auto'
        doc_srcdir = pathlib.Path(app.srcdir) / 'auto'

        mod_dpath = doc_srcdir / '../../../kwcoco'

        src_fpath = (mod_dpath / 'coco_schema.json')
        copy(src_fpath, doc_outdir / src_fpath.name)
        copy(src_fpath, doc_srcdir / src_fpath.name)

        src_fpath = (mod_dpath / 'coco_schema_informal.rst')
        copy(src_fpath, doc_outdir / src_fpath.name)
        copy(src_fpath, doc_srcdir / src_fpath.name)
    return app