Skip to content

Commit 76bb233

Browse files
committed
Refactored draft submission checks so that new checkers can be slotted in through a configuration in settings.py. Refactored the calling of idnits to use the new API, and added a pyang validation check.
- Legacy-Id: 10894
1 parent 1c8a171 commit 76bb233

18 files changed

Lines changed: 608 additions & 63 deletions

ietf/checks.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from django.conf import settings
44
from django.core import checks
5+
from django.utils.module_loading import import_string
56

67
@checks.register('directories')
78
def check_cdn_directory_exists(app_configs, **kwargs):
@@ -98,3 +99,46 @@ def check_id_submission_files(app_configs, **kwargs):
9899
id = "datatracker.E0007",
99100
))
100101
return errors
102+
103+
@checks.register('submission-checkers')
104+
def check_id_submission_checkers(app_configs, **kwargs):
105+
errors = []
106+
for checker_path in settings.IDSUBMIT_CHECKER_CLASSES:
107+
try:
108+
checker_class = import_string(checker_path)
109+
except Exception as e:
110+
errors.append(checks.Critical(
111+
"An exception was raised when trying to import the draft submission"
112+
"checker class '%s':\n %s" % (checker_path, e),
113+
hint = "Please check that the class exists and can be imported.",
114+
id = "datatracker.E0008",
115+
))
116+
try:
117+
checker = checker_class()
118+
except Exception as e:
119+
errors.append(checks.Critical(
120+
"An exception was raised when trying to instantiate the draft submission"
121+
"checker class '%s': %s" % (checker_path, e),
122+
hint = "Please check that the class can be instantiated.",
123+
id = "datatracker.E0009",
124+
))
125+
continue
126+
for attr in ('name',):
127+
if not hasattr(checker, attr):
128+
errors.append(checks.Critical(
129+
"The draft submission checker '%s' has no attribute '%s', which is required" % (checker_path, attr),
130+
hint = "Please update the class.",
131+
id = "datatracker.E0010",
132+
))
133+
checker_methods = ("check_file_txt", "check_file_xml", "check_fragment_txt", "check_fragment_xml", )
134+
for method in checker_methods:
135+
if hasattr(checker, method):
136+
break
137+
else:
138+
errors.append(checks.Critical(
139+
"The draft submission checker '%s' has no recognised checker method; "
140+
"should be one or more of %s." % (checker_path, checker_methods),
141+
hint = "Please update the class.",
142+
id = "datatracker.E0011",
143+
))
144+
return errors

ietf/settings.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -472,10 +472,16 @@ def skip_unreadable_post(record):
472472
IDSUBMIT_STAGING_PATH = '/a/www/www6s/staging/'
473473
IDSUBMIT_STAGING_URL = '//www.ietf.org/staging/'
474474
IDSUBMIT_IDNITS_BINARY = '/a/www/ietf-datatracker/scripts/idnits'
475+
IDSUBMIT_PYANG_COMMAND = 'pyang -p %(workdir)s --verbose --ietf %(model)s'
475476

476-
IDSUBMIT_MANUAL_STAGING_DIR = '/tmp/'
477+
IDSUBMIT_CHECKER_CLASSES = (
478+
"ietf.submit.checkers.DraftIdnitsChecker",
479+
"ietf.submit.checkers.DraftYangChecker",
480+
)
477481

478482

483+
IDSUBMIT_MANUAL_STAGING_DIR = '/tmp/'
484+
479485
IDSUBMIT_FILE_TYPES = (
480486
'txt',
481487
'xml',

ietf/submit/admin.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
from django.contrib import admin
33

44

5-
from ietf.submit.models import Preapproval, Submission
5+
from ietf.submit.models import Preapproval, Submission, SubmissionCheck
66

77
class SubmissionAdmin(admin.ModelAdmin):
88
list_display = ['id', 'draft_link', 'status_link', 'submission_date',]
@@ -23,9 +23,13 @@ def draft_link(self, instance):
2323
else:
2424
return instance.name
2525
draft_link.allow_tags = True
26-
2726
admin.site.register(Submission, SubmissionAdmin)
2827

28+
class SubmissionCheckAdmin(admin.ModelAdmin):
29+
list_display = ['submission', 'time', 'checker', 'passed', 'errors', 'warnings', 'items']
30+
raw_id_fields = ['submission']
31+
admin.site.register(SubmissionCheck, SubmissionCheckAdmin)
32+
2933
class PreapprovalAdmin(admin.ModelAdmin):
3034
pass
3135
admin.site.register(Preapproval, PreapprovalAdmin)

ietf/submit/checkers.py

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
# Copyright The IETF Trust 2016, All Rights Reserved
2+
3+
import os
4+
import re
5+
from xym import xym
6+
import shutil
7+
import tempfile
8+
9+
from django.conf import settings
10+
11+
import debug # pyflakes:ignore
12+
13+
from ietf.utils.pipe import pipe
14+
from ietf.utils.log import log
15+
16+
class DraftSubmissionChecker():
17+
name = ""
18+
19+
def check_file_txt(self, text):
20+
"Run checks on a text file"
21+
raise NotImplementedError
22+
23+
def check_file_xml(self, xml):
24+
"Run checks on an xml file"
25+
raise NotImplementedError
26+
27+
def check_fragment_txt(self, text):
28+
"Run checks on a fragment from a text file"
29+
raise NotImplementedError
30+
31+
def check_fragment_xml(self, xml):
32+
"Run checks on a fragment from an xml file"
33+
raise NotImplementedError
34+
35+
36+
class DraftIdnitsChecker(object):
37+
"""
38+
Draft checker class for idnits. Idnits can only handle whole text files,
39+
so only check_file_txt() is defined; check_file_xml and check_fragment_*
40+
methods are undefined.
41+
42+
Furthermore, idnits doesn't provide an error code or line-by-line errors,
43+
so a bit of massage is needed in order to return the expected failure flag.
44+
"""
45+
name = "idnits check"
46+
47+
def check_file_txt(self, path):
48+
"""
49+
Run an idnits check, and return a passed/failed indication, a message,
50+
and error and warning messages.
51+
52+
Error and warning list items are tuples:
53+
(line_number, line_text, message)
54+
"""
55+
filename = os.path.basename(path)
56+
result = {}
57+
items = []
58+
errors = 0
59+
warnings = 0
60+
errstart = [' ** ', ' ~~ ']
61+
warnstart = [' == ', ' -- ']
62+
63+
64+
cmd = "%s --submitcheck --nitcount %s" % (settings.IDSUBMIT_IDNITS_BINARY, path)
65+
code, out, err = pipe(cmd)
66+
if code != 0 or out == "":
67+
message = "idnits error: %s:\n Error %s: %s" %( cmd, code, err)
68+
log(message)
69+
passed = False
70+
71+
else:
72+
message = out
73+
if re.search("\s+Summary:\s+0\s+|No nits found", out):
74+
passed = True
75+
else:
76+
passed = False
77+
78+
item = None
79+
for line in message.splitlines():
80+
if line[:5] in (errstart + warnstart):
81+
item = line.rstrip()
82+
elif line.strip() == "" and item:
83+
tuple = (None, None, item)
84+
items.append(tuple)
85+
if item[:5] in errstart:
86+
errors += 1
87+
elif item[:5] in warnstart:
88+
warnings += 1
89+
else:
90+
raise RuntimeError("Unexpected state in idnits checker: item: %s, line: %s" % (item, line))
91+
item = None
92+
elif item and line.strip() != "":
93+
item += " " + line.strip()
94+
else:
95+
pass
96+
result[filename] = {
97+
"passed": passed,
98+
"message": message,
99+
"errors": errors,
100+
"warnings":warnings,
101+
"items": items,
102+
}
103+
104+
105+
return passed, message, errors, warnings, result
106+
107+
class DraftYangChecker(object):
108+
109+
name = "yang validation"
110+
111+
def check_file_txt(self, path):
112+
name = os.path.basename(path)
113+
workdir = tempfile.mkdtemp()
114+
results = {}
115+
116+
extractor = xym.YangModuleExtractor(path, workdir, strict=True, debug_level = 0)
117+
with open(path) as file:
118+
try:
119+
# This places the yang models as files in workdir
120+
extractor.extract_yang_model(file.readlines())
121+
model_list = extractor.get_extracted_models()
122+
except Exception as exc:
123+
passed = False
124+
message = exc
125+
errors = [ (name, None, None, exc) ]
126+
warnings = []
127+
return passed, message, errors, warnings
128+
129+
for model in model_list:
130+
path = os.path.join(workdir, model)
131+
with open(path) as file:
132+
text = file.readlines()
133+
cmd = settings.IDSUBMIT_PYANG_COMMAND % {"workdir": workdir, "model": path, }
134+
code, out, err = pipe(cmd)
135+
errors = 0
136+
warnings = 0
137+
items = []
138+
if code > 0:
139+
error_lines = err.splitlines()
140+
for line in error_lines:
141+
fn, lnum, msg = line.split(':', 2)
142+
lnum = int(lnum)
143+
line = text[lnum-1].rstrip()
144+
items.append((lnum, line, msg))
145+
if 'error: ' in msg:
146+
errors += 1
147+
if 'warning: ' in msg:
148+
warnings += 1
149+
results[model] = {
150+
"passed": code == 0,
151+
"message": out+"No validation errors\n" if code == 0 else err,
152+
"warnings": warnings,
153+
"errors": errors,
154+
"items": items,
155+
}
156+
157+
shutil.rmtree(workdir)
158+
159+
## For now, never fail because of failed yang validation.
160+
if len(model_list):
161+
passed = True
162+
else:
163+
passed = None
164+
#passed = all( res["passed"] for res in results.values() )
165+
message = "\n\n".join([ "\n".join([model+':', res["message"]]) for model, res in results.items() ])
166+
errors = sum(res["errors"] for res in results.values() )
167+
warnings = sum(res["warnings"] for res in results.values() )
168+
items = [ e for res in results.values() for e in res["items"] ]
169+
170+
return passed, message, errors, warnings, items
171+
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# -*- coding: utf-8 -*-
2+
from __future__ import unicode_literals
3+
4+
from django.db import models, migrations
5+
import jsonfield
6+
7+
class Migration(migrations.Migration):
8+
9+
dependencies = [
10+
('submit', '0003_auto_20150713_1104'),
11+
]
12+
13+
operations = [
14+
migrations.CreateModel(
15+
name='SubmissionCheck',
16+
fields=[
17+
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
18+
('time', models.DateTimeField(default=None, auto_now=True)),
19+
('checker', models.CharField(max_length=256, blank=True)),
20+
('passed', models.NullBooleanField(default=False)),
21+
('message', models.TextField(null=True, blank=True)),
22+
('warnings', models.IntegerField(null=True, blank=True, default=None)),
23+
('errors', models.IntegerField(null=True, blank=True, default=None)),
24+
('items', jsonfield.JSONField(null=True, blank=True, default=b'{}')),
25+
('submission', models.ForeignKey(related_name='checks', to='submit.Submission')),
26+
],
27+
options={
28+
},
29+
bases=(models.Model,),
30+
),
31+
]
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# -*- coding: utf-8 -*-
2+
from __future__ import unicode_literals
3+
4+
import re
5+
6+
from django.db import migrations
7+
8+
def convert_to_submission_check(apps, schema_editor):
9+
Submission = apps.get_model('submit','Submission')
10+
SubmissionCheck = apps.get_model('submit','SubmissionCheck')
11+
for s in Submission.objects.all():
12+
passed = re.search('\s+Summary:\s+0\s+|No nits found', s.idnits_message) != None
13+
c = SubmissionCheck(submission=s, checker='idnits check', passed=passed, message=s.idnits_message)
14+
c.save()
15+
16+
def convert_from_submission_check(apps, schema_editor):
17+
SubmissionCheck = apps.get_model('submit','SubmissionCheck')
18+
for c in SubmissionCheck.objects.filter(checker='idnits check'):
19+
c.submission.idnits_message = c.message
20+
c.save()
21+
pass
22+
23+
class Migration(migrations.Migration):
24+
25+
dependencies = [
26+
('submit', '0004_submissioncheck'),
27+
]
28+
29+
operations = [
30+
migrations.RunPython(convert_to_submission_check, convert_from_submission_check)
31+
]

ietf/submit/models.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import datetime
33

44
from django.db import models
5+
import jsonfield
56

67
from ietf.doc.models import Document
78
from ietf.person.models import Person
@@ -66,6 +67,23 @@ def access_token(self):
6667
def existing_document(self):
6768
return Document.objects.filter(name=self.name).first()
6869

70+
class SubmissionCheck(models.Model):
71+
time = models.DateTimeField(auto_now=True, default=None) # The default is to make makemigrations happy
72+
submission = models.ForeignKey(Submission, related_name='checks')
73+
checker = models.CharField(max_length=256, blank=True)
74+
passed = models.NullBooleanField(default=False)
75+
message = models.TextField(null=True, blank=True)
76+
errors = models.IntegerField(null=True, blank=True, default=None)
77+
warnings = models.IntegerField(null=True, blank=True, default=None)
78+
items = jsonfield.JSONField(null=True, blank=True, default='{}')
79+
#
80+
def __unicode__(self):
81+
return "%s submission check: %s: %s" % (self.checker, 'Passed' if self.passed else 'Failed', self.message[:48]+'...')
82+
def has_warnings(self):
83+
return self.warnings != '[]'
84+
def has_errors(self):
85+
return self.errors != '[]'
86+
6987
class SubmissionEvent(models.Model):
7088
submission = models.ForeignKey(Submission)
7189
time = models.DateTimeField(default=datetime.datetime.now)

0 commit comments

Comments
 (0)