| Tor Norbye | 3a2425a | 2013-11-04 10:16:08 -0800 | [diff] [blame^] | 1 | """ |
| 2 | A Docutils_ interpreted text role for cross-API reference support. |
| 3 | |
| 4 | This module allows a Docutils_ document to refer to elements defined in |
| 5 | external API documentation. It is possible to refer to many external API |
| 6 | from the same document. |
| 7 | |
| 8 | Each API documentation is assigned a new interpreted text role: using such |
| 9 | interpreted text, an user can specify an object name inside an API |
| 10 | documentation. The system will convert such text into an url and generate a |
| 11 | reference to it. For example, if the API ``db`` is defined, being a database |
| 12 | package, then a certain method may be referred as:: |
| 13 | |
| 14 | :db:`Connection.cursor()` |
| 15 | |
| 16 | To define a new API, an *index file* must be provided. This file contains |
| 17 | a mapping from the object name to the URL part required to resolve such object. |
| 18 | |
| 19 | Index file |
| 20 | ---------- |
| 21 | |
| 22 | Each line in the the index file describes an object. |
| 23 | |
| 24 | Each line contains the fully qualified name of the object and the URL at which |
| 25 | the documentation is located. The fields are separated by a ``<tab>`` |
| 26 | character. |
| 27 | |
| 28 | The URL's in the file are relative from the documentation root: the system can |
| 29 | be configured to add a prefix in front of each returned URL. |
| 30 | |
| 31 | Allowed names |
| 32 | ------------- |
| 33 | |
| 34 | When a name is used in an API text role, it is split over any *separator*. |
| 35 | The separators defined are '``.``', '``::``', '``->``'. All the text from the |
| 36 | first noise char (neither a separator nor alphanumeric or '``_``') is |
| 37 | discarded. The same algorithm is applied when the index file is read. |
| 38 | |
| 39 | First the sequence of name parts is looked for in the provided index file. |
| 40 | If no matching name is found, a partial match against the trailing part of the |
| 41 | names in the index is performed. If no object is found, or if the trailing part |
| 42 | of the name may refer to many objects, a warning is issued and no reference |
| 43 | is created. |
| 44 | |
| 45 | Configuration |
| 46 | ------------- |
| 47 | |
| 48 | This module provides the class `ApiLinkReader` a replacement for the Docutils |
| 49 | standalone reader. Such reader specifies the settings required for the |
| 50 | API canonical roles configuration. The same command line options are exposed by |
| 51 | Epydoc. |
| 52 | |
| 53 | The script ``apirst2html.py`` is a frontend for the `ApiLinkReader` reader. |
| 54 | |
| 55 | API Linking Options:: |
| 56 | |
| 57 | --external-api=NAME |
| 58 | Define a new API document. A new interpreted text |
| 59 | role NAME will be added. |
| 60 | --external-api-file=NAME:FILENAME |
| 61 | Use records in FILENAME to resolve objects in the API |
| 62 | named NAME. |
| 63 | --external-api-root=NAME:STRING |
| 64 | Use STRING as prefix for the URL generated from the |
| 65 | API NAME. |
| 66 | |
| 67 | .. _Docutils: http://docutils.sourceforge.net/ |
| 68 | """ |
| 69 | |
| 70 | # $Id: xlink.py 1586 2007-03-14 01:53:42Z dvarrazzo $ |
| 71 | __version__ = "$Revision: 1586 $"[11:-2] |
| 72 | __author__ = "Daniele Varrazzo" |
| 73 | __copyright__ = "Copyright (C) 2007 by Daniele Varrazzo" |
| 74 | __docformat__ = 'reStructuredText en' |
| 75 | |
| 76 | import re |
| 77 | import sys |
| 78 | from optparse import OptionValueError |
| 79 | |
| 80 | from epydoc import log |
| 81 | |
| 82 | class UrlGenerator: |
| 83 | """ |
| 84 | Generate URL from an object name. |
| 85 | """ |
| 86 | class IndexAmbiguous(IndexError): |
| 87 | """ |
| 88 | The name looked for is ambiguous |
| 89 | """ |
| 90 | |
| 91 | def get_url(self, name): |
| 92 | """Look for a name and return the matching URL documentation. |
| 93 | |
| 94 | First look for a fully qualified name. If not found, try with partial |
| 95 | name. |
| 96 | |
| 97 | If no url exists for the given object, return `None`. |
| 98 | |
| 99 | :Parameters: |
| 100 | `name` : `str` |
| 101 | the name to look for |
| 102 | |
| 103 | :return: the URL that can be used to reach the `name` documentation. |
| 104 | `None` if no such URL exists. |
| 105 | :rtype: `str` |
| 106 | |
| 107 | :Exceptions: |
| 108 | - `IndexError`: no object found with `name` |
| 109 | - `DocUrlGenerator.IndexAmbiguous` : more than one object found with |
| 110 | a non-fully qualified name; notice that this is an ``IndexError`` |
| 111 | subclass |
| 112 | """ |
| 113 | raise NotImplementedError |
| 114 | |
| 115 | def get_canonical_name(self, name): |
| 116 | """ |
| 117 | Convert an object name into a canonical name. |
| 118 | |
| 119 | the canonical name of an object is a tuple of strings containing its |
| 120 | name fragments, splitted on any allowed separator ('``.``', '``::``', |
| 121 | '``->``'). |
| 122 | |
| 123 | Noise such parenthesis to indicate a function is discarded. |
| 124 | |
| 125 | :Parameters: |
| 126 | `name` : `str` |
| 127 | an object name, such as ``os.path.prefix()`` or ``lib::foo::bar`` |
| 128 | |
| 129 | :return: the fully qualified name such ``('os', 'path', 'prefix')`` and |
| 130 | ``('lib', 'foo', 'bar')`` |
| 131 | :rtype: `tuple` of `str` |
| 132 | """ |
| 133 | rv = [] |
| 134 | for m in self._SEP_RE.finditer(name): |
| 135 | groups = m.groups() |
| 136 | if groups[0] is not None: |
| 137 | rv.append(groups[0]) |
| 138 | elif groups[2] is not None: |
| 139 | break |
| 140 | |
| 141 | return tuple(rv) |
| 142 | |
| 143 | _SEP_RE = re.compile(r"""(?x) |
| 144 | # Tokenize the input into keyword, separator, noise |
| 145 | ([a-zA-Z0-9_]+) | # A keyword is a alphanum word |
| 146 | ( \. | \:\: | \-\> ) | # These are the allowed separators |
| 147 | (.) # If it doesn't fit, it's noise. |
| 148 | # Matching a single noise char is enough, because it |
| 149 | # is used to break the tokenization as soon as some noise |
| 150 | # is found. |
| 151 | """) |
| 152 | |
| 153 | |
| 154 | class VoidUrlGenerator(UrlGenerator): |
| 155 | """ |
| 156 | Don't actually know any url, but don't report any error. |
| 157 | |
| 158 | Useful if an index file is not available, but a document linking to it |
| 159 | is to be generated, and warnings are to be avoided. |
| 160 | |
| 161 | Don't report any object as missing, Don't return any url anyway. |
| 162 | """ |
| 163 | def get_url(self, name): |
| 164 | return None |
| 165 | |
| 166 | |
| 167 | class DocUrlGenerator(UrlGenerator): |
| 168 | """ |
| 169 | Read a *documentation index* and generate URL's for it. |
| 170 | """ |
| 171 | def __init__(self): |
| 172 | self._exact_matches = {} |
| 173 | """ |
| 174 | A map from an object fully qualified name to its URL. |
| 175 | |
| 176 | Values are both the name as tuple of fragments and as read from the |
| 177 | records (see `load_records()`), mostly to help `_partial_names` to |
| 178 | perform lookup for unambiguous names. |
| 179 | """ |
| 180 | |
| 181 | self._partial_names= {} |
| 182 | """ |
| 183 | A map from partial names to the fully qualified names they may refer. |
| 184 | |
| 185 | The keys are the possible left sub-tuples of fully qualified names, |
| 186 | the values are list of strings as provided by the index. |
| 187 | |
| 188 | If the list for a given tuple contains a single item, the partial |
| 189 | match is not ambuguous. In this case the string can be looked up in |
| 190 | `_exact_matches`. |
| 191 | |
| 192 | If the name fragment is ambiguous, a warning may be issued to the user. |
| 193 | The items can be used to provide an informative message to the user, |
| 194 | to help him qualifying the name in a unambiguous manner. |
| 195 | """ |
| 196 | |
| 197 | self.prefix = '' |
| 198 | """ |
| 199 | Prefix portion for the URL's returned by `get_url()`. |
| 200 | """ |
| 201 | |
| 202 | self._filename = None |
| 203 | """ |
| 204 | Not very important: only for logging. |
| 205 | """ |
| 206 | |
| 207 | def get_url(self, name): |
| 208 | cname = self.get_canonical_name(name) |
| 209 | url = self._exact_matches.get(cname, None) |
| 210 | if url is None: |
| 211 | |
| 212 | # go for a partial match |
| 213 | vals = self._partial_names.get(cname) |
| 214 | if vals is None: |
| 215 | raise IndexError( |
| 216 | "no object named '%s' found" % (name)) |
| 217 | |
| 218 | elif len(vals) == 1: |
| 219 | url = self._exact_matches[vals[0]] |
| 220 | |
| 221 | else: |
| 222 | raise self.IndexAmbiguous( |
| 223 | "found %d objects that '%s' may refer to: %s" |
| 224 | % (len(vals), name, ", ".join(["'%s'" % n for n in vals]))) |
| 225 | |
| 226 | return self.prefix + url |
| 227 | |
| 228 | #{ Content loading |
| 229 | # --------------- |
| 230 | |
| 231 | def clear(self): |
| 232 | """ |
| 233 | Clear the current class content. |
| 234 | """ |
| 235 | self._exact_matches.clear() |
| 236 | self._partial_names.clear() |
| 237 | |
| 238 | def load_index(self, f): |
| 239 | """ |
| 240 | Read the content of an index file. |
| 241 | |
| 242 | Populate the internal maps with the file content using `load_records()`. |
| 243 | |
| 244 | :Parameters: |
| 245 | f : `str` or file |
| 246 | a file name or file-like object fron which read the index. |
| 247 | """ |
| 248 | self._filename = str(f) |
| 249 | |
| 250 | if isinstance(f, basestring): |
| 251 | f = open(f) |
| 252 | |
| 253 | self.load_records(self._iter_tuples(f)) |
| 254 | |
| 255 | def _iter_tuples(self, f): |
| 256 | """Iterate on a file returning 2-tuples.""" |
| 257 | for nrow, row in enumerate(f): |
| 258 | # skip blank lines |
| 259 | row = row.rstrip() |
| 260 | if not row: continue |
| 261 | |
| 262 | rec = row.split('\t', 2) |
| 263 | if len(rec) == 2: |
| 264 | yield rec |
| 265 | else: |
| 266 | log.warning("invalid row in '%s' row %d: '%s'" |
| 267 | % (self._filename, nrow+1, row)) |
| 268 | |
| 269 | def load_records(self, records): |
| 270 | """ |
| 271 | Read a sequence of pairs name -> url and populate the internal maps. |
| 272 | |
| 273 | :Parameters: |
| 274 | records : iterable |
| 275 | the sequence of pairs (*name*, *url*) to add to the maps. |
| 276 | """ |
| 277 | for name, url in records: |
| 278 | cname = self.get_canonical_name(name) |
| 279 | if not cname: |
| 280 | log.warning("invalid object name in '%s': '%s'" |
| 281 | % (self._filename, name)) |
| 282 | continue |
| 283 | |
| 284 | # discard duplicates |
| 285 | if name in self._exact_matches: |
| 286 | continue |
| 287 | |
| 288 | self._exact_matches[name] = url |
| 289 | self._exact_matches[cname] = url |
| 290 | |
| 291 | # Link the different ambiguous fragments to the url |
| 292 | for i in range(1, len(cname)): |
| 293 | self._partial_names.setdefault(cname[i:], []).append(name) |
| 294 | |
| 295 | #{ API register |
| 296 | # ------------ |
| 297 | |
| 298 | api_register = {} |
| 299 | """ |
| 300 | Mapping from the API name to the `UrlGenerator` to be used. |
| 301 | |
| 302 | Use `register_api()` to add new generators to the register. |
| 303 | """ |
| 304 | |
| 305 | def register_api(name, generator=None): |
| 306 | """Register the API `name` into the `api_register`. |
| 307 | |
| 308 | A registered API will be available to the markup as the interpreted text |
| 309 | role ``name``. |
| 310 | |
| 311 | If a `generator` is not provided, register a `VoidUrlGenerator` instance: |
| 312 | in this case no warning will be issued for missing names, but no URL will |
| 313 | be generated and all the dotted names will simply be rendered as literals. |
| 314 | |
| 315 | :Parameters: |
| 316 | `name` : `str` |
| 317 | the name of the generator to be registered |
| 318 | `generator` : `UrlGenerator` |
| 319 | the object to register to translate names into URLs. |
| 320 | """ |
| 321 | if generator is None: |
| 322 | generator = VoidUrlGenerator() |
| 323 | |
| 324 | api_register[name] = generator |
| 325 | |
| 326 | def set_api_file(name, file): |
| 327 | """Set an URL generator populated with data from `file`. |
| 328 | |
| 329 | Use `file` to populate a new `DocUrlGenerator` instance and register it |
| 330 | as `name`. |
| 331 | |
| 332 | :Parameters: |
| 333 | `name` : `str` |
| 334 | the name of the generator to be registered |
| 335 | `file` : `str` or file |
| 336 | the file to parse populate the URL generator |
| 337 | """ |
| 338 | generator = DocUrlGenerator() |
| 339 | generator.load_index(file) |
| 340 | register_api(name, generator) |
| 341 | |
| 342 | def set_api_root(name, prefix): |
| 343 | """Set the root for the URLs returned by a registered URL generator. |
| 344 | |
| 345 | :Parameters: |
| 346 | `name` : `str` |
| 347 | the name of the generator to be updated |
| 348 | `prefix` : `str` |
| 349 | the prefix for the generated URL's |
| 350 | |
| 351 | :Exceptions: |
| 352 | - `IndexError`: `name` is not a registered generator |
| 353 | """ |
| 354 | api_register[name].prefix = prefix |
| 355 | |
| 356 | ###################################################################### |
| 357 | # Below this point requires docutils. |
| 358 | try: |
| 359 | import docutils |
| 360 | from docutils.parsers.rst import roles |
| 361 | from docutils import nodes, utils |
| 362 | from docutils.readers.standalone import Reader |
| 363 | except ImportError: |
| 364 | docutils = roles = nodes = utils = None |
| 365 | class Reader: settings_spec = () |
| 366 | |
| 367 | def create_api_role(name, problematic): |
| 368 | """ |
| 369 | Create and register a new role to create links for an API documentation. |
| 370 | |
| 371 | Create a role called `name`, which will use the URL resolver registered as |
| 372 | ``name`` in `api_register` to create a link for an object. |
| 373 | |
| 374 | :Parameters: |
| 375 | `name` : `str` |
| 376 | name of the role to create. |
| 377 | `problematic` : `bool` |
| 378 | if True, the registered role will create problematic nodes in |
| 379 | case of failed references. If False, a warning will be raised |
| 380 | anyway, but the output will appear as an ordinary literal. |
| 381 | """ |
| 382 | def resolve_api_name(n, rawtext, text, lineno, inliner, |
| 383 | options={}, content=[]): |
| 384 | if docutils is None: |
| 385 | raise AssertionError('requires docutils') |
| 386 | |
| 387 | # node in monotype font |
| 388 | text = utils.unescape(text) |
| 389 | node = nodes.literal(rawtext, text, **options) |
| 390 | |
| 391 | # Get the resolver from the register and create an url from it. |
| 392 | try: |
| 393 | url = api_register[name].get_url(text) |
| 394 | except IndexError, exc: |
| 395 | msg = inliner.reporter.warning(str(exc), line=lineno) |
| 396 | if problematic: |
| 397 | prb = inliner.problematic(rawtext, text, msg) |
| 398 | return [prb], [msg] |
| 399 | else: |
| 400 | return [node], [] |
| 401 | |
| 402 | if url is not None: |
| 403 | node = nodes.reference(rawtext, '', node, refuri=url, **options) |
| 404 | return [node], [] |
| 405 | |
| 406 | roles.register_local_role(name, resolve_api_name) |
| 407 | |
| 408 | |
| 409 | #{ Command line parsing |
| 410 | # -------------------- |
| 411 | |
| 412 | |
| 413 | def split_name(value): |
| 414 | """ |
| 415 | Split an option in form ``NAME:VALUE`` and check if ``NAME`` exists. |
| 416 | """ |
| 417 | parts = value.split(':', 1) |
| 418 | if len(parts) != 2: |
| 419 | raise OptionValueError( |
| 420 | "option value must be specified as NAME:VALUE; got '%s' instead" |
| 421 | % value) |
| 422 | |
| 423 | name, val = parts |
| 424 | |
| 425 | if name not in api_register: |
| 426 | raise OptionValueError( |
| 427 | "the name '%s' has not been registered; use --external-api" |
| 428 | % name) |
| 429 | |
| 430 | return (name, val) |
| 431 | |
| 432 | |
| 433 | class ApiLinkReader(Reader): |
| 434 | """ |
| 435 | A Docutils standalone reader allowing external documentation links. |
| 436 | |
| 437 | The reader configure the url resolvers at the time `read()` is invoked the |
| 438 | first time. |
| 439 | """ |
| 440 | #: The option parser configuration. |
| 441 | settings_spec = ( |
| 442 | 'API Linking Options', |
| 443 | None, |
| 444 | (( |
| 445 | 'Define a new API document. A new interpreted text role NAME will be ' |
| 446 | 'added.', |
| 447 | ['--external-api'], |
| 448 | {'metavar': 'NAME', 'action': 'append'} |
| 449 | ), ( |
| 450 | 'Use records in FILENAME to resolve objects in the API named NAME.', |
| 451 | ['--external-api-file'], |
| 452 | {'metavar': 'NAME:FILENAME', 'action': 'append'} |
| 453 | ), ( |
| 454 | 'Use STRING as prefix for the URL generated from the API NAME.', |
| 455 | ['--external-api-root'], |
| 456 | {'metavar': 'NAME:STRING', 'action': 'append'} |
| 457 | ),)) + Reader.settings_spec |
| 458 | |
| 459 | def __init__(self, *args, **kwargs): |
| 460 | if docutils is None: |
| 461 | raise AssertionError('requires docutils') |
| 462 | Reader.__init__(self, *args, **kwargs) |
| 463 | |
| 464 | def read(self, source, parser, settings): |
| 465 | self.read_configuration(settings, problematic=True) |
| 466 | return Reader.read(self, source, parser, settings) |
| 467 | |
| 468 | def read_configuration(self, settings, problematic=True): |
| 469 | """ |
| 470 | Read the configuration for the configured URL resolver. |
| 471 | |
| 472 | Register a new role for each configured API. |
| 473 | |
| 474 | :Parameters: |
| 475 | `settings` |
| 476 | the settings structure containing the options to read. |
| 477 | `problematic` : `bool` |
| 478 | if True, the registered role will create problematic nodes in |
| 479 | case of failed references. If False, a warning will be raised |
| 480 | anyway, but the output will appear as an ordinary literal. |
| 481 | """ |
| 482 | # Read config only once |
| 483 | if hasattr(self, '_conf'): |
| 484 | return |
| 485 | ApiLinkReader._conf = True |
| 486 | |
| 487 | try: |
| 488 | if settings.external_api is not None: |
| 489 | for name in settings.external_api: |
| 490 | register_api(name) |
| 491 | create_api_role(name, problematic=problematic) |
| 492 | |
| 493 | if settings.external_api_file is not None: |
| 494 | for name, file in map(split_name, settings.external_api_file): |
| 495 | set_api_file(name, file) |
| 496 | |
| 497 | if settings.external_api_root is not None: |
| 498 | for name, root in map(split_name, settings.external_api_root): |
| 499 | set_api_root(name, root) |
| 500 | |
| 501 | except OptionValueError, exc: |
| 502 | print >>sys.stderr, "%s: %s" % (exc.__class__.__name__, exc) |
| 503 | sys.exit(2) |
| 504 | |
| 505 | read_configuration = classmethod(read_configuration) |