-
Notifications
You must be signed in to change notification settings - Fork 463
/
links.py
383 lines (319 loc) · 14.3 KB
/
links.py
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
import subprocess
from pathlib import Path
import re
from urllib.request import urlopen
from urllib.error import URLError
import shutil
from docutils import nodes
from docutils.parsers.rst import directives
from docutils.parsers.rst.states import Struct
from docutils.utils import unescape
from sphinx.util.docutils import SphinxDirective
from sphinx.util.typing import OptionSpec
from sphinx.util import logging
import fitz # PyMuPDF
gh_url_base = 'https://github.com'
omg_url_base = 'https://issues.omg.org'
omg_spec_section_re = re.compile(r'^(\d(\.\d+)*) (.*)')
logger = logging.getLogger(__name__)
docs_path = Path(__file__).parent.parent
def get_config(inliner):
return inliner.document.settings.env.app.config
def get_commitish(config):
if config.github_links_commitish is None:
if config.github_links_release_tag is not None:
config.github_links_commitish = config.github_links_release_tag
else:
try:
config.github_links_commitish = str(subprocess.check_output(
['git', 'rev-parse', 'HEAD']), "utf-8").strip()
except:
config.github_links_commitish = 'master'
return config.github_links_commitish
def rst_error(rawtext, text, lineno, inliner, message, *fmtargs, **fmtkwargs):
error = inliner.reporter.error(message.format(*fmtargs, **fmtkwargs), line=lineno)
return [inliner.problematic(text, rawtext, error)], [error]
title_target_re = re.compile(r'^(.+?)\s*<(.*?)>$')
def process_title_target(text, implied_title=None):
m = title_target_re.match(text)
if m:
return True, m[1], m[2]
return False, text if implied_title is None else implied_title, text
def parse_rst(parent, text, lineno, inliner):
context = Struct(
document=inliner.document,
reporter=inliner.reporter,
language=inliner.language)
processed, messages = inliner.parse(unescape(text), lineno, context, parent)
parent += processed
return messages
def text_node(rawtext, lineno, text, options):
return ([nodes.inline(rawtext, text, **options)], [])
def link_node(rawtext, lineno, inliner, title, parse_title, url, options):
node = nodes.reference(rawtext, '' if parse_title else title, refuri=url, **options)
messages = []
if parse_title:
messages = parse_rst(node, title, lineno, inliner)
return ([node], messages)
def append(all_values, values):
if all_values:
all_values[0].extend(values[0])
all_values[1].extend(values[1])
else:
all_values.append(values[0])
all_values.append(values[1])
ghfile_arg_re = re.compile(r'^([^#]+)(#[^#]+)?$')
def ghfile_role(name, rawtext, text, lineno, inliner, options={}, content=[]):
config = get_config(inliner)
explicit_title, title, target = process_title_target(text)
# Seperate path and possible URL fragment
m = ghfile_arg_re.match(target)
if not m:
return rst_error(rawtext, text, lineno, inliner,
'{} is an invalid target', repr(target))
path = m.group(1)
fragment = m.group(2) if m.group(2) is not None else ''
# Check if the path exists locally
local_path = Path(config.github_links_root_path) / path
if not local_path.exists():
return rst_error(rawtext, text, lineno, inliner,
'"{}" doesn\'t exist. Checked for existence of "{}"',
path, str(local_path)),
# Create the main link
kind = 'tree' if local_path.is_dir() else 'blob'
url = '/'.join([gh_url_base, config.github_links_repo, kind, get_commitish(config), path]) + fragment
rv = []
main_link = link_node(rawtext, lineno, inliner,
title if explicit_title else '', explicit_title, url, options)
if not explicit_title:
main_link[0][0].append(nodes.literal(rawtext, text, **options))
append(rv, main_link)
# Create a secondary link to preview HTML files
if path.endswith('.html'):
append(rv, text_node(rawtext, lineno, ' ', options))
append(rv, link_node(rawtext, lineno, inliner,
'(View as HTML)', False,
'https://htmlpreview.github.io/?' + url,
options))
return rv
# Turns :ghissue:`213` into the equivalent of:
# `Issue #213 <https://github.com/OpenDDS/OpenDDS/issues/213>`_
def ghissue_role(name, rawtext, text, lineno, inliner, options={}, content=[]):
config = get_config(inliner)
explicit_title, title, target = process_title_target(
text, 'Issue #{}'.format(text))
return link_node(rawtext, lineno, inliner,
title, explicit_title,
'{}/{}/issues/{}'.format(gh_url_base, config.github_links_repo, target),
options)
# Turns :ghpr:`1` into the equivalent of:
# `PR #1 <https://github.com/OpenDDS/OpenDDS/pull/1>`_
def ghpr_role(name, rawtext, text, lineno, inliner, options={}, content=[]):
config = get_config(inliner)
explicit_title, title, target = process_title_target(
text, 'PR #{}'.format(text))
return link_node(rawtext, lineno, inliner,
title, explicit_title,
'{}/{}/pull/{}'.format(gh_url_base, config.github_links_repo, target),
options)
# If this is a release, turns :ghrelease:`Release Text` into "Release Text"
# that is a link to the GitHub release page to the release. If it's not a
# release then it turns into an invalid link. Because of this, the paragraph
# this is part of should be conditional.
def ghrelease_role(name, rawtext, text, lineno, inliner, options={}, content=[]):
config = get_config(inliner)
explicit_title, title, target = process_title_target(text)
if not explicit_title and config.github_links_release_tag is None:
url = ''
title = '(Invalid Release Link)'
else:
url = '{}/{}/releases/tag/{}'.format(
gh_url_base, config.github_links_repo,
target if explicit_title else config.github_links_release_tag)
node = nodes.reference(rawtext, title, refuri=url, **options)
return ([node], [])
def omgissue_role(name, rawtext, text, lineno, inliner, options={}, content=[]):
explicit_title, title, target = process_title_target(
text, 'OMG Issue {}'.format(text))
rv = []
append(rv, link_node(rawtext, lineno, inliner,
title, explicit_title,
'{}/issues/{}'.format(omg_url_base, target),
options))
append(rv, text_node(rawtext, lineno, ' ', options))
append(rv, link_node(rawtext, lineno, inliner,
'(Member Link)', False,
'{}/browse/{}'.format(omg_url_base, target),
options))
return rv
def add_omg_spec(app, slug, version, our_name=None, display_name=None):
'''To be used in conf.py to declare sepcs based on links like https://www.omg.org/spec/DDS/1.4.
slug is the OMG name in their URL.
version must also match the URL.
our_name is the name to use with :omgspec:.
display_name is the name of the spec to be used in output.
'''
our_name = slug.lower() if our_name is None else our_name
omg_specs = app.config.omg_specs
if our_name in omg_specs:
raise KeyError('Already a spec named ' + our_name)
display_name = slug.replace('-', ' ') if display_name is None else display_name
# Get the PDF if we don't have it
dir_path = docs_path / Path('_build') / 'omg-specs'
dir_path.mkdir(parents=True, exist_ok=True)
pdf_path = dir_path / '{}-{}.pdf'.format(slug, version)
url = 'https://www.omg.org/spec/{}/{}'.format(slug, version)
pdf_url = url + '/PDF'
if not pdf_path.is_file():
logger.info('Downloading spec %s from %s', our_name, pdf_url)
try:
with urlopen(pdf_url) as res, pdf_path.open('wb') as pdf_file:
shutil.copyfileobj(res, pdf_file)
except Exception as e:
logger.warning("Couldn't download pdf %s: %s", pdf_url, repr(e))
pdf_path.unlink(missing_ok=True)
pdf_path = None
# Process PDF's Table of Contents
root = dict(subsections=None)
sections_by_number = {}
sections_by_title = {}
if pdf_path:
doc = fitz.open(pdf_path)
root = dict(subsections=[])
section_stack = []
last_section = root
# See https://pymupdf.readthedocs.io/en/latest/document.html#Document.get_toc
for level, title, page, dest in doc.get_toc(simple=False):
assert page >= 1
# We only have level numbers, so we have to recreate the structure
# of the sections by using last_section and section_stack to keep
# track what the different levels mean.
if level > len(section_stack):
section_stack.append(last_section)
elif level < len(section_stack):
del section_stack[level - len(section_stack):]
kind = dest['kind']
# PDFs have two kinds of internal links. One is named and the other
# is page and coordinate based.
# See https://pdfobject.com/pdf/pdf_open_parameters_acro8.pdf for URL syntax
if kind == fitz.LINK_GOTO:
loc = 'page={}&view=FitH,{}'.format(page, dest['to'].y)
elif kind == fitz.LINK_NAMED:
loc = dest['name']
else:
continue
# Sections can be referenced by section number, which is preferred
# or by part of or the whole title. See omgspec_role for why.
section = dict(title=title, loc=loc, subsections=[], level=level)
section_stack[-1]['subsections'].append(section)
last_section = section
m = omg_spec_section_re.match(title)
if m:
sections_by_number[m.group(1)] = section
sections_by_title[title] = section
omg_specs[our_name] = dict(
display_name=display_name,
url=url,
pdf_url=pdf_url,
version=version,
sections=root['subsections'],
sections_by_number=sections_by_number,
sections_by_title=sections_by_title,
)
def section_link(spec, section):
return spec['pdf_url'] + '#' + section['loc']
def omgspec_role(name, rawtext, text, lineno, inliner, options={}, content=[]):
config = get_config(inliner)
explicit_title, title, target = process_title_target(text)
args = target.split(':', 1)
if len(args) == 1:
spec_name = args[0]
section_key = None
elif len(args) == 2:
spec_name = args[0]
section_key = args[1]
else:
return rst_error(rawtext, text, lineno, inliner,
'omgspec target must be of the form SPEC[:SECTION], not {}', repr(target))
spec = config.omg_specs.get(spec_name)
if spec is None:
return rst_error(rawtext, text, lineno, inliner,
'{} is not a valid omgspec spec name, must be one of: {}',
repr(spec_name), ', '.join(config.omg_specs.keys()))
section = None
if section_key is None or spec['sections'] is None:
# Either no section was specified or we couldn't download the PDF for
# some reason.
url = spec['url']
else:
# Here we check it as a section number first, then as a partial or
# whole title. The whole title includes the section number, so we could
# just check the title, but doing that can match part of an earlier
# section number/title. Ex: 1.2.3 would match 1.1.1.2.3
section = spec['sections_by_number'].get(section_key)
if section is None:
for section_title, sect in spec['sections_by_title'].items():
if section_key in section_title:
section = sect
break
if section is None:
return rst_error(rawtext, text, lineno, inliner,
'{} is not a valid section in the {} spec', repr(section_key), spec_name)
url = section_link(spec, section)
if not explicit_title:
title = '{display_name} v{version}'.format(**spec)
if section_key is not None:
if section is None:
# If we got here then we couldn't download the PDF for some
# reason, so just use the target/section_key in the title.
title += ' ' + section_key
else:
title += ' ' + section['title']
return link_node(rawtext, lineno, inliner, title, explicit_title, url, options)
class OmgSpecsDirective(SphinxDirective):
option_spec: OptionSpec = {
'debug-links': directives.flag,
}
def spec_sections(self, spec, node, sections):
if sections is None:
node += nodes.inline('', '(No section info, PDF download failed)')
else:
section_list = nodes.bullet_list()
for section in sections:
section_node = nodes.list_item()
p = nodes.paragraph()
p += nodes.reference('', section['title'], refuri=section_link(spec, section))
p += nodes.inline('', ' ({})'.format(section['level']))
self.spec_sections(spec, p, section['subsections'])
section_node += p
section_list += section_node
node += section_list
def run(self):
specs_node = nodes.bullet_list()
for spec_name, spec in self.env.app.config.omg_specs.items():
spec_node = nodes.list_item()
p = nodes.paragraph()
p += nodes.reference('',
spec['display_name'] + ' ' + spec['version'], refuri=spec['url'])
p += nodes.inline('', ' (')
p += nodes.literal('', spec_name)
p += nodes.inline('', ')')
spec_node += p
if 'debug-links' in self.options:
self.spec_sections(spec, spec_node, spec['sections'])
specs_node += spec_node
return [specs_node]
def setup(app):
app.add_config_value('github_links_repo', None, 'env', types=[str])
app.add_config_value('github_links_commitish', None, 'env', types=[str])
app.add_config_value('github_links_release_tag', None, 'env', types=[str])
app.add_config_value('github_links_root_path', None, 'env', types=[str])
app.add_role('ghfile', ghfile_role)
app.add_role('ghissue', ghissue_role)
app.add_role('ghpr', ghpr_role)
app.add_role('ghrelease', ghrelease_role)
app.add_config_value('omg_specs', {}, 'env', types=[dict])
app.add_role('omgissue', omgissue_role)
app.add_role('omgspec', omgspec_role)
app.add_directive("omgspecs", OmgSpecsDirective)
# vim: expandtab:ts=4:sw=4