Skip to content

Commit cb8ef96

Browse files
fix: more submission date feedback; refactor xml2rfc log capture (#8621)
* feat: catch and report any <date> parsing error * refactor: error handling in a more testable way * fix: no bare `except` * test: exception cases for test_parse_creation_date * fix: explicitly reject non-numeric day/year * test: suppress xml2rfc output in test * refactor: context manager to capture xml2rfc output * refactor: more capture_xml2rfc_output usage * fix: capture_xml2rfc_output exception handling
1 parent a9a8f9b commit cb8ef96

File tree

3 files changed

+232
-103
lines changed

3 files changed

+232
-103
lines changed

ietf/submit/utils.py

Lines changed: 79 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@
5858
from ietf.utils.mail import is_valid_email
5959
from ietf.utils.text import parse_unicode, normalize_text
6060
from ietf.utils.timezone import date_today
61-
from ietf.utils.xmldraft import InvalidMetadataError, XMLDraft
61+
from ietf.utils.xmldraft import InvalidMetadataError, XMLDraft, capture_xml2rfc_output
6262
from ietf.person.name import unidecode_name
6363

6464

@@ -926,105 +926,101 @@ def render_missing_formats(submission):
926926
If a txt file already exists, leaves it in place. Overwrites an existing html file
927927
if there is one.
928928
"""
929-
# Capture stdio/stdout from xml2rfc
930-
xml2rfc_stdout = io.StringIO()
931-
xml2rfc_stderr = io.StringIO()
932-
xml2rfc.log.write_out = xml2rfc_stdout
933-
xml2rfc.log.write_err = xml2rfc_stderr
934-
xml_path = staging_path(submission.name, submission.rev, '.xml')
935-
parser = xml2rfc.XmlRfcParser(str(xml_path), quiet=True)
936-
try:
937-
# --- Parse the xml ---
938-
xmltree = parser.parse(remove_comments=False)
939-
except Exception as err:
940-
raise XmlRfcError(
941-
"Error parsing XML",
942-
xml2rfc_stdout=xml2rfc_stdout.getvalue(),
943-
xml2rfc_stderr=xml2rfc_stderr.getvalue(),
944-
) from err
945-
# If we have v2, run it through v2v3. Keep track of the submitted version, though.
946-
xmlroot = xmltree.getroot()
947-
xml_version = xmlroot.get('version', '2')
948-
if xml_version == '2':
949-
v2v3 = xml2rfc.V2v3XmlWriter(xmltree)
929+
with capture_xml2rfc_output() as xml2rfc_logs:
930+
xml_path = staging_path(submission.name, submission.rev, '.xml')
931+
parser = xml2rfc.XmlRfcParser(str(xml_path), quiet=True)
950932
try:
951-
xmltree.tree = v2v3.convert2to3()
933+
# --- Parse the xml ---
934+
xmltree = parser.parse(remove_comments=False)
952935
except Exception as err:
953936
raise XmlRfcError(
954-
"Error converting v2 XML to v3",
955-
xml2rfc_stdout=xml2rfc_stdout.getvalue(),
956-
xml2rfc_stderr=xml2rfc_stderr.getvalue(),
937+
"Error parsing XML",
938+
xml2rfc_stdout=xml2rfc_logs["stdout"].getvalue(),
939+
xml2rfc_stderr=xml2rfc_logs["stderr"].getvalue(),
957940
) from err
958-
959-
# --- Prep the xml ---
960-
today = date_today()
961-
prep = xml2rfc.PrepToolWriter(xmltree, quiet=True, liberal=True, keep_pis=[xml2rfc.V3_PI_TARGET])
962-
prep.options.accept_prepped = True
963-
prep.options.date = today
964-
try:
965-
xmltree.tree = prep.prep()
966-
except RfcWriterError:
967-
raise XmlRfcError(
968-
f"Error during xml2rfc prep: {prep.errors}",
969-
xml2rfc_stdout=xml2rfc_stdout.getvalue(),
970-
xml2rfc_stderr=xml2rfc_stderr.getvalue(),
971-
)
972-
except Exception as err:
973-
raise XmlRfcError(
974-
"Unexpected error during xml2rfc prep",
975-
xml2rfc_stdout=xml2rfc_stdout.getvalue(),
976-
xml2rfc_stderr=xml2rfc_stderr.getvalue(),
977-
) from err
978-
979-
# --- Convert to txt ---
980-
txt_path = staging_path(submission.name, submission.rev, '.txt')
981-
if not txt_path.exists():
982-
writer = xml2rfc.TextWriter(xmltree, quiet=True)
983-
writer.options.accept_prepped = True
941+
# If we have v2, run it through v2v3. Keep track of the submitted version, though.
942+
xmlroot = xmltree.getroot()
943+
xml_version = xmlroot.get('version', '2')
944+
if xml_version == '2':
945+
v2v3 = xml2rfc.V2v3XmlWriter(xmltree)
946+
try:
947+
xmltree.tree = v2v3.convert2to3()
948+
except Exception as err:
949+
raise XmlRfcError(
950+
"Error converting v2 XML to v3",
951+
xml2rfc_stdout=xml2rfc_logs["stdout"].getvalue(),
952+
xml2rfc_stderr=xml2rfc_logs["stderr"].getvalue(),
953+
) from err
954+
955+
# --- Prep the xml ---
956+
today = date_today()
957+
prep = xml2rfc.PrepToolWriter(xmltree, quiet=True, liberal=True, keep_pis=[xml2rfc.V3_PI_TARGET])
958+
prep.options.accept_prepped = True
959+
prep.options.date = today
960+
try:
961+
xmltree.tree = prep.prep()
962+
except RfcWriterError:
963+
raise XmlRfcError(
964+
f"Error during xml2rfc prep: {prep.errors}",
965+
xml2rfc_stdout=xml2rfc_logs["stdout"].getvalue(),
966+
xml2rfc_stderr=xml2rfc_logs["stderr"].getvalue(),
967+
)
968+
except Exception as err:
969+
raise XmlRfcError(
970+
"Unexpected error during xml2rfc prep",
971+
xml2rfc_stdout=xml2rfc_logs["stdout"].getvalue(),
972+
xml2rfc_stderr=xml2rfc_logs["stderr"].getvalue(),
973+
) from err
974+
975+
# --- Convert to txt ---
976+
txt_path = staging_path(submission.name, submission.rev, '.txt')
977+
if not txt_path.exists():
978+
writer = xml2rfc.TextWriter(xmltree, quiet=True)
979+
writer.options.accept_prepped = True
980+
writer.options.date = today
981+
try:
982+
writer.write(txt_path)
983+
except Exception as err:
984+
raise XmlRfcError(
985+
"Error generating text format from XML",
986+
xml2rfc_stdout=xml2rfc_logs["stdout"].getvalue(),
987+
xml2rfc_stderr=xml2rfc_logs["stderr"].getvalue(),
988+
) from err
989+
log.log(
990+
'In %s: xml2rfc %s generated %s from %s (version %s)' % (
991+
str(xml_path.parent),
992+
xml2rfc.__version__,
993+
txt_path.name,
994+
xml_path.name,
995+
xml_version,
996+
)
997+
)
998+
# When the blobstores become autoritative - the guard at the
999+
# containing if statement needs to be based on the store
1000+
with Path(txt_path).open("rb") as f:
1001+
store_file("staging", f"{submission.name}-{submission.rev}.txt", f)
1002+
1003+
# --- Convert to html ---
1004+
html_path = staging_path(submission.name, submission.rev, '.html')
1005+
writer = xml2rfc.HtmlWriter(xmltree, quiet=True)
9841006
writer.options.date = today
9851007
try:
986-
writer.write(txt_path)
1008+
writer.write(str(html_path))
9871009
except Exception as err:
9881010
raise XmlRfcError(
989-
"Error generating text format from XML",
990-
xml2rfc_stdout=xml2rfc_stdout.getvalue(),
991-
xml2rfc_stderr=xml2rfc_stderr.getvalue(),
1011+
"Error generating HTML format from XML",
1012+
xml2rfc_stdout=xml2rfc_logs["stdout"].getvalue(),
1013+
xml2rfc_stderr=xml2rfc_logs["stderr"].getvalue(),
9921014
) from err
9931015
log.log(
9941016
'In %s: xml2rfc %s generated %s from %s (version %s)' % (
9951017
str(xml_path.parent),
9961018
xml2rfc.__version__,
997-
txt_path.name,
1019+
html_path.name,
9981020
xml_path.name,
9991021
xml_version,
10001022
)
10011023
)
1002-
# When the blobstores become autoritative - the guard at the
1003-
# containing if statement needs to be based on the store
1004-
with Path(txt_path).open("rb") as f:
1005-
store_file("staging", f"{submission.name}-{submission.rev}.txt", f)
1006-
1007-
# --- Convert to html ---
1008-
html_path = staging_path(submission.name, submission.rev, '.html')
1009-
writer = xml2rfc.HtmlWriter(xmltree, quiet=True)
1010-
writer.options.date = today
1011-
try:
1012-
writer.write(str(html_path))
1013-
except Exception as err:
1014-
raise XmlRfcError(
1015-
"Error generating HTML format from XML",
1016-
xml2rfc_stdout=xml2rfc_stdout.getvalue(),
1017-
xml2rfc_stderr=xml2rfc_stderr.getvalue(),
1018-
) from err
1019-
log.log(
1020-
'In %s: xml2rfc %s generated %s from %s (version %s)' % (
1021-
str(xml_path.parent),
1022-
xml2rfc.__version__,
1023-
html_path.name,
1024-
xml_path.name,
1025-
xml_version,
1026-
)
1027-
)
10281024
with Path(html_path).open("rb") as f:
10291025
store_file("staging", f"{submission.name}-{submission.rev}.html", f)
10301026

ietf/utils/tests.py

Lines changed: 105 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@
2323
from importlib import import_module
2424
from textwrap import dedent
2525
from tempfile import mkdtemp
26+
from xml2rfc import log as xml2rfc_log
27+
from xml2rfc.util.date import extract_date as xml2rfc_extract_date
2628

2729
from django.apps import apps
2830
from django.contrib.auth.models import User
@@ -57,7 +59,7 @@
5759
from ietf.utils.test_utils import TestCase, unicontent
5860
from ietf.utils.text import parse_unicode
5961
from ietf.utils.timezone import timezone_not_near_midnight
60-
from ietf.utils.xmldraft import XMLDraft
62+
from ietf.utils.xmldraft import XMLDraft, InvalidMetadataError, capture_xml2rfc_output
6163

6264
class SendingMail(TestCase):
6365

@@ -544,7 +546,7 @@ def test_get_refs_v2(self):
544546
def test_parse_creation_date(self):
545547
# override date_today to avoid skew when test runs around midnight
546548
today = datetime.date.today()
547-
with patch("ietf.utils.xmldraft.date_today", return_value=today):
549+
with capture_xml2rfc_output(), patch("ietf.utils.xmldraft.date_today", return_value=today):
548550
# Note: using a dict as a stand-in for XML elements, which rely on the get() method
549551
self.assertEqual(
550552
XMLDraft.parse_creation_date({"year": "2022", "month": "11", "day": "24"}),
@@ -590,6 +592,74 @@ def test_parse_creation_date(self):
590592
),
591593
datetime.date(today.year, 1 if today.month != 1 else 2, 15),
592594
)
595+
# Some exeception-inducing conditions
596+
with self.assertRaises(
597+
InvalidMetadataError,
598+
msg="raise an InvalidMetadataError if a year-only date is not current",
599+
):
600+
XMLDraft.parse_creation_date(
601+
{
602+
"year": str(today.year - 1),
603+
"month": "",
604+
"day": "",
605+
}
606+
)
607+
with self.assertRaises(
608+
InvalidMetadataError,
609+
msg="raise an InvalidMetadataError for a non-numeric year"
610+
):
611+
XMLDraft.parse_creation_date(
612+
{
613+
"year": "two thousand twenty-five",
614+
"month": "2",
615+
"day": "28",
616+
}
617+
)
618+
with self.assertRaises(
619+
InvalidMetadataError,
620+
msg="raise an InvalidMetadataError for an invalid month"
621+
):
622+
XMLDraft.parse_creation_date(
623+
{
624+
"year": "2024",
625+
"month": "13",
626+
"day": "28",
627+
}
628+
)
629+
with self.assertRaises(
630+
InvalidMetadataError,
631+
msg="raise an InvalidMetadataError for a misspelled month"
632+
):
633+
XMLDraft.parse_creation_date(
634+
{
635+
"year": "2024",
636+
"month": "Oktobur",
637+
"day": "28",
638+
}
639+
)
640+
with self.assertRaises(
641+
InvalidMetadataError,
642+
msg="raise an InvalidMetadataError for an invalid day"
643+
):
644+
XMLDraft.parse_creation_date(
645+
{
646+
"year": "2024",
647+
"month": "feb",
648+
"day": "31",
649+
}
650+
)
651+
with self.assertRaises(
652+
InvalidMetadataError,
653+
msg="raise an InvalidMetadataError for a non-numeric day"
654+
):
655+
XMLDraft.parse_creation_date(
656+
{
657+
"year": "2024",
658+
"month": "feb",
659+
"day": "twenty-four",
660+
}
661+
)
662+
593663

594664
def test_parse_docname(self):
595665
with self.assertRaises(ValueError) as cm:
@@ -671,6 +741,39 @@ def test_render_author_name(self):
671741
"J. Q.",
672742
)
673743

744+
def test_capture_xml2rfc_output(self):
745+
"""capture_xml2rfc_output reroutes and captures xml2rfc logs"""
746+
orig_write_out = xml2rfc_log.write_out
747+
orig_write_err = xml2rfc_log.write_err
748+
with capture_xml2rfc_output() as outer_log_streams: # ensure no output
749+
# such meta! very Inception!
750+
with capture_xml2rfc_output() as inner_log_streams:
751+
# arbitrary xml2rfc method that triggers a log, nothing special otherwise
752+
xml2rfc_extract_date({"year": "fish"}, datetime.date(2025,3,1))
753+
self.assertNotEqual(inner_log_streams, outer_log_streams)
754+
self.assertEqual(xml2rfc_log.write_out, outer_log_streams["stdout"], "out stream should be restored")
755+
self.assertEqual(xml2rfc_log.write_err, outer_log_streams["stderr"], "err stream should be restored")
756+
self.assertEqual(xml2rfc_log.write_out, orig_write_out, "original out stream should be restored")
757+
self.assertEqual(xml2rfc_log.write_err, orig_write_err, "original err stream should be restored")
758+
759+
# don't happen to get any output on stdout and not paranoid enough to force some, just test stderr
760+
self.assertGreater(len(inner_log_streams["stderr"].getvalue()), 0, "want output on inner streams")
761+
self.assertEqual(len(outer_log_streams["stdout"].getvalue()), 0, "no output on outer streams")
762+
self.assertEqual(len(outer_log_streams["stderr"].getvalue()), 0, "no output on outer streams")
763+
764+
def test_capture_xml2rfc_output_exception_handling(self):
765+
"""capture_xml2rfc_output restores streams after an exception"""
766+
orig_write_out = xml2rfc_log.write_out
767+
orig_write_err = xml2rfc_log.write_err
768+
with capture_xml2rfc_output() as outer_log_streams: # ensure no output
769+
with self.assertRaises(RuntimeError), capture_xml2rfc_output() as inner_log_streams:
770+
raise RuntimeError("nooo")
771+
self.assertNotEqual(inner_log_streams, outer_log_streams)
772+
self.assertEqual(xml2rfc_log.write_out, outer_log_streams["stdout"], "out stream should be restored")
773+
self.assertEqual(xml2rfc_log.write_err, outer_log_streams["stderr"], "err stream should be restored")
774+
self.assertEqual(xml2rfc_log.write_out, orig_write_out, "original out stream should be restored")
775+
self.assertEqual(xml2rfc_log.write_err, orig_write_err, "original err stream should be restored")
776+
674777

675778
class NameTests(TestCase):
676779

0 commit comments

Comments
 (0)