Skip to content

Commit 9bd71a5

Browse files
authored
Merge pull request #29 from edx/bmedx/docgen
Add the ability to generate human readable reports in RST from code-annotations generated YAML files
2 parents 98417b4 + a117c7c commit 9bd71a5

25 files changed

Lines changed: 613 additions & 208 deletions

.annotations_sample

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
1-
source_path: ../
1+
source_path: ./
22
report_path: reports
33
safelist_path: .annotation_safe_list.yml
44
coverage_target: 50.0
5+
report_template_dir: code_annotations/report_templates/
6+
rendered_report_dir: code_annotations/reports/
7+
rendered_report_file_extension: .rst
8+
rendered_report_source_link_prefix: https://github.com/edx/edx-platform/tree/master/
59
annotations:
610
".. no_pii:":
711
".. ignored:":

code_annotations/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,4 @@
44

55
from __future__ import absolute_import, unicode_literals
66

7-
__version__ = '0.2.4'
7+
__version__ = '0.3'

code_annotations/base.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ class AnnotationConfig(object):
2121
Configuration shared among all Code Annotations commands.
2222
"""
2323

24-
def __init__(self, config_file_path, report_path_override, verbosity, source_path_override=None):
24+
def __init__(self, config_file_path, report_path_override=None, verbosity=1, source_path_override=None):
2525
"""
2626
Initialize AnnotationConfig.
2727
@@ -41,7 +41,7 @@ def __init__(self, config_file_path, report_path_override, verbosity, source_pat
4141
self.echo = VerboseEcho()
4242

4343
with open(config_file_path) as config_file:
44-
raw_config = yaml.load(config_file)
44+
raw_config = yaml.safe_load(config_file)
4545

4646
self._check_raw_config_keys(raw_config)
4747

@@ -58,6 +58,11 @@ def __init__(self, config_file_path, report_path_override, verbosity, source_pat
5858
self.echo("Configured for source path: {}".format(self.source_path))
5959

6060
self._configure_coverage(raw_config.get('coverage_target', None))
61+
self.report_template_dir = raw_config.get('report_template_dir')
62+
self.rendered_report_dir = raw_config.get('rendered_report_dir')
63+
self.rendered_report_file_extension = raw_config.get('rendered_report_file_extension')
64+
self.rendered_report_source_link_prefix = raw_config.get('rendered_report_source_link_prefix')
65+
6166
self._configure_annotations(raw_config)
6267
self._configure_extensions()
6368

@@ -599,6 +604,6 @@ def report(self, all_results, report_prefix=''):
599604
raise
600605

601606
with open(report_filename, 'w+') as report_file:
602-
yaml.dump(formatted_results, report_file, default_flow_style=False)
607+
yaml.safe_dump(formatted_results, report_file, default_flow_style=False)
603608

604609
return report_filename

code_annotations/cli.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from code_annotations.base import AnnotationConfig, ConfigurationException
1010
from code_annotations.find_django import DjangoSearch
1111
from code_annotations.find_static import StaticSearch
12+
from code_annotations.generate_docs import ReportRenderer
1213
from code_annotations.helpers import fail
1314

1415

@@ -171,3 +172,46 @@ def static_find_annotations(config_file, source_path, report_path, verbosity, li
171172
except Exception as exc: # pylint: disable=broad-except
172173
click.echo(traceback.print_exc())
173174
fail(str(exc))
175+
176+
177+
@entry_point.command("generate_docs")
178+
@click.option(
179+
'--config_file',
180+
default='.annotations',
181+
help='Path to the configuration file',
182+
type=click.Path(exists=True, dir_okay=False)
183+
)
184+
@click.option('-v', '--verbosity', count=True, help='Verbosity level (-v through -vvv)')
185+
@click.argument("report_files", type=click.File('r'), nargs=-1)
186+
def generate_docs(
187+
config_file,
188+
verbosity,
189+
report_files
190+
):
191+
"""
192+
Generate documentation from a code annotations report.
193+
"""
194+
start_time = datetime.datetime.now()
195+
196+
try:
197+
config = AnnotationConfig(config_file, verbosity)
198+
199+
for key in (
200+
'report_template_dir',
201+
'rendered_report_dir',
202+
'rendered_report_file_extension',
203+
'rendered_report_source_link_prefix'
204+
):
205+
if not getattr(config, key):
206+
raise ConfigurationException("No {key} key in {config_file}".format(key=key, config_file=config_file))
207+
208+
config.echo("Rendering the following reports: \n{}".format("\n".join([r.name for r in report_files])))
209+
210+
renderer = ReportRenderer(config, report_files)
211+
renderer.render()
212+
213+
elapsed = datetime.datetime.now() - start_time
214+
click.echo("Report rendered in {} seconds.".format(elapsed.total_seconds()))
215+
except Exception as exc: # pylint: disable=broad-except
216+
click.echo(traceback.print_exc())
217+
fail(str(exc))

code_annotations/find_django.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,15 @@
77
import re
88
import sys
99

10+
# pylint: disable=ungrouped-imports
1011
import django
12+
import yaml
1113
from django.apps import apps
1214
from django.db import models
1315
from six import text_type
1416

1517
from code_annotations.base import BaseSearch
16-
from code_annotations.helpers import fail, get_annotation_regex, yaml_ordered_dump, yaml_ordered_load
18+
from code_annotations.helpers import fail, get_annotation_regex
1719

1820
DEFAULT_SAFELIST_FILE_PATH = '.annotation_safe_list.yml'
1921

@@ -75,7 +77,7 @@ def seed_safelist(self):
7577
7678
"""
7779
safelist_file.write(safelist_comment.lstrip())
78-
yaml_ordered_dump(safelist_data, stream=safelist_file, default_flow_style=False)
80+
yaml.safe_dump(safelist_data, stream=safelist_file, default_flow_style=False)
7981

8082
self.echo('Successfully created safelist file "{}".'.format(self.config.safelist_path), fg='red')
8183
self.echo('Now, you need to:', fg='red')
@@ -175,7 +177,7 @@ def _read_safelist(self):
175177
if os.path.exists(self.config.safelist_path):
176178
self.echo('Found safelist at {}. Reading.\n'.format(self.config.safelist_path))
177179
with open(self.config.safelist_path) as safelist_file:
178-
safelisted_models = yaml_ordered_load(safelist_file)
180+
safelisted_models = yaml.safe_load(safelist_file)
179181
self._increment_count('safelisted', len(safelisted_models))
180182

181183
if safelisted_models:

code_annotations/generate_docs.py

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
"""
2+
Contains functionality for turning YAML reports into human-readable documentation.
3+
"""
4+
5+
import collections
6+
import datetime
7+
import os
8+
9+
import jinja2
10+
import yaml
11+
from slugify import slugify
12+
13+
14+
class ReportRenderer(object):
15+
"""
16+
Generates human readable documentation from YAML reports.
17+
"""
18+
19+
def __init__(self, config, report_files):
20+
"""
21+
Initialize a ReportRenderer.
22+
23+
Args:
24+
config: An AnnotationConfig object
25+
report_files: A list of files to combine and report on
26+
"""
27+
self.config = config
28+
self.echo = self.config.echo
29+
self.report_files = report_files
30+
self.create_time = datetime.datetime.now().isoformat()
31+
32+
self.full_report = self._aggregate_reports()
33+
34+
self.jinja_environment = jinja2.Environment(
35+
autoescape=False,
36+
loader=jinja2.FileSystemLoader(self.config.report_template_dir),
37+
lstrip_blocks=True,
38+
trim_blocks=True
39+
)
40+
self.top_level_template = self.jinja_environment.get_template('annotation_list.tpl')
41+
self.all_choices = []
42+
self.group_mapping = {}
43+
44+
for token in self.config.choices:
45+
self.all_choices.extend(self.config.choices[token])
46+
47+
for group_name in self.config.groups:
48+
for token in self.config.groups[group_name]:
49+
self.group_mapping[token] = group_name
50+
51+
def _add_report_file_to_full_report(self, report_file, report):
52+
loaded_report = yaml.safe_load(report_file)
53+
54+
for filename in loaded_report:
55+
if filename in report:
56+
for loaded_annotation in loaded_report[filename]:
57+
found = False
58+
for report_annotation in report[filename]:
59+
index_keys = ('line_number', 'annotation_token', 'annotation_data')
60+
61+
if all([loaded_annotation[k] == report_annotation[k] for k in index_keys]):
62+
report_annotation.update(loaded_annotation)
63+
found = True
64+
break
65+
66+
if not found:
67+
report[filename].append(loaded_annotation)
68+
else:
69+
report[filename] = loaded_report[filename]
70+
71+
def _aggregate_reports(self):
72+
"""
73+
Combine all of the given report files into a single report object.
74+
"""
75+
report = collections.defaultdict(list)
76+
77+
# Combine report files into a single dict. If there are duplicate annotations, make sure we have the superset
78+
# of data.
79+
for r in self.report_files:
80+
self._add_report_file_to_full_report(r, report)
81+
82+
return report
83+
84+
def _write_doc_file(self, doc_filename, doc_data):
85+
"""
86+
Write out a single report file with the given data. This is rendered using the configured top level template.
87+
88+
Args:
89+
doc_filename: Filename to write to.
90+
doc_data: Dict of reporting data to use, in the {'file name': [list, of, annotations,]} style.
91+
"""
92+
full_doc_filename = os.path.join(
93+
self.config.rendered_report_dir,
94+
slugify(doc_filename)
95+
)
96+
97+
full_doc_filename += self.config.rendered_report_file_extension
98+
99+
self.echo.echo_v('Writing {}'.format(full_doc_filename))
100+
101+
with open(full_doc_filename, 'w') as output:
102+
output.write(self.top_level_template.render(
103+
create_time=self.create_time,
104+
report=doc_data,
105+
all_choices=self.all_choices,
106+
all_annotations=self.config.annotation_tokens,
107+
group_mapping=self.group_mapping,
108+
slugify=slugify,
109+
source_link_prefix=self.config.rendered_report_source_link_prefix)
110+
)
111+
112+
def _generate_per_choice_docs(self):
113+
"""
114+
Generate a page of documentation for each configured annotation choice.
115+
"""
116+
for choice in self.all_choices:
117+
choice_report = collections.defaultdict(list)
118+
for filename in self.full_report:
119+
for annotation in self.full_report[filename]:
120+
if isinstance(annotation['annotation_data'], list) and choice in annotation['annotation_data']:
121+
choice_report[filename].append(annotation)
122+
123+
self._write_doc_file('choice_{}'.format(choice), choice_report)
124+
125+
def _generate_per_annotation_docs(self):
126+
"""
127+
Generate a page of documentation for each configured annotation.
128+
"""
129+
for annotation in self.config.annotation_tokens:
130+
annotation_report = collections.defaultdict(list)
131+
for filename in self.full_report:
132+
for report_annotation in self.full_report[filename]:
133+
if report_annotation['annotation_token'] == annotation:
134+
annotation_report[filename].append(report_annotation)
135+
136+
self._write_doc_file('annotation_{}'.format(annotation), annotation_report)
137+
138+
def render(self):
139+
"""
140+
Perform the rendering of all documentation using the configured Jinja2 templates.
141+
"""
142+
# Generate the top level list of all annotations
143+
self._write_doc_file('index', self.full_report)
144+
self._generate_per_choice_docs()
145+
self._generate_per_annotation_docs()

code_annotations/helpers.py

Lines changed: 1 addition & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,8 @@
33
"""
44
import os
55
import sys
6-
from collections import OrderedDict
76

87
import click
9-
import yaml
108

119

1210
def fail(msg):
@@ -20,76 +18,6 @@ def fail(msg):
2018
sys.exit(-1)
2119

2220

23-
def yaml_ordered_load(stream):
24-
"""
25-
Load YAML files in an ordered way.
26-
27-
We use this to maintain the order of annotations in the safelist. Slighty modified from
28-
https://stackoverflow.com/questions/5121931/in-python-how-can-you-load-yaml-mappings-as-ordereddicts/21048064#21048064
29-
30-
Args:
31-
stream: File-like handle to load
32-
33-
Returns:
34-
Ordered Python representation of the YAML file
35-
"""
36-
class OrderedLoader(yaml.SafeLoader):
37-
"""
38-
A dummy object that we can safely modify using `add_constructor`.
39-
"""
40-
41-
pass
42-
43-
def construct_mapping(loader, node):
44-
"""
45-
Handle actually ordering the data on a node-by-node basis.
46-
47-
Args:
48-
loader: A PyYAML resolver
49-
node: The node to be constructed
50-
51-
Returns:
52-
OrderedDict of the mapped pairs
53-
"""
54-
loader.flatten_mapping(node)
55-
return OrderedDict(loader.construct_pairs(node))
56-
57-
OrderedLoader.add_constructor(
58-
yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG,
59-
construct_mapping
60-
)
61-
62-
return yaml.load(stream, OrderedLoader)
63-
64-
65-
def yaml_ordered_dump(data, stream, **kwargs):
66-
"""
67-
Dump data to YAML files in an ordered way.
68-
69-
We use this to maintain the order of annotations in the safelist. Slighty modified from
70-
https://stackoverflow.com/questions/5121931/in-python-how-can-you-load-yaml-mappings-as-ordereddicts/21048064#21048064
71-
72-
Args:
73-
data: Python object to be dumped
74-
stream: File-like handle to write to
75-
**kwargs:
76-
77-
Returns:
78-
Results of the yaml.dump
79-
"""
80-
class OrderedDumper(yaml.SafeDumper):
81-
pass
82-
83-
def _dict_representer(dumper, data):
84-
return dumper.represent_mapping(
85-
yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG,
86-
data.items()
87-
)
88-
89-
OrderedDumper.add_representer(OrderedDict, _dict_representer)
90-
return yaml.dump(data, stream, OrderedDumper, **kwargs)
91-
92-
9321
class VerboseEcho(object):
9422
"""
9523
Helper to handle verbosity-dependent logging.
@@ -177,7 +105,7 @@ def clean_abs_path(filename_to_clean, parent_path):
177105
# If we are operating on only one file we don't know what to strip off here,
178106
# just return the whole thing.
179107
if filename_to_clean == parent_path:
180-
return parent_path
108+
return os.path.basename(filename_to_clean)
181109
return os.path.relpath(filename_to_clean, parent_path)
182110

183111

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{% if annotation.extra and annotation.extra.object_id %}
2+
`<{{ annotation.extra.object_id }}> line {{ annotation.line_number }} <{{ source_link_prefix }}{{ filename }}#L{{ annotation.line_number }}>`_: {{ annotation.annotation_token }} {% include "annotation_data.tpl" %}
3+
{% else %}
4+
`{{ filename }}:{{ annotation.line_number }} <{{ source_link_prefix }}{{ filename }}#L{{ annotation.line_number }}>`_: {{ annotation.annotation_token }} {% include "annotation_data.tpl" %}
5+
{% endif %}

0 commit comments

Comments
 (0)