├── .editorconfig ├── .gitignore ├── .travis.yml ├── AUTHORS ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── pycco ├── __init__.py ├── compat.py ├── generate_index.py ├── languages.py └── main.py ├── pycco_resources └── __init__.py ├── requirements.test.txt ├── requirements.txt ├── setup.py ├── tests ├── __init__.py └── test_pycco.py └── tox.ini /.editorconfig: -------------------------------------------------------------------------------- 1 | ; This file is for unifying the coding style for different editors and IDEs. 2 | ; More information at http://EditorConfig.org 3 | 4 | root = true 5 | 6 | [*] 7 | end_of_line = LF 8 | 9 | [*.py] 10 | indent_style = space 11 | indent_size = 4 12 | 13 | [*.css] 14 | indent_style = space 15 | indent_size = 2 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .coverage 2 | *.pyc 3 | /Pycco.egg-info 4 | build/* 5 | dist/* 6 | docs/* 7 | /tags 8 | 9 | .cache 10 | .hypothesis 11 | .ropeproject 12 | 13 | .DS_Store 14 | 15 | .tox -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: python 3 | python: 4 | - '2.7' 5 | - '3.6' 6 | install: 7 | - 'pip install -r requirements.txt' 8 | - 'pip install -r requirements.test.txt' 9 | script: 10 | - 'py.test --cov=pycco tests/' 11 | - 'python -m pycco.main pycco/main.py' 12 | after_success: 13 | - coveralls 14 | matrix: 15 | include: 16 | - python: 2.7 17 | env: TOXENV=py27 18 | install: pip install tox 19 | script: tox 20 | - python: 3.6 21 | env: TOXENV=py36 22 | install: pip install tox 23 | script: tox 24 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | Alexis Metaireau 2 | Anders Bergh 3 | Antti Kaihola 4 | Christopher Gateley 5 | Jack Miller 6 | Morgan Goose 7 | Nick Fitzgerald 8 | Steffen Kampmann 9 | Zach Smith 10 | goosemo 11 | khamidou 12 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Pycco 2 | 3 | [Zach Smith](http://zdsmith.com) is the current maintainer of this project. 4 | 5 | ## Help Us Out 6 | 7 | Feel free to contribute by opening a pull request on this project's [GitHub repo](https://github.com/pycco-docs/pycco). All requests with documented and tested code will be gladly reviewed. 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2010 Nick Fitzgerald 2 | Copyright (c) 2016 Zachary Smith 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in 12 | all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 20 | THE SOFTWARE. 21 | 22 | Parts of Pycco are taken from Docco, see http://github.com/jashkenas/docco for 23 | more information. 24 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ``` 2 | 888888b. 3 | 888 Y88b 4 | 888 888 5 | 888 d88P 888 888 .d8888b .d8888b .d88b. 6 | 8888888P" 888 888 d88P" d88P" d88""88b 7 | 888 888 888 888 888 888 888 8 | 888 Y88b 888 Y88b. Y88b. Y88..88P 9 | 888 "Y88888 "Y8888P "Y8888P "Y88P" 10 | 888 11 | Y8b d88P 12 | "Y88P" 13 | ``` 14 | 15 | Pycco is a Python port of Docco: the original quick-and-dirty, hundred-line- 16 | long, literate-programming-style documentation generator. For more information, 17 | see: 18 | 19 | https://pycco-docs.github.io/pycco/ 20 | 21 | Others: 22 | 23 | CoffeeScript (Original) - http://jashkenas.github.com/docco/ 24 | 25 | Ruby - http://rtomayko.github.com/rocco/ 26 | 27 | Sh - http://rtomayko.github.com/shocco/ 28 | 29 | [![Build Status](https://travis-ci.org/pycco-docs/pycco.svg?branch=master)](https://travis-ci.org/pycco-docs/pycco) 30 | [![Coverage Status](https://coveralls.io/repos/pycco-docs/pycco/badge.svg?branch=master&service=github)](https://coveralls.io/github/pycco-docs/pycco?branch=master) 31 | -------------------------------------------------------------------------------- /pycco/__init__.py: -------------------------------------------------------------------------------- 1 | from .main import * # noqa 2 | 3 | __all__ = ("process",) 4 | -------------------------------------------------------------------------------- /pycco/compat.py: -------------------------------------------------------------------------------- 1 | try: 2 | pycco_unichr = unichr 3 | except NameError: 4 | pycco_unichr = chr 5 | 6 | 7 | def compat_items(d): 8 | try: 9 | return d.iteritems() 10 | except AttributeError: 11 | return d.items() 12 | -------------------------------------------------------------------------------- /pycco/generate_index.py: -------------------------------------------------------------------------------- 1 | """ 2 | This is the module responsible for automatically generating an HTML index of 3 | all documentation files generated by Pycco. 4 | """ 5 | import re 6 | from os import path 7 | 8 | from pycco.compat import compat_items 9 | from pycco_resources import pycco_template 10 | 11 | 12 | __all__ = ('generate_index',) 13 | 14 | 15 | def build_tree(file_paths, outdir): 16 | tree = {} 17 | for file_path in file_paths: 18 | entry = { 19 | 'path': file_path, 20 | 'relpath': path.relpath(file_path, outdir) 21 | } 22 | path_steps = entry['relpath'].split(path.sep) 23 | add_file(entry, path_steps, tree) 24 | 25 | return tree 26 | 27 | 28 | def add_file(entry, path_steps, tree): 29 | """ 30 | :param entry: A dictionary containing a path to a documentation file, and a 31 | relative path to the same file. 32 | :param path_steps: A list of steps in a file path to look within. 33 | """ 34 | node, subpath = path_steps[0], path_steps[1:] 35 | if node not in tree: 36 | tree[node] = {} 37 | 38 | if subpath: 39 | add_file(entry, subpath, tree[node]) 40 | 41 | else: 42 | tree[node]['entry'] = entry 43 | 44 | 45 | def generate_tree_html(tree): 46 | """ 47 | Given a tree representing HTML file paths, return an HTML table plotting 48 | those paths. 49 | """ 50 | items = [] 51 | for node, subtree in sorted(compat_items(tree)): 52 | if 'entry' in subtree: 53 | html = u'
  • {}
  • '.format(subtree['entry']['relpath'], node) 54 | else: 55 | html = u'
    {}
    '.format( 56 | node, generate_tree_html(subtree) 57 | ) 58 | 59 | items.append(html) 60 | 61 | return ''.join(items) 62 | 63 | 64 | def generate_index(files, outdir): 65 | """ 66 | Given a list of generated documentation files, generate HTML to display 67 | index of all files. 68 | """ 69 | tree = build_tree(files, outdir) 70 | 71 | rendered = pycco_template({ 72 | "title": 'Index', 73 | "stylesheet": 'pycco.css', 74 | "sections": {'docs_html': generate_tree_html(tree)}, 75 | "source": '', 76 | }) 77 | 78 | return re.sub(r"__DOUBLE_OPEN_STACHE__", "{{", rendered).encode("utf-8") 79 | -------------------------------------------------------------------------------- /pycco/languages.py: -------------------------------------------------------------------------------- 1 | """ 2 | A list of the languages that Pycco supports, mapping the file extension to 3 | the name of the Pygments lexer and the symbol that indicates a comment. To 4 | add another language to Pycco's repertoire, add it here. 5 | """ 6 | 7 | __all__ = ("supported_languages",) 8 | 9 | HASH = "#" 10 | SLASH_STAR = "/*" 11 | STAR_SLASH = "*/" 12 | SLASH_SLASH = "//" 13 | DASH_DASH = "--" 14 | TRIPLE_QUOTE = '"""' 15 | 16 | def lang(name, comment_symbol, multistart=None, multiend=None): 17 | """ 18 | Generate a language entry dictionary, given a name and comment symbol and 19 | optional start/end strings for multiline comments. 20 | """ 21 | result = { 22 | "name": name, 23 | "comment_symbol": comment_symbol 24 | } 25 | if multistart is not None and multiend is not None: 26 | result.update(multistart=multistart, multiend=multiend) 27 | return result 28 | 29 | 30 | c_lang = lang("c", SLASH_SLASH, SLASH_STAR, STAR_SLASH) 31 | 32 | supported_languages = { 33 | ".coffee": lang("coffee-script", HASH, "###", "###"), 34 | 35 | ".pl": lang("perl", HASH), 36 | 37 | ".sql": lang("sql", DASH_DASH, SLASH_STAR, STAR_SLASH), 38 | 39 | ".sh": lang("bash", HASH), 40 | 41 | ".c": c_lang, 42 | 43 | ".h": c_lang, 44 | 45 | ".cl": c_lang, 46 | 47 | ".cpp": lang("cpp", SLASH_SLASH), 48 | 49 | ".js": lang("javascript", SLASH_SLASH, SLASH_STAR, STAR_SLASH), 50 | 51 | ".rb": lang("ruby", HASH, "=begin", "=end"), 52 | 53 | ".py": lang("python", HASH, TRIPLE_QUOTE, TRIPLE_QUOTE), 54 | 55 | ".pyx": lang("cython", HASH, TRIPLE_QUOTE, TRIPLE_QUOTE), 56 | 57 | ".scm": lang("scheme", ";;", "#|", "|#"), 58 | 59 | ".lua": lang("lua", DASH_DASH, "--[[", "--]]"), 60 | 61 | ".erl": lang("erlang", "%%"), 62 | 63 | ".tcl": lang("tcl", HASH), 64 | 65 | ".hs": lang("haskell", DASH_DASH, "{-", "-}"), 66 | 67 | ".r": lang("r", HASH), 68 | ".R": lang("r", HASH), 69 | 70 | ".jl": lang("julia", HASH, "#=", "=#"), 71 | 72 | ".m": lang("matlab", "%", "%{", "%}"), 73 | 74 | ".do": lang("stata", SLASH_SLASH, SLASH_STAR, STAR_SLASH) 75 | 76 | } 77 | -------------------------------------------------------------------------------- /pycco/main.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """ 3 | "**Pycco**" is a Python port of [Docco](http://jashkenas.github.com/docco/): 4 | the original quick-and-dirty, hundred-line-long, literate-programming-style 5 | documentation generator. It produces HTML that displays your comments alongside 6 | your code. Comments are passed through [Markdown][markdown] and 7 | [SmartyPants][smartypants][^extensions], while code is passed through 8 | [Pygments](http://pygments.org/) for syntax highlighting. 9 | 10 | [markdown]: http://daringfireball.net/projects/markdown/syntax 11 | [smartypants]: https://python-markdown.github.io/extensions/footnotes/ 12 | 13 | [^extensions]: Three extensions to Markdown are available: 14 | 15 | 1. [SmartyPants][smarty] 16 | 2. [Fenced code blocks][fences] 17 | 3. [Footnotes][footnotes] 18 | 19 | [smarty]: https://python-markdown.github.io/extensions/smarty/ 20 | [fences]: https://python-markdown.github.io/extensions/fenced_code_blocks/ 21 | [footnotes]: https://python-markdown.github.io/extensions/footnotes/ 22 | 23 | This page is the result of running Pycco against its own source file. 24 | 25 | If you install Pycco, you can run it from the command-line: 26 | 27 | pycco src/*.py 28 | 29 | This will generate linked HTML documentation for the named source files, 30 | saving it into a `docs` folder by default. 31 | 32 | The [source for Pycco](https://github.com/pycco-docs/pycco) is available on GitHub, 33 | and released under the MIT license. 34 | 35 | To install Pycco, simply 36 | 37 | pip install pycco 38 | 39 | Or, to install the latest source 40 | 41 | git clone git://github.com/pycco-docs/pycco.git 42 | cd pycco 43 | python setup.py install 44 | """ 45 | 46 | from __future__ import absolute_import, print_function 47 | 48 | # Import our external dependencies. 49 | import argparse 50 | import os 51 | import re 52 | import sys 53 | import time 54 | from os import path 55 | 56 | import pygments 57 | from pygments import formatters, lexers 58 | 59 | from markdown import markdown 60 | from pycco.generate_index import generate_index 61 | from pycco.languages import supported_languages 62 | from pycco_resources import css as pycco_css 63 | # This module contains all of our static resources. 64 | from pycco_resources import pycco_template 65 | 66 | # === Main Documentation Generation Functions === 67 | 68 | 69 | def generate_documentation(source, outdir=None, preserve_paths=True, 70 | language=None, encoding="utf8"): 71 | """ 72 | Generate the documentation for a source file by reading it in, splitting it 73 | up into comment/code sections, highlighting them for the appropriate 74 | language, and merging them into an HTML template. 75 | """ 76 | 77 | if not outdir: 78 | raise TypeError("Missing the required 'outdir' keyword argument.") 79 | code = open(source, "rb").read().decode(encoding) 80 | return _generate_documentation(source, code, outdir, preserve_paths, language) 81 | 82 | 83 | def _generate_documentation(file_path, code, outdir, preserve_paths, language): 84 | """ 85 | Helper function to allow documentation generation without file handling. 86 | """ 87 | language = get_language(file_path, code, language_name=language) 88 | sections = parse(code, language) 89 | highlight(sections, language, preserve_paths=preserve_paths, outdir=outdir) 90 | return generate_html(file_path, sections, preserve_paths=preserve_paths, outdir=outdir) 91 | 92 | 93 | def parse(code, language): 94 | """ 95 | Given a string of source code, parse out each comment and the code that 96 | follows it, and create an individual **section** for it. 97 | Sections take the form: 98 | 99 | { "docs_text": ..., 100 | "docs_html": ..., 101 | "code_text": ..., 102 | "code_html": ..., 103 | "num": ... 104 | } 105 | """ 106 | 107 | lines = code.split("\n") 108 | sections = [] 109 | has_code = docs_text = code_text = "" 110 | 111 | if lines[0].startswith("#!"): 112 | lines.pop(0) 113 | 114 | if language["name"] == "python": 115 | for linenum, line in enumerate(lines[:2]): 116 | if re.search(r'coding[:=]\s*([-\w.]+)', lines[linenum]): 117 | lines.pop(linenum) 118 | break 119 | 120 | def save(docs, code): 121 | if docs or code: 122 | sections.append({ 123 | "docs_text": docs, 124 | "code_text": code 125 | }) 126 | 127 | # Setup the variables to get ready to check for multiline comments 128 | multi_line = False 129 | multi_string = False 130 | multistart, multiend = language.get("multistart"), language.get("multiend") 131 | comment_matcher = language['comment_matcher'] 132 | 133 | for line in lines: 134 | process_as_code = False 135 | # Only go into multiline comments section when one of the delimiters is 136 | # found to be at the start of a line 137 | if multistart and multiend \ 138 | and any(line.lstrip().startswith(delim) or line.rstrip().endswith(delim) 139 | for delim in (multistart, multiend)): 140 | multi_line = not multi_line 141 | 142 | if multi_line \ 143 | and line.strip().endswith(multiend) \ 144 | and len(line.strip()) > len(multiend): 145 | multi_line = False 146 | 147 | if not line.strip().startswith(multistart) and not multi_line \ 148 | or multi_string: 149 | 150 | process_as_code = True 151 | 152 | if multi_string: 153 | multi_line = False 154 | multi_string = False 155 | else: 156 | multi_string = True 157 | 158 | else: 159 | # Get rid of the delimiters so that they aren't in the final 160 | # docs 161 | line = line.replace(multistart, '') 162 | line = line.replace(multiend, '') 163 | docs_text += line.strip() + '\n' 164 | indent_level = re.match(r"\s*", line).group(0) 165 | 166 | if has_code and docs_text.strip(): 167 | save(docs_text, code_text[:-1]) 168 | code_text = code_text.split('\n')[-1] 169 | has_code = docs_text = '' 170 | 171 | elif multi_line: 172 | # Remove leading spaces 173 | if re.match(r' {{{:d}}}'.format(len(indent_level)), line): 174 | docs_text += line[len(indent_level):] + '\n' 175 | else: 176 | docs_text += line + '\n' 177 | 178 | elif re.match(comment_matcher, line): 179 | if has_code: 180 | save(docs_text, code_text) 181 | has_code = docs_text = code_text = '' 182 | docs_text += re.sub(comment_matcher, "", line) + "\n" 183 | 184 | else: 185 | process_as_code = True 186 | 187 | if process_as_code: 188 | if code_text and any(line.lstrip().startswith(x) 189 | for x in ['class ', 'def ', '@']): 190 | if not code_text.lstrip().startswith("@"): 191 | save(docs_text, code_text) 192 | code_text = has_code = docs_text = '' 193 | 194 | has_code = True 195 | code_text += line + '\n' 196 | 197 | save(docs_text, code_text) 198 | 199 | return sections 200 | 201 | # === Preprocessing the comments === 202 | 203 | 204 | def preprocess(comment, preserve_paths=True, outdir=None): 205 | """ 206 | Add cross-references before having the text processed by markdown. It's 207 | possible to reference another file, like this : `[[main.py]]` which renders 208 | [[main.py]]. You can also reference a specific section of another file, 209 | like this: `[[main.py#highlighting-the-source-code]]` which renders as 210 | [[main.py#highlighting-the-source-code]]. Sections have to be manually 211 | declared; they are written on a single line, and surrounded by equals 212 | signs: 213 | `=== like this ===` 214 | """ 215 | 216 | if not outdir: 217 | raise TypeError("Missing the required 'outdir' keyword argument.") 218 | 219 | def sanitize_section_name(name): 220 | return "-".join(name.lower().strip().split(" ")) 221 | 222 | def replace_crossref(match): 223 | # Check if the match contains an anchor 224 | if '#' in match.group(1): 225 | name, anchor = match.group(1).split('#') 226 | return " [{}]({}#{})".format(name, 227 | path.basename(destination(name, 228 | preserve_paths=preserve_paths, 229 | outdir=outdir)), 230 | anchor) 231 | 232 | else: 233 | return " [{}]({})".format(match.group(1), 234 | path.basename(destination(match.group(1), 235 | preserve_paths=preserve_paths, 236 | outdir=outdir))) 237 | 238 | def replace_section_name(match): 239 | """ 240 | Replace equals-sign-formatted section names with anchor links. 241 | """ 242 | return '{lvl} {name}'.format( 243 | lvl=re.sub('=', '#', match.group(1)), 244 | id=sanitize_section_name(match.group(2)), 245 | name=match.group(2) 246 | ) 247 | 248 | comment = re.sub(r'^([=]+)([^=]+)[=]*\s*$', replace_section_name, comment) 249 | comment = re.sub(r'(?{}DIVIDER\n*'.format(comment_symbol) 366 | ) 367 | 368 | # Get the Pygments Lexer for this language. 369 | l["lexer"] = lexers.get_lexer_by_name(language_name) 370 | 371 | 372 | for entry in supported_languages.values(): 373 | compile_language(entry) 374 | 375 | def get_language(source, code, language_name=None): 376 | """ 377 | Get the current language we're documenting, based on the extension. 378 | """ 379 | if language_name is not None: 380 | for entry in supported_languages.values(): 381 | if entry["name"] == language_name: 382 | return entry 383 | else: 384 | raise ValueError("Unknown forced language: {}".format(language_name)) 385 | 386 | if source: 387 | m = re.match(r'.*(\..+)', os.path.basename(source)) 388 | if m and m.group(1) in supported_languages: 389 | return supported_languages[m.group(1)] 390 | 391 | try: 392 | language_name = lexers.guess_lexer(code).name.lower() 393 | for entry in supported_languages.values(): 394 | if entry["name"] == language_name: 395 | return entry 396 | else: 397 | raise ValueError() 398 | except ValueError: 399 | # If pygments can't find any lexers, it will raise its own 400 | # subclass of ValueError. We will catch it and raise ours 401 | # for consistency. 402 | raise ValueError("Can't figure out the language!") 403 | 404 | 405 | def destination(filepath, preserve_paths=True, outdir=None): 406 | """ 407 | Compute the destination HTML path for an input source file path. If the 408 | source is `lib/example.py`, the HTML will be at `docs/example.html`. 409 | """ 410 | 411 | dirname, filename = path.split(filepath) 412 | if not outdir: 413 | raise TypeError("Missing the required 'outdir' keyword argument.") 414 | try: 415 | name = re.sub(r"\.[^.]*$", "", filename) 416 | except ValueError: 417 | name = filename 418 | if preserve_paths: 419 | name = path.join(dirname, name) 420 | dest = path.join(outdir, u"{}.html".format(name)) 421 | # If `join` is passed an absolute path, it will ignore any earlier path 422 | # elements. We will force outdir to the beginning of the path to avoid 423 | # writing outside our destination. 424 | if not dest.startswith(outdir): 425 | dest = outdir + os.sep + dest 426 | return dest 427 | 428 | 429 | def shift(list, default): 430 | """ 431 | Shift items off the front of the `list` until it is empty, then return 432 | `default`. 433 | """ 434 | try: 435 | return list.pop(0) 436 | except IndexError: 437 | return default 438 | 439 | 440 | def remove_control_chars(s): 441 | # Sanitization regexp copied from 442 | # http://stackoverflow.com/questions/92438/stripping-non-printable-characters-from-a-string-in-python 443 | from pycco.compat import pycco_unichr 444 | control_chars = ''.join( 445 | map(pycco_unichr, list(range(0, 32)) + list(range(127, 160)))) 446 | control_char_re = re.compile(u'[{}]'.format(re.escape(control_chars))) 447 | return control_char_re.sub('', s) 448 | 449 | 450 | def ensure_directory(directory): 451 | """ 452 | Sanitize directory string and ensure that the destination directory exists. 453 | """ 454 | directory = remove_control_chars(directory) 455 | if not os.path.isdir(directory): 456 | os.makedirs(directory) 457 | 458 | return directory 459 | 460 | 461 | # The start of each Pygments highlight block. 462 | highlight_start = "
    "
    463 | 
    464 | # The end of each Pygments highlight block.
    465 | highlight_end = "
    " 466 | 467 | 468 | def _flatten_sources(sources): 469 | """ 470 | This function will iterate through the list of sources and if a directory 471 | is encountered it will walk the tree for any files. 472 | """ 473 | _sources = [] 474 | 475 | for source in sources: 476 | if os.path.isdir(source): 477 | for dirpath, _, filenames in os.walk(source): 478 | _sources.extend([os.path.join(dirpath, f) for f in filenames]) 479 | else: 480 | _sources.append(source) 481 | 482 | return _sources 483 | 484 | 485 | def process(sources, preserve_paths=True, outdir=None, language=None, 486 | encoding="utf8", index=False, skip=False): 487 | """ 488 | For each source file passed as argument, generate the documentation. 489 | """ 490 | 491 | if not outdir: 492 | raise TypeError("Missing the required 'directory' keyword argument.") 493 | 494 | # Make a copy of sources given on the command line. `main()` needs the 495 | # original list when monitoring for changed files. 496 | sources = sorted(_flatten_sources(sources)) 497 | 498 | # Proceed to generating the documentation. 499 | if sources: 500 | outdir = ensure_directory(outdir) 501 | css = open(path.join(outdir, "pycco.css"), "wb") 502 | css.write(pycco_css.encode(encoding)) 503 | css.close() 504 | 505 | generated_files = [] 506 | 507 | def next_file(): 508 | s = sources.pop(0) 509 | dest = destination(s, preserve_paths=preserve_paths, outdir=outdir) 510 | 511 | try: 512 | os.makedirs(path.split(dest)[0]) 513 | except OSError: 514 | pass 515 | 516 | try: 517 | with open(dest, "wb") as f: 518 | f.write(generate_documentation(s, preserve_paths=preserve_paths, 519 | outdir=outdir, 520 | language=language, 521 | encoding=encoding)) 522 | 523 | print("pycco: {} -> {}".format(s, dest)) 524 | generated_files.append(dest) 525 | except (ValueError, UnicodeDecodeError) as e: 526 | if skip: 527 | print("pycco [FAILURE]: {}, {}".format(s, e)) 528 | else: 529 | raise 530 | 531 | if sources: 532 | next_file() 533 | next_file() 534 | 535 | if index: 536 | with open(path.join(outdir, "index.html"), "wb") as f: 537 | f.write(generate_index(generated_files, outdir)) 538 | 539 | 540 | __all__ = ("process", "generate_documentation") 541 | 542 | 543 | def monitor(sources, opts): 544 | """ 545 | Monitor each source file and re-generate documentation on change. 546 | """ 547 | 548 | # The watchdog modules are imported in `main()` but we need to re-import 549 | # here to bring them into the local namespace. 550 | import watchdog.events 551 | import watchdog.observers 552 | 553 | # Watchdog operates on absolute paths, so map those to original paths 554 | # as specified on the command line. 555 | absolute_sources = dict((os.path.abspath(source), source) 556 | for source in sources) 557 | 558 | class RegenerateHandler(watchdog.events.FileSystemEventHandler): 559 | """ 560 | A handler for recompiling files which triggered watchdog events. 561 | """ 562 | 563 | def on_modified(self, event): 564 | """ 565 | Regenerate documentation for a file which triggered an event. 566 | """ 567 | # Re-generate documentation from a source file if it was listed on 568 | # the command line. Watchdog monitors whole directories, so other 569 | # files may cause notifications as well. 570 | if event.src_path in absolute_sources: 571 | process([absolute_sources[event.src_path]], 572 | outdir=opts.outdir, 573 | preserve_paths=opts.paths) 574 | 575 | # Set up an observer which monitors all directories for files given on 576 | # the command line and notifies the handler defined above. 577 | event_handler = RegenerateHandler() 578 | observer = watchdog.observers.Observer() 579 | directories = set(os.path.split(source)[0] for source in sources) 580 | for directory in directories: 581 | observer.schedule(event_handler, path=directory) 582 | 583 | # Run the file change monitoring loop until the user hits Ctrl-C. 584 | observer.start() 585 | try: 586 | while True: 587 | time.sleep(1) 588 | except KeyboardInterrupt: 589 | observer.stop() 590 | observer.join() 591 | 592 | 593 | def main(): 594 | """ 595 | Hook spot for the console script. 596 | """ 597 | 598 | parser = argparse.ArgumentParser() 599 | parser.add_argument('-p', '--paths', action='store_true', 600 | help='Preserve path structure of original files') 601 | 602 | parser.add_argument('-d', '--directory', action='store', type=str, 603 | dest='outdir', default='docs', 604 | help='The output directory that the rendered files should go to.') 605 | 606 | parser.add_argument('-w', '--watch', action='store_true', 607 | help='Watch original files and re-generate documentation on changes') 608 | 609 | parser.add_argument('-l', '--force-language', action='store', type=str, 610 | dest='language', default=None, 611 | help='Force the language for the given files') 612 | 613 | parser.add_argument('-i', '--generate_index', action='store_true', 614 | help='Generate an index.html document with sitemap content') 615 | 616 | parser.add_argument('-s', '--skip-bad-files', '-e', '--ignore-errors', 617 | action='store_true', 618 | dest='skip_bad_files', 619 | help='Continue processing after hitting a bad file') 620 | 621 | parser.add_argument('sources', nargs='*') 622 | 623 | args = parser.parse_args() 624 | if args.outdir == '': 625 | outdir = '.' 626 | else: 627 | outdir = args.outdir 628 | 629 | process(args.sources, outdir=outdir, preserve_paths=args.paths, 630 | language=args.language, index=args.generate_index, 631 | skip=args.skip_bad_files) 632 | 633 | # If the -w / \-\-watch option was present, monitor the source directories 634 | # for changes and re-generate documentation for source files whenever they 635 | # are modified. 636 | if args.watch: 637 | try: 638 | import watchdog.events 639 | import watchdog.observers # noqa 640 | except ImportError: 641 | sys.exit('The -w/--watch option requires the watchdog package.') 642 | 643 | monitor(args.sources, args) 644 | 645 | 646 | # Run the script. 647 | if __name__ == "__main__": 648 | main() 649 | -------------------------------------------------------------------------------- /pycco_resources/__init__.py: -------------------------------------------------------------------------------- 1 | import pystache 2 | 3 | css = """\ 4 | /*--------------------- Layout and Typography ----------------------------*/ 5 | body { 6 | font-family: 'Palatino Linotype', 'Book Antiqua', Palatino, FreeSerif, serif; 7 | font-size: 16px; 8 | line-height: 24px; 9 | color: #252519; 10 | margin: 0; padding: 0; 11 | background: #f5f5ff; 12 | } 13 | a { 14 | color: #261a3b; 15 | } 16 | a:visited { 17 | color: #261a3b; 18 | } 19 | p { 20 | margin: 0 0 15px 0; 21 | } 22 | h1, h2, h3, h4, h5, h6 { 23 | margin: 40px 0 15px 0; 24 | } 25 | h2, h3, h4, h5, h6 { 26 | margin-top: 0; 27 | } 28 | #container { 29 | background: white; 30 | } 31 | #container, div.section { 32 | position: relative; 33 | } 34 | #background { 35 | position: absolute; 36 | top: 0; left: 580px; right: 0; bottom: 0; 37 | background: #f5f5ff; 38 | border-left: 1px solid #e5e5ee; 39 | z-index: 0; 40 | } 41 | #jump_to, #jump_page { 42 | background: white; 43 | -webkit-box-shadow: 0 0 25px #777; -moz-box-shadow: 0 0 25px #777; 44 | -webkit-border-bottom-left-radius: 5px; -moz-border-radius-bottomleft: 5px; 45 | font: 10px Arial; 46 | text-transform: uppercase; 47 | cursor: pointer; 48 | text-align: right; 49 | } 50 | #jump_to, #jump_wrapper { 51 | position: fixed; 52 | right: 0; top: 0; 53 | padding: 5px 10px; 54 | } 55 | #jump_wrapper { 56 | padding: 0; 57 | display: none; 58 | } 59 | #jump_to:hover #jump_wrapper { 60 | display: block; 61 | } 62 | #jump_page { 63 | padding: 5px 0 3px; 64 | margin: 0 0 25px 25px; 65 | } 66 | #jump_page .source { 67 | display: block; 68 | padding: 5px 10px; 69 | text-decoration: none; 70 | border-top: 1px solid #eee; 71 | } 72 | #jump_page .source:hover { 73 | background: #f5f5ff; 74 | } 75 | #jump_page .source:first-child { 76 | } 77 | div.docs { 78 | float: left; 79 | max-width: 500px; 80 | min-width: 500px; 81 | min-height: 5px; 82 | padding: 10px 25px 1px 50px; 83 | vertical-align: top; 84 | text-align: left; 85 | } 86 | .docs pre { 87 | margin: 15px 0 15px; 88 | padding-left: 15px; 89 | overflow-y: scroll; 90 | } 91 | .docs p tt, .docs p code { 92 | background: #f8f8ff; 93 | border: 1px solid #dedede; 94 | font-size: 12px; 95 | padding: 0 0.2em; 96 | } 97 | .octowrap { 98 | position: relative; 99 | } 100 | .octothorpe { 101 | font: 12px Arial; 102 | text-decoration: none; 103 | color: #454545; 104 | position: absolute; 105 | top: 3px; left: -20px; 106 | padding: 1px 2px; 107 | opacity: 0; 108 | -webkit-transition: opacity 0.2s linear; 109 | } 110 | div.docs:hover .octothorpe { 111 | opacity: 1; 112 | } 113 | div.code { 114 | margin-left: 580px; 115 | padding: 14px 15px 16px 50px; 116 | vertical-align: top; 117 | } 118 | .code pre, .docs p code { 119 | font-size: 12px; 120 | } 121 | pre, tt, code { 122 | line-height: 18px; 123 | font-family: Monaco, Consolas, "Lucida Console", monospace; 124 | margin: 0; padding: 0; 125 | } 126 | div.clearall { 127 | clear: both; 128 | } 129 | 130 | 131 | /*---------------------- Syntax Highlighting -----------------------------*/ 132 | td.linenos { background-color: #f0f0f0; padding-right: 10px; } 133 | span.lineno { background-color: #f0f0f0; padding: 0 5px 0 5px; } 134 | body .hll { background-color: #ffffcc } 135 | body .c { color: #408080; font-style: italic } /* Comment */ 136 | body .err { border: 1px solid #FF0000 } /* Error */ 137 | body .k { color: #954121 } /* Keyword */ 138 | body .o { color: #666666 } /* Operator */ 139 | body .cm { color: #408080; font-style: italic } /* Comment.Multiline */ 140 | body .cp { color: #BC7A00 } /* Comment.Preproc */ 141 | body .c1 { color: #408080; font-style: italic } /* Comment.Single */ 142 | body .cs { color: #408080; font-style: italic } /* Comment.Special */ 143 | body .gd { color: #A00000 } /* Generic.Deleted */ 144 | body .ge { font-style: italic } /* Generic.Emph */ 145 | body .gr { color: #FF0000 } /* Generic.Error */ 146 | body .gh { color: #000080; font-weight: bold } /* Generic.Heading */ 147 | body .gi { color: #00A000 } /* Generic.Inserted */ 148 | body .go { color: #808080 } /* Generic.Output */ 149 | body .gp { color: #000080; font-weight: bold } /* Generic.Prompt */ 150 | body .gs { font-weight: bold } /* Generic.Strong */ 151 | body .gu { color: #800080; font-weight: bold } /* Generic.Subheading */ 152 | body .gt { color: #0040D0 } /* Generic.Traceback */ 153 | body .kc { color: #954121 } /* Keyword.Constant */ 154 | body .kd { color: #954121; font-weight: bold } /* Keyword.Declaration */ 155 | body .kn { color: #954121; font-weight: bold } /* Keyword.Namespace */ 156 | body .kp { color: #954121 } /* Keyword.Pseudo */ 157 | body .kr { color: #954121; font-weight: bold } /* Keyword.Reserved */ 158 | body .kt { color: #B00040 } /* Keyword.Type */ 159 | body .m { color: #666666 } /* Literal.Number */ 160 | body .s { color: #219161 } /* Literal.String */ 161 | body .na { color: #7D9029 } /* Name.Attribute */ 162 | body .nb { color: #954121 } /* Name.Builtin */ 163 | body .nc { color: #0000FF; font-weight: bold } /* Name.Class */ 164 | body .no { color: #880000 } /* Name.Constant */ 165 | body .nd { color: #AA22FF } /* Name.Decorator */ 166 | body .ni { color: #999999; font-weight: bold } /* Name.Entity */ 167 | body .ne { color: #D2413A; font-weight: bold } /* Name.Exception */ 168 | body .nf { color: #0000FF } /* Name.Function */ 169 | body .nl { color: #A0A000 } /* Name.Label */ 170 | body .nn { color: #0000FF; font-weight: bold } /* Name.Namespace */ 171 | body .nt { color: #954121; font-weight: bold } /* Name.Tag */ 172 | body .nv { color: #19469D } /* Name.Variable */ 173 | body .ow { color: #AA22FF; font-weight: bold } /* Operator.Word */ 174 | body .w { color: #bbbbbb } /* Text.Whitespace */ 175 | body .mf { color: #666666 } /* Literal.Number.Float */ 176 | body .mh { color: #666666 } /* Literal.Number.Hex */ 177 | body .mi { color: #666666 } /* Literal.Number.Integer */ 178 | body .mo { color: #666666 } /* Literal.Number.Oct */ 179 | body .sb { color: #219161 } /* Literal.String.Backtick */ 180 | body .sc { color: #219161 } /* Literal.String.Char */ 181 | body .sd { color: #219161; font-style: italic } /* Literal.String.Doc */ 182 | body .s2 { color: #219161 } /* Literal.String.Double */ 183 | body .se { color: #BB6622; font-weight: bold } /* Literal.String.Escape */ 184 | body .sh { color: #219161 } /* Literal.String.Heredoc */ 185 | body .si { color: #BB6688; font-weight: bold } /* Literal.String.Interpol */ 186 | body .sx { color: #954121 } /* Literal.String.Other */ 187 | body .sr { color: #BB6688 } /* Literal.String.Regex */ 188 | body .s1 { color: #219161 } /* Literal.String.Single */ 189 | body .ss { color: #19469D } /* Literal.String.Symbol */ 190 | body .bp { color: #954121 } /* Name.Builtin.Pseudo */ 191 | body .vc { color: #19469D } /* Name.Variable.Class */ 192 | body .vg { color: #19469D } /* Name.Variable.Global */ 193 | body .vi { color: #19469D } /* Name.Variable.Instance */ 194 | body .il { color: #666666 } /* Literal.Number.Integer.Long */ 195 | """ 196 | 197 | html = """\ 198 | 199 | 200 | 201 | 202 | {{ title }} 203 | 204 | 205 | 206 |
    207 |
    208 | {{#sources?}} 209 |
    210 | Jump To … 211 |
    212 |
    213 | {{#sources}} 214 | {{ basename }} 215 | {{/sources}} 216 |
    217 |
    218 |
    219 | {{/sources?}} 220 |
    221 |

    {{ title }}

    222 |
    223 |
    224 | {{#sections}} 225 |
    226 |
    227 |
    228 | # 229 |
    230 | {{{ docs_html }}} 231 |
    232 |
    233 | {{{ code_html }}} 234 |
    235 |
    236 |
    237 | {{/sections}} 238 |
    239 | 240 | """ 241 | 242 | 243 | def template(source): 244 | return lambda context: pystache.render(source, context) 245 | # Create the template that we will use to generate the Pycco HTML page. 246 | pycco_template = template(html) 247 | -------------------------------------------------------------------------------- /requirements.test.txt: -------------------------------------------------------------------------------- 1 | coveralls==1.9.2 2 | hypothesis==4.56.1 3 | mock~=2.0 4 | pytest-cov~=2.8.1 5 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | pystache==0.5.4 2 | Pygments==2.5.2 3 | markdown==2.6.11 4 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | description = ( 4 | "A Python port of Docco: the original quick-and-dirty, " 5 | "hundred-line-long, literate-programming-style documentation " 6 | "generator." 7 | ) 8 | 9 | setup( 10 | name="Pycco", 11 | version="0.6.0", 12 | description=description, 13 | author="Zach Smith", 14 | author_email="subsetpark@gmail.com", 15 | url="https://pycco-docs.github.io/pycco/", 16 | packages=find_packages(), 17 | entry_points={ 18 | 'console_scripts': [ 19 | 'pycco = pycco.main:main', 20 | ] 21 | }, 22 | install_requires=['markdown', 'pygments', 'pystache', 'smartypants'], 23 | extras_require={'monitoring': 'watchdog'}, 24 | ) 25 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pycco-docs/pycco/27b1bf012e204c4beda18fb48cdf4b378f68ba8d/tests/__init__.py -------------------------------------------------------------------------------- /tests/test_pycco.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | 3 | import copy 4 | import os 5 | import os.path 6 | import tempfile 7 | import time 8 | 9 | import pytest 10 | 11 | import pycco.generate_index as generate_index 12 | import pycco.main as p 13 | from hypothesis import assume, example, given 14 | from hypothesis.strategies import booleans, lists, none, text, sampled_from, data 15 | from pycco.languages import supported_languages 16 | 17 | try: 18 | from unittest.mock import patch 19 | except ImportError: 20 | from mock import patch 21 | 22 | 23 | 24 | PYTHON = supported_languages['.py'] 25 | PYCCO_SOURCE = 'pycco/main.py' 26 | FOO_FUNCTION = """def foo():\n return True""" 27 | 28 | 29 | def get_language(data): 30 | return data.draw(sampled_from(list(supported_languages.values()))) 31 | 32 | 33 | @given(lists(text()), text()) 34 | def test_shift(fragments, default): 35 | if fragments == []: 36 | assert p.shift(fragments, default) == default 37 | else: 38 | fragments2 = copy.copy(fragments) 39 | head = p.shift(fragments, default) 40 | assert [head] + fragments == fragments2 41 | 42 | 43 | @given(text(), booleans(), text(min_size=1)) 44 | @example("/foo", True, "0") 45 | def test_destination(filepath, preserve_paths, outdir): 46 | dest = p.destination( 47 | filepath, preserve_paths=preserve_paths, outdir=outdir) 48 | assert dest.startswith(outdir) 49 | assert dest.endswith(".html") 50 | 51 | 52 | @given(data(), text()) 53 | def test_parse(data, source): 54 | lang = get_language(data) 55 | parsed = p.parse(source, lang) 56 | for s in parsed: 57 | assert {"code_text", "docs_text"} == set(s.keys()) 58 | 59 | 60 | def test_skip_coding_directive(): 61 | source = "# -*- coding: utf-8 -*-\n" + FOO_FUNCTION 62 | parsed = p.parse(source, PYTHON) 63 | for section in parsed: 64 | assert "coding" not in section['code_text'] 65 | 66 | 67 | def test_multi_line_leading_spaces(): 68 | source = "# This is a\n# comment that\n# is indented\n" 69 | source += FOO_FUNCTION 70 | parsed = p.parse(source, PYTHON) 71 | # The resulting comment has leading spaces stripped out. 72 | assert parsed[0]["docs_text"] == "This is a\ncomment that\nis indented\n" 73 | 74 | 75 | def test_comment_with_only_cross_ref(): 76 | source = ( 77 | '''# ==Link Target==\n\ndef test_link():\n """[[testing.py#link-target]]"""\n pass''' 78 | ) 79 | sections = p.parse(source, PYTHON) 80 | p.highlight(sections, PYTHON, outdir=tempfile.gettempdir()) 81 | assert sections[1][ 82 | 'docs_html'] == '

    testing.py

    ' 83 | 84 | 85 | @given(text(), text()) 86 | def test_get_language_specify_language(source, code): 87 | assert p.get_language( 88 | source, code, language_name="python") == supported_languages['.py'] 89 | 90 | with pytest.raises(ValueError): 91 | p.get_language(source, code, language_name="non-existent") 92 | 93 | 94 | @given(text() | none()) 95 | def test_get_language_bad_source(source): 96 | code = "#!/usr/bin/python\n" 97 | code += FOO_FUNCTION 98 | assert p.get_language(source, code) == PYTHON 99 | with pytest.raises(ValueError) as e: 100 | assert p.get_language(source, "badlang") 101 | 102 | msg = "Can't figure out the language!" 103 | try: 104 | assert e.value.message == msg 105 | except AttributeError: 106 | assert e.value.args[0] == msg 107 | 108 | 109 | @given(text() | none()) 110 | def test_get_language_bad_code(code): 111 | source = "test.py" 112 | assert p.get_language(source, code) == PYTHON 113 | 114 | 115 | @given(text(max_size=64)) 116 | def test_ensure_directory(dir_name): 117 | tempdir = os.path.join(tempfile.gettempdir(), 118 | str(int(time.time())), dir_name) 119 | 120 | # Use sanitization from function, but only for housekeeping. We 121 | # pass in the unsanitized string to the function. 122 | safe_name = p.remove_control_chars(dir_name) 123 | 124 | if not os.path.isdir(safe_name) and os.access(safe_name, os.W_OK): 125 | p.ensure_directory(tempdir) 126 | assert os.path.isdir(safe_name) 127 | 128 | 129 | def test_ensure_multiline_string_support(): 130 | code = '''x = """ 131 | multi-line-string 132 | """ 133 | 134 | y = z # comment 135 | 136 | # *comment with formatting* 137 | 138 | def x(): 139 | """multi-line-string 140 | """''' 141 | 142 | docs_code_tuple_list = p.parse(code, PYTHON) 143 | 144 | assert docs_code_tuple_list[0]['docs_text'] == '' 145 | assert "#" not in docs_code_tuple_list[1]['docs_text'] 146 | 147 | 148 | def test_indented_block(): 149 | 150 | code = '''"""To install Pycco, simply 151 | 152 | pip install pycco 153 | """ 154 | ''' 155 | parsed = p.parse(code, PYTHON) 156 | highlighted = p.highlight(parsed, PYTHON, outdir=tempfile.gettempdir()) 157 | pre_block = highlighted[0]['docs_html'] 158 | assert '
    ' in pre_block
    159 |     assert '
    ' in pre_block 160 | 161 | 162 | def test_generate_documentation(): 163 | p.generate_documentation(PYCCO_SOURCE, outdir=tempfile.gettempdir()) 164 | 165 | 166 | @given(booleans(), booleans(), data()) 167 | def test_process(preserve_paths, index, data): 168 | lang_name = data.draw(sampled_from([l["name"] for l in supported_languages.values()])) 169 | p.process([PYCCO_SOURCE], preserve_paths=preserve_paths, 170 | index=index, 171 | outdir=tempfile.gettempdir(), 172 | language=lang_name) 173 | 174 | 175 | @patch('pygments.lexers.guess_lexer') 176 | def test_process_skips_unknown_languages(mock_guess_lexer): 177 | class Name: 178 | name = 'this language does not exist' 179 | mock_guess_lexer.return_value = Name() 180 | 181 | with pytest.raises(ValueError): 182 | p.process(['LICENSE'], outdir=tempfile.gettempdir(), skip=False) 183 | 184 | p.process(['LICENSE'], outdir=tempfile.gettempdir(), skip=True) 185 | 186 | 187 | one_or_more_chars = text(min_size=1, max_size=255) 188 | paths = lists(one_or_more_chars, min_size=1, max_size=30) 189 | @given( 190 | lists(paths, min_size=1, max_size=255), 191 | lists(one_or_more_chars, min_size=1, max_size=255) 192 | ) 193 | def test_generate_index(path_lists, outdir_list): 194 | file_paths = [os.path.join(*path_list) for path_list in path_lists] 195 | outdir = os.path.join(*outdir_list) 196 | generate_index.generate_index(file_paths, outdir=outdir) 197 | 198 | 199 | def test_flatten_sources(tmpdir): 200 | sources = [str(tmpdir)] 201 | expected_sources = [] 202 | 203 | # Setup the base dir 204 | td = tmpdir.join("test.py") 205 | td.write("#!/bin/env python") 206 | expected_sources.append(str(td)) 207 | 208 | # Make some more directories, each with a file present 209 | for d in ["foo", "bar", "buzz"]: 210 | dd = tmpdir.mkdir(d) 211 | dummy_file = dd.join("test.py") 212 | dummy_file.write("#!/bin/env python") 213 | expected_sources.append(str(dummy_file)) 214 | 215 | # Get the flattened version of the base directory 216 | flattened = p._flatten_sources(sources) 217 | 218 | # Make sure that the lists are the same 219 | assert sorted(expected_sources) == sorted(flattened) 220 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py27,py36,codestyle 3 | 4 | [testenv] 5 | deps = -rrequirements.test.txt 6 | commands = pytest 7 | 8 | [testenv:codestyle] 9 | deps = pycodestyle 10 | # E501 - line too long 11 | commands = pycodestyle --ignore=E501 pycco 12 | --------------------------------------------------------------------------------