From 09e6c3c083413dddc58262077e78237c033e3844 Mon Sep 17 00:00:00 2001 From: Dom Heinzeller Date: Wed, 8 Jul 2026 06:19:59 -0600 Subject: [PATCH 1/9] Simplify capgen/metadata/parse_tools/parse_source.py --- capgen/metadata/parse_tools/__init__.py | 2 - capgen/metadata/parse_tools/parse_source.py | 91 ++++----------------- 2 files changed, 18 insertions(+), 75 deletions(-) diff --git a/capgen/metadata/parse_tools/__init__.py b/capgen/metadata/parse_tools/__init__.py index 2bb38592..1795e973 100644 --- a/capgen/metadata/parse_tools/__init__.py +++ b/capgen/metadata/parse_tools/__init__.py @@ -3,9 +3,7 @@ from .parse_source import ( CCPPError, ParseSyntaxError, - ParseInternalError, ParseContext, - context_string, ) from .parse_log import init_log, set_log_level, set_log_to_null, set_log_to_stdout from .parse_checkers import ( diff --git a/capgen/metadata/parse_tools/parse_source.py b/capgen/metadata/parse_tools/parse_source.py index 6648f59d..6d68e291 100644 --- a/capgen/metadata/parse_tools/parse_source.py +++ b/capgen/metadata/parse_tools/parse_source.py @@ -1,59 +1,25 @@ """Parsing primitives: parse context and exception types.""" -import logging -import os.path - - -def context_string(context=None, with_comma=True, nodir=False): - """Return a human-readable location string from *context*. - - Parameters - ---------- - context : ParseContext or None - Parsing location. ``None`` returns an empty string. - with_comma : bool - Prepend ``', at '`` or ``', in '`` when *context* is given. - nodir : bool - Strip the directory portion of the filename. - - >>> context_string() - '' - >>> context_string(context=ParseContext(linenum=32, filename="dir/source.F90"), with_comma=False) - 'dir/source.F90:33' - >>> context_string(context=ParseContext(linenum=32, filename="dir/source.F90"), with_comma=True) - ', at dir/source.F90:33' - >>> context_string(context=ParseContext(filename="dir/source.F90"), with_comma=False) - 'dir/source.F90' - >>> context_string(context=ParseContext(linenum=32, filename="dir/source.F90"), with_comma=False, nodir=True) - 'source.F90:33' - """ - if context is None: - return '' - if context.line_num < 0: - where_str = 'in ' - else: - where_str = 'at ' - comma = ', ' if with_comma else '' - if not with_comma: - where_str = '' - spec = '{ctx:nodir}' if nodir else '{ctx}' - return ('{comma}{where_str}' + spec).format(comma=comma, where_str=where_str, ctx=context) - class CCPPError(ValueError): """User-facing error with a plain message and no traceback noise.""" - def __init__(self, message): - logging.shutdown() - super().__init__(message) - class ParseSyntaxError(CCPPError): - """Syntax error that includes parsing context in the message.""" + """Syntax error that includes parsing context in the message. + + >>> str(ParseSyntaxError("dimension", token="foo", context=ParseContext(32, "s.F90"))) + "Invalid dimension, 'foo', at s.F90:33" + >>> str(ParseSyntaxError("End of file", context=ParseContext(filename="s.F90"))) + 'End of file, in s.F90' + """ def __init__(self, token_type, token=None, context=None): - logging.shutdown() - cstr = context_string(context) + if context is None: + cstr = '' + else: + where_str = 'at' if context.line_num >= 0 else 'in' + cstr = ", {} {}".format(where_str, context) if token is None: message = "{}{}".format(token_type, cstr) else: @@ -61,20 +27,11 @@ def __init__(self, token_type, token=None, context=None): super().__init__(message) -class ParseInternalError(Exception): - """Internal parser logic error — not caught by normal user-error handlers.""" - - def __init__(self, errmsg, context=None): - logging.shutdown() - message = "{}{}".format(errmsg, context_string(context)) - super().__init__(message) - - class ParseContext: """File-position record used as the location anchor for parse errors. Holds a filename and a zero-based line number (negative means «file - level, no specific line»); formats as ``filename:line``. + level, no specific line»); formats as ``filename:line`` (1-based). >>> str(ParseContext(linenum=0, filename="foo.F90")) 'foo.F90:1' @@ -97,22 +54,10 @@ def __init__(self, linenum=None, filename=None): elif not isinstance(filename, str): raise CCPPError('ParseContext filename must be a string') - self.__linenum = linenum - self.__filename = filename - - @property - def line_num(self): - return self.__linenum - - @property - def filename(self): - return self.__filename - - def __format__(self, spec): - fname = os.path.basename(self.__filename) if spec == 'nodir' else self.__filename - if self.__linenum >= 0: - return "{}:{}".format(fname, self.__linenum + 1) - return fname + self.line_num = linenum + self.filename = filename def __str__(self): - return format(self) + if self.line_num >= 0: + return "{}:{}".format(self.filename, self.line_num + 1) + return self.filename From 1d3c8d041d9b0420d7625186dacdd884282c4b1e Mon Sep 17 00:00:00 2001 From: Dom Heinzeller Date: Wed, 8 Jul 2026 11:01:07 -0600 Subject: [PATCH 2/9] Simplify capgen/metadata/parse_tools/parse_log.py --- capgen/metadata/parse_tools/__init__.py | 2 +- capgen/metadata/parse_tools/parse_log.py | 15 ++++----------- 2 files changed, 5 insertions(+), 12 deletions(-) diff --git a/capgen/metadata/parse_tools/__init__.py b/capgen/metadata/parse_tools/__init__.py index 1795e973..7b281219 100644 --- a/capgen/metadata/parse_tools/__init__.py +++ b/capgen/metadata/parse_tools/__init__.py @@ -5,7 +5,7 @@ ParseSyntaxError, ParseContext, ) -from .parse_log import init_log, set_log_level, set_log_to_null, set_log_to_stdout +from .parse_log import init_log, set_log_level, set_log_to_null from .parse_checkers import ( check_units, check_dimensions, diff --git a/capgen/metadata/parse_tools/parse_log.py b/capgen/metadata/parse_tools/parse_log.py index fe080cc6..43cf8d83 100644 --- a/capgen/metadata/parse_tools/parse_log.py +++ b/capgen/metadata/parse_tools/parse_log.py @@ -4,20 +4,13 @@ def init_log(name, level=None): - """Initialize and return a named logger. + """Initialize and return a named logger writing to stdout. - Defaults to WARNING level when *level* is not specified and the logger - has no existing level set. - - >>> logger = init_log('test_logger') - >>> logger.name - 'test_logger' + When *level* is given it is applied; otherwise the logger inherits the + root default (WARNING). """ logger = logging.getLogger(name) - llevel = logger.getEffectiveLevel() - if level is None and llevel == logging.NOTSET: - logger.setLevel(logging.WARNING) - elif level: + if level: logger.setLevel(level) set_log_to_stdout(logger) return logger From 5ffce703543bbcf53e499fde911cd9e92f2db543 Mon Sep 17 00:00:00 2001 From: Dom Heinzeller Date: Wed, 8 Jul 2026 11:41:36 -0600 Subject: [PATCH 3/9] Add documentation, simplify, and add a comment about a latent bug that doesn't hurt is today w.r.t. escaping periods in decimals in capgen/metadata/parse_tools/fortran_conditional.py --- .../parse_tools/fortran_conditional.py | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/capgen/metadata/parse_tools/fortran_conditional.py b/capgen/metadata/parse_tools/fortran_conditional.py index 17ae6859..4c50b00d 100755 --- a/capgen/metadata/parse_tools/fortran_conditional.py +++ b/capgen/metadata/parse_tools/fortran_conditional.py @@ -7,7 +7,28 @@ import re +# Every Fortran token in a conditional that is NOT an operand: whitespace, +# parentheses, the comparison operators in both symbolic (==, /=, <=, >=, <, >) +# and F77 dotted (.lt., .le., .eq., .ge., .gt., .ne.) spellings, the logical +# literals (.true., .false.) and the logical operators (.eqv., .neqv., .not., +# .and., .or., .xor.). These are the delimiters the tokenizer below splits on. +# Multi-character operators are listed before their single-character prefixes +# so, e.g., '<=' is matched whole rather than as '<' followed by '='. FORTRAN_CONDITIONAL_REGEX_WORDS = [' ', '(', ')', '==', '/=', '<=', '>=', '<', '>', '.eqv.', '.neqv.', '.true.', '.false.', '.lt.', '.le.', '.eq.', '.ge.', '.gt.', '.ne.', '.not.', '.and.', '.or.', '.xor.'] -FORTRAN_CONDITIONAL_REGEX = re.compile(r"[\w']+|" + "|".join([word.replace('(',r'\(').replace(')', r'\)') for word in FORTRAN_CONDITIONAL_REGEX_WORDS])) +# Tokenizer for a Fortran conditional: re.findall() returns an ordered list of +# every operand and operator in the expression. Each match is EITHER +# [\w']+ -- one operand: a run of identifier characters (a standard name or a +# number), with ' included so a quoted literal like 'active' stays a +# single token instead of being split -- OR +# one of the operator/delimiter strings from the list above, each passed +# through re.escape() -- so its regex metacharacters (the parentheses and the +# dots in .and./.eq./...) match literally -- and joined with '|'. +# The caller then walks the tokens and swaps each operand's standard name for +# its local name, leaving the operators untouched. +# Known limitation: decimal literals are not supported. An operand is a run of +# word characters, so '1.5' tokenizes as '1' and '5' -- the '.' matches no token +# and is dropped. Metadata conditionals compare standard names against integers +# or .true./.false., so this has not mattered in practice. +FORTRAN_CONDITIONAL_REGEX = re.compile(r"[\w']+|" + "|".join([re.escape(word) for word in FORTRAN_CONDITIONAL_REGEX_WORDS])) From 7426a28840514610e5100c06d4e7ca2bb9f55a83 Mon Sep 17 00:00:00 2001 From: Dom Heinzeller Date: Wed, 8 Jul 2026 11:43:17 -0600 Subject: [PATCH 4/9] Run CI tests also for PRs against feature/capgen-v1 --- .github/workflows/end-to-end-tests.yaml | 2 +- .github/workflows/unit-tests.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/end-to-end-tests.yaml b/.github/workflows/end-to-end-tests.yaml index 3d64ef76..d789aaa0 100644 --- a/.github/workflows/end-to-end-tests.yaml +++ b/.github/workflows/end-to-end-tests.yaml @@ -3,7 +3,7 @@ name: capgen end-to-end tests on: workflow_dispatch: pull_request: - branches: [develop] + branches: [develop, feature/capgen-v1] concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} diff --git a/.github/workflows/unit-tests.yaml b/.github/workflows/unit-tests.yaml index 7a87f6d7..2d3d48bd 100644 --- a/.github/workflows/unit-tests.yaml +++ b/.github/workflows/unit-tests.yaml @@ -3,7 +3,7 @@ name: capgen unit tests on: workflow_dispatch: pull_request: - branches: [develop] + branches: [develop, feature/capgen-v1] concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} From 47cdbb9279c01cf4ce32807f7b2fa938fea1edd9 Mon Sep 17 00:00:00 2001 From: Dom Heinzeller Date: Thu, 9 Jul 2026 08:37:29 -0600 Subject: [PATCH 5/9] Update capgen/metadata/parse_tools/io_helpers.py: better documentation, preserve default umask --- capgen/metadata/parse_tools/io_helpers.py | 64 ++++++++++------------- 1 file changed, 29 insertions(+), 35 deletions(-) diff --git a/capgen/metadata/parse_tools/io_helpers.py b/capgen/metadata/parse_tools/io_helpers.py index 44108fee..d9c73ab5 100644 --- a/capgen/metadata/parse_tools/io_helpers.py +++ b/capgen/metadata/parse_tools/io_helpers.py @@ -1,25 +1,28 @@ """File-write helpers with no-op-if-unchanged semantics. -The original ``ccpp-prebuild`` and ``ccpp-capgen`` both avoided rewriting -generated cap files when their content was unchanged — preserving each -file's mtime so downstream build systems (CMake, Make, Ninja) don't -trigger unnecessary recompilation cascades. This module reproduces that -behaviour for ``capgen``. - -Staging strategy ----------------- -A naive ``open(path, 'w')`` always touches the mtime, even when the -content is identical. Instead each writer builds the file's content in -memory and calls :func:`write_if_changed`, which: - -1. Reads the existing file at *file_path* (if any). -2. If the existing content matches the new content byte-for-byte, returns - ``False`` without touching the filesystem. -3. Otherwise writes the new content to a sibling temp file (in the same - directory, which sits **under the generator's output root** — never - ``/tmp``, so this works on systems that disallow ``/tmp`` writes) and - then ``os.replace``s it over the target. Same-directory replace is - atomic on POSIX and Windows; no partial writes. +This module implements the same no-op-if-unchanged semantics as the original +``ccpp-prebuild`` and ``ccpp-capgen`` — avoiding rewriting generated cap files +when their content was unchanged — preserving each file's mtime so downstream +build systems (CMake, Make, Ninja) don't trigger unnecessary recompilation +cascades. + +Two separate concerns +--------------------- +:func:`write_if_changed` handles *whether* to write and *how* to write +independently — they are different problems and solved by different means: + +- **Whether** — the new content is compared in memory against the existing + file. If it is byte-for-byte identical the file is left untouched (mtime + preserved), so CMake/Make/Ninja don't see a spurious change and trigger a + needless recompilation cascade. Because the comparison is in memory, + nothing is written to disk in the common unchanged case. + +- **How** — when the content *does* differ it is written to a sibling temp + file which is then ``os.replace``d over the target. A same-directory + replace is atomic on POSIX and Windows, so an interrupted or failed write + can never leave a partially written / corrupt cap in place for the build + to compile. The temp file lives in the target's parent directory (under + the generator's output root) and is written with the default umask. For writers that already produce content via a ``with open(...) as fh`` pattern, use :func:`open_if_changed` as a drop-in replacement — it yields @@ -50,31 +53,18 @@ def write_if_changed( Full file content to write. encoding : str Encoding passed to :func:`open` for both the read-back comparison - and the staged write. Defaults to ``'utf-8'`` to match every - capgen writer. + and the staged write. Defaults to ``'utf-8'``. logger : logging.Logger, optional When supplied, the helper logs an ``info``-level message after each call: ``"Wrote "`` if the file was newly written or rewritten, or ``"Unchanged: "`` if the existing content - matched and the filesystem was left untouched. Callers want - this so end users can tell at a glance which generated files - actually changed on a rerun (the original ccpp-prebuild / - ccpp-capgen output distinguished the two cases too). + matched and the filesystem was left untouched. Returns ------- bool ``True`` if the file was written or replaced; ``False`` if the existing content already matched. - - Notes - ----- - The temp file is created in the target's parent directory via - :func:`tempfile.mkstemp` (which generates a unique name and opens it - with ``O_EXCL`` semantics). On any exception, the temp file is - removed so we never leak ``.capgen_tmp_*`` artifacts. Crucially the - staging directory is the target's parent — which is under the - generator's output root — so no ``/tmp`` access is required. """ parent = os.path.dirname(os.path.abspath(file_path)) or '.' os.makedirs(parent, exist_ok=True) @@ -100,6 +90,10 @@ def write_if_changed( try: with os.fdopen(tmp_fd, 'w', encoding=encoding) as fh: fh.write(content) + # mkstemp opens the temp file 0600; restore the umask-based default + umask = os.umask(0) + os.umask(umask) + os.chmod(tmp_path, 0o666 & ~umask) os.replace(tmp_path, file_path) except BaseException: try: From dd287f01b8689c418cda47a98ace024601b5f86f Mon Sep 17 00:00:00 2001 From: Dom Heinzeller Date: Thu, 9 Jul 2026 11:49:07 -0600 Subject: [PATCH 6/9] Simplify capgen/metadata/parse_tools/xml_tools.py: remove unneeded arguments, trim down docstring tests (covered in unit tests and end-to-end tests --- capgen/generator/suite_xml.py | 4 +- capgen/metadata/parse_tools/xml_tools.py | 225 +++-------------------- unit-tests/test_suite_xml.py | 14 +- 3 files changed, 36 insertions(+), 207 deletions(-) diff --git a/capgen/generator/suite_xml.py b/capgen/generator/suite_xml.py index fb8f50e7..bd29a351 100644 --- a/capgen/generator/suite_xml.py +++ b/capgen/generator/suite_xml.py @@ -583,7 +583,7 @@ def parse_suite_xml( # ---- schema validation (pre-expansion) -------------------------------- if not skip_validation: - validate_xml_file(suite_file, 'suite', version, log, schema_path=sdir) + validate_xml_file(suite_file, version, log, schema_path=sdir) # ---- expand nested suites (v2 only) ----------------------------------- if version[0] >= 2: @@ -605,7 +605,7 @@ def parse_suite_xml( # ---- re-validate the expanded XML (catches duplicate xs:ID errors) ---- if not skip_validation: - validate_xml_file(expanded_path, 'suite', version, log, schema_path=sdir) + validate_xml_file(expanded_path, version, log, schema_path=sdir) return suite diff --git a/capgen/metadata/parse_tools/xml_tools.py b/capgen/metadata/parse_tools/xml_tools.py index 52f80c12..c691d61f 100644 --- a/capgen/metadata/parse_tools/xml_tools.py +++ b/capgen/metadata/parse_tools/xml_tools.py @@ -5,28 +5,14 @@ """ import os -import re import shutil import subprocess -import sys import xml.etree.ElementTree as ET import xml.dom.minidom from .parse_source import CCPPError from .parse_log import init_log, set_log_to_null -_INDENT_STR = " " -beg_tag_re = re.compile(r"([<][^/][^<>]*[^/][>])") -end_tag_re = re.compile(r"([<][/][^<>/]+[>])") -simple_tag_re = re.compile(r"([<][^/][^<>/]+[/][>])") - -PYSUBVER = sys.version_info[1] -_LOGGER = None - - -class XMLToolsInternalError(ValueError): - """Internal error raised by helpers in this module.""" - def find_schema_version(root): """Return the schema version as ``[major, minor]`` from the *root*'s @@ -84,26 +70,23 @@ def find_schema_file(schema_root, version, schema_path=None): return None -def validate_xml_file(filename, schema_root, version, logger, schema_path=None): - """Validate *filename* against the matching schema using xmllint.""" +def validate_xml_file(filename, version, logger, schema_path=None): + """Validate *filename* against the suite schema for *version* using xmllint.""" if not os.path.isfile(filename): raise CCPPError("validate_xml_file: Filename, '{}', does not exist".format(filename)) if not os.access(filename, os.R_OK): raise CCPPError("validate_xml_file: Cannot open '{}'".format(filename)) - if os.path.isfile(schema_root): - schema_file = schema_root - else: - if not schema_path: - thispath = os.path.abspath(__file__) - pdir = os.path.dirname(os.path.dirname(os.path.dirname(thispath))) - schema_path = os.path.join(pdir, 'schema') - schema_file = find_schema_file(schema_root, version, schema_path) - if not (schema_file and os.path.isfile(schema_file)): - verstring = '.'.join([str(x) for x in version]) - raise CCPPError( - f"validate_xml_file: Cannot find schema for version {verstring},\n" - f" {schema_file} does not exist" - ) + if not schema_path: + thispath = os.path.abspath(__file__) + pdir = os.path.dirname(os.path.dirname(os.path.dirname(thispath))) + schema_path = os.path.join(pdir, 'schema') + schema_file = find_schema_file('suite', version, schema_path) + if not (schema_file and os.path.isfile(schema_file)): + verstring = '.'.join([str(x) for x in version]) + raise CCPPError( + f"validate_xml_file: Cannot find suite schema for version {verstring},\n" + f" {schema_file} does not exist" + ) if not os.access(schema_file, os.R_OK): raise CCPPError( "validate_xml_file: Cannot open schema, '{}'".format(schema_file)) @@ -177,35 +160,6 @@ def load_suite_by_name(suite_name, group_name, file, logger=None): ------- xml.etree.ElementTree.Element The matching suite or group element. - - Examples - -------- - >>> import tempfile - >>> import xml.etree.ElementTree as ET - >>> logger = init_log('xml_tools') - >>> set_log_to_null(logger) - >>> tmpdir = tempfile.TemporaryDirectory() - >>> file1_path = os.path.join(tmpdir.name, "file1.xml") - >>> with open(file1_path, "w") as f: - ... _ = f.write(''' - ... - ... - ... - ... - ... ''') - >>> load_suite_by_name("physics_suite", None, file1_path, logger).tag - 'suite' - >>> load_suite_by_name("physics_suite", "dynamics", file1_path, logger).attrib['name'] - 'dynamics' - >>> load_suite_by_name("physics_suite", "missing_group", file1_path, logger) #doctest: +IGNORE_EXCEPTION_DETAIL - Traceback (most recent call last): - ... - CCPPError: Nested suite physics_suite, group missing_group, not found - >>> load_suite_by_name("missing_suite", None, file1_path, logger) #doctest: +IGNORE_EXCEPTION_DETAIL - Traceback (most recent call last): - ... - CCPPError: Nested suite missing_suite not found - >>> tmpdir.cleanup() """ _, root = read_xml_file(file, logger) try: @@ -214,7 +168,7 @@ def load_suite_by_name(suite_name, group_name, file, logger=None): raise CCPPError( f"{verr} in nested suite XML file '{file}'" ) from verr - if not validate_xml_file(file, 'suite', schema_version, logger): + if not validate_xml_file(file, schema_version, logger): raise CCPPError(f"Invalid suite definition file, '{file}'") if root.attrib.get("name") == suite_name: if group_name: @@ -246,66 +200,6 @@ def replace_nested_suite(element, nested_suite, default_path, logger): ------- str Name of the suite that was substituted in. - - Examples - -------- - >>> import tempfile - >>> import xml.etree.ElementTree as ET - >>> logger = init_log('xml_tools') - >>> set_log_to_null(logger) - >>> tmpdir = tempfile.TemporaryDirectory() - >>> file1_path = os.path.join(tmpdir.name, "file1.xml") - >>> with open(file1_path, "w") as f: - ... _ = f.write(''' - ... - ... - ... my_scheme - ... - ... - ... ''') - >>> xml = f''' - ... - ... - ... - ... ''' - >>> top_suite = ET.fromstring(xml) - >>> nested = top_suite.find("nested_suite") - >>> replace_nested_suite(top_suite, nested, tmpdir.name, logger) - 'my_suite' - >>> [child.tag for child in top_suite] - ['group'] - >>> top_suite.find("group").find("scheme").text - 'my_scheme' - >>> xml = f''' - ... - ... - ... - ... - ... - ... ''' - >>> top_suite = ET.fromstring(xml) - >>> top_group = top_suite.find("group") - >>> nested = top_group.find("nested_suite") - >>> replace_nested_suite(top_group, nested, tmpdir.name, logger) - 'my_suite' - >>> [child.tag for child in top_suite] - ['group'] - >>> top_suite.find("group").find("scheme").text - 'my_scheme' - >>> xml = f''' - ... - ... - ... - ... ''' - >>> top_suite = ET.fromstring(xml) - >>> nested = top_suite.find("nested_suite") - >>> replace_nested_suite(top_suite, nested, tmpdir.name, logger) - 'my_suite' - >>> [child.tag for child in top_suite] - ['group'] - >>> top_suite.find("group").find("scheme").text - 'my_scheme' - >>> tmpdir.cleanup() """ suite_name = nested_suite.attrib.get("name") group_name = nested_suite.attrib.get("group") @@ -342,90 +236,25 @@ def expand_nested_suites(suite, default_path, logger=None): Examples -------- + Expand a single group-less ```` reference in place (error + paths — missing suites/groups, cycle detection — are covered by + ``test_suite_xml.py``): + >>> import tempfile >>> import xml.etree.ElementTree as ET >>> logger = init_log('xml_tools') >>> set_log_to_null(logger) >>> tmpdir = tempfile.TemporaryDirectory() - >>> file1_path = os.path.join(tmpdir.name, "file1.xml") - >>> file2_path = os.path.join(tmpdir.name, "file2.xml") - >>> file3_path = os.path.join(tmpdir.name, "file3.xml") - >>> file4_path = os.path.join(tmpdir.name, "file4.xml") - >>> file5_path = os.path.join(tmpdir.name, "file5.xml") - >>> with open(file1_path, "w") as f: - ... _ = f.write(''' - ... - ... - ... cloud_scheme - ... - ... - ... ''') - >>> with open(file2_path, "w") as f: - ... _ = f.write(''' - ... - ... - ... pbl_scheme - ... - ... - ... ''') - >>> with open(file3_path, "w") as f: - ... _ = f.write(''' - ... - ... - ... rrtmg_lw_scheme - ... - ... - ... rrtmg_sw_scheme - ... - ... - ... ''') - >>> with open(file4_path, "w") as f: - ... _ = f.write(f''' - ... - ... - ... - ... ''') - >>> with open(file5_path, "w") as f: - ... _ = f.write(f''' - ... - ... - ... - ... ''') - >>> xml_content = f''' - ... - ... - ... - ... - ... - ... - ... - ... ''' - >>> suite = ET.fromstring(xml_content) + >>> ref = os.path.join(tmpdir.name, "pbl.xml") + >>> with open(ref, "w") as f: + ... _ = f.write('' + ... 'pbl_scheme') + >>> suite = ET.fromstring( + ... f'' + ... f'') >>> expand_nested_suites(suite, tmpdir.name, logger) - >>> ET.dump(suite) - - - cloud_scheme - - pbl_scheme - - rrtmg_lw_scheme - - rrtmg_sw_scheme - - >>> xml_content = f''' - ... - ... - ... - ... - ... - ... - ... ''' - >>> suite = ET.fromstring(xml_content) - >>> expand_nested_suites(suite, tmpdir.name, logger) #doctest: +IGNORE_EXCEPTION_DETAIL - Traceback (most recent call last): - ... - CCPPError: Exceeded number of iterations while expanding nested suites + >>> [g.attrib["name"] for g in suite.findall("group")] + ['pbl'] >>> tmpdir.cleanup() """ max_iterations = 10 diff --git a/unit-tests/test_suite_xml.py b/unit-tests/test_suite_xml.py index 11d94bac..987dae7f 100644 --- a/unit-tests/test_suite_xml.py +++ b/unit-tests/test_suite_xml.py @@ -651,7 +651,7 @@ def test_good_v2_suite_validates(self): _, root = read_xml_file(_sample('suite_good_v2_test01.xml'), self._log) version = find_schema_version(root) result = validate_xml_file( - _sample('suite_good_v2_test01.xml'), 'suite', version, self._log, + _sample('suite_good_v2_test01.xml'), version, self._log, schema_path=_SCHEMA_DIR ) self.assertTrue(result) @@ -662,7 +662,7 @@ def test_bad_suite_tag_rejected(self): version = find_schema_version(root) try: result = validate_xml_file( - _sample('suite_bad_v2_suite_tag.xml'), 'suite', version, + _sample('suite_bad_v2_suite_tag.xml'), version, self._log, schema_path=_SCHEMA_DIR ) # Some xmllint versions return True even on error @@ -677,7 +677,7 @@ def test_invalid_fortran_id_scheme_rejected(self): version = find_schema_version(root) with self.assertRaises(CCPPError) as cm: validate_xml_file( - _sample('suite_invalid_scheme_fortran_id.xml'), 'suite', + _sample('suite_invalid_scheme_fortran_id.xml'), version, self._log, schema_path=_SCHEMA_DIR ) self.assertIn("scheme-1", str(cm.exception)) @@ -689,7 +689,7 @@ def test_invalid_fortran_id_group_rejected(self): version = find_schema_version(root) with self.assertRaises(CCPPError) as cm: validate_xml_file( - _sample('suite_invalid_group_fortran_id.xml'), 'suite', + _sample('suite_invalid_group_fortran_id.xml'), version, self._log, schema_path=_SCHEMA_DIR ) self.assertIn("group-1", str(cm.exception)) @@ -701,7 +701,7 @@ def test_invalid_fortran_id_suite_rejected(self): version = find_schema_version(root) with self.assertRaises(CCPPError) as cm: validate_xml_file( - _sample('suite_invalid_suite_fortran_id.xml'), 'suite', + _sample('suite_invalid_suite_fortran_id.xml'), version, self._log, schema_path=_SCHEMA_DIR ) self.assertIn("ver-test-suite", str(cm.exception)) @@ -712,7 +712,7 @@ def test_duplicate_group_name_rejected_after_expansion(self): version = find_schema_version(root) # Initial file validates OK result = validate_xml_file( - _sample('suite_bad_v2_duplicate_group.xml'), 'suite', version, + _sample('suite_bad_v2_duplicate_group.xml'), version, self._log, schema_path=_SCHEMA_DIR ) self.assertTrue(result) @@ -721,7 +721,7 @@ def test_duplicate_group_name_rejected_after_expansion(self): expanded_path = os.path.join(self._tmp, 'dup_group_expanded.xml') write_xml_file(root, expanded_path, self._log) with self.assertRaises(CCPPError) as cm: - validate_xml_file(expanded_path, 'suite', version, self._log, + validate_xml_file(expanded_path, version, self._log, schema_path=_SCHEMA_DIR) self.assertIn('group1', str(cm.exception)) From 0373c01f5b68308234be68c17ad7dbd129dfb89f Mon Sep 17 00:00:00 2001 From: Dom Heinzeller Date: Thu, 9 Jul 2026 15:51:44 -0600 Subject: [PATCH 7/9] Strip down capgen/metadata/parse_tools/parse_checkers.py and update capgen/metadata/metadata_table.py accordingly --- capgen/metadata/metadata_table.py | 6 +- capgen/metadata/parse_tools/__init__.py | 3 +- capgen/metadata/parse_tools/parse_checkers.py | 194 ++++-------------- 3 files changed, 45 insertions(+), 158 deletions(-) diff --git a/capgen/metadata/metadata_table.py b/capgen/metadata/metadata_table.py index 995ca7c6..2db48f18 100644 --- a/capgen/metadata/metadata_table.py +++ b/capgen/metadata/metadata_table.py @@ -88,7 +88,7 @@ ParseSyntaxError, check_cf_standard_name, check_units, - check_dimensions, + check_dimension, check_diagnostic_fixed, check_diagnostic_id, check_fortran_id, @@ -317,7 +317,7 @@ def _parse_dimensions(value: str, context: ParseContext) -> List[str]: "empty dimension entry in '{}'".format(value), context=context ) - check_dimensions([part], None, error=True) + check_dimension(part) # Lowercase every non-integer token so the resolver's # host_dict lookups succeed regardless of the user's metadata # casing. Range form ``lower:upper`` lowercases each half; @@ -636,7 +636,7 @@ def set_attr(self, key: str, value: str, context: ParseContext) -> None: # check_cf_standard_name (which lowercases) so mixed-case # legacy spellings are captured. No-op otherwise. self.standard_name = legacy_compat.translate( - check_cf_standard_name(value, None, error=True)) + check_cf_standard_name(value)) elif key == 'long_name': self.long_name = value elif key == 'units': diff --git a/capgen/metadata/parse_tools/__init__.py b/capgen/metadata/parse_tools/__init__.py index 7b281219..6994db2e 100644 --- a/capgen/metadata/parse_tools/__init__.py +++ b/capgen/metadata/parse_tools/__init__.py @@ -8,13 +8,12 @@ from .parse_log import init_log, set_log_level, set_log_to_null from .parse_checkers import ( check_units, - check_dimensions, + check_dimension, check_cf_standard_name, check_diagnostic_fixed, check_diagnostic_id, check_fortran_id, check_fortran_ref, - check_fortran_type, check_fortran_intrinsic, check_molar_mass, # auto-clone-constituents: legacy-shim checkers exported here so diff --git a/capgen/metadata/parse_tools/parse_checkers.py b/capgen/metadata/parse_tools/parse_checkers.py index 63bed4ab..502fc349 100644 --- a/capgen/metadata/parse_tools/parse_checkers.py +++ b/capgen/metadata/parse_tools/parse_checkers.py @@ -52,63 +52,39 @@ def check_units(test_val, prop_dict, error): return test_val -def check_dimensions(test_val, prop_dict, error, max_len=0): - """Return if a valid dimensions list, otherwise, None - If > 0, each string in must not be longer than - . - if is True, raise an Exception if is not valid. - >>> check_dimensions(["dim1", "dim2name"], None, False) - ['dim1', 'dim2name'] - >>> check_dimensions([":", ":"], None, False) - [':', ':'] - >>> check_dimensions(["8", "::"], None, False) - ['8', '::'] - >>> check_dimensions(['start1:end1', 'start2:end2'], None, False) - ['start1:end1', 'start2:end2'] - >>> check_dimensions(['size(foo)'], None, False) - ['size(foo)'] - >>> check_dimensions(['size(foo,1'], None, False) #doctest: +IGNORE_EXCEPTION_DETAIL - Traceback (most recent call last): - CCPPError: Invalid dimension component, size(foo,1 - >>> check_dimensions(["dim1", "dim2name"], None, True, max_len=5) #doctest: +IGNORE_EXCEPTION_DETAIL +def check_dimension(test_val): + """Return if a valid single dimension entry, else raise CCPPError. + + A dimension entry is a colon-separated range (``lower``, ``lower:upper``, + or ``lower:upper:stride``) whose non-empty bounds are each an integer + literal or a Fortran identifier. Integer literals are valid in any bound + position; semantic restrictions (e.g. horizontal_dimension lower bound + must be 1) are enforced by the resolver, not here. + >>> check_dimension("dim2name") + 'dim2name' + >>> check_dimension("8") + '8' + >>> check_dimension(":") + ':' + >>> check_dimension("start:end") + 'start:end' + >>> check_dimension("ccpp_constant_one:1") + 'ccpp_constant_one:1' + >>> check_dimension("a:b:c:d") #doctest: +IGNORE_EXCEPTION_DETAIL Traceback (most recent call last): - CCPPError: 'dim2name' is too long (> 5 chars) - >>> check_dimensions("hi_mom", None, True) #doctest: +IGNORE_EXCEPTION_DETAIL + CCPPError: 'a:b:c:d' is an invalid dimension range + >>> check_dimension("hi mom") #doctest: +IGNORE_EXCEPTION_DETAIL Traceback (most recent call last): - CCPPError: 'hi_mom' is invalid; not a list - >>> check_dimensions(["ccpp_constant_one:1", "dim2name"], None, True) - ['ccpp_constant_one:1', 'dim2name'] + CCPPError: 'hi mom' is not a valid Fortran identifier """ - if not isinstance(test_val, list): - if error: - raise CCPPError("'{}' is invalid; not a list".format(test_val)) - return None - for item in test_val: - isplit = item.split(':') - if len(isplit) > 3: - if error: - raise CCPPError("'{}' is an invalid dimension range".format(item)) - return None - # Integer literals are valid in any bound position; semantic - # restrictions (e.g. horizontal_dimension lower bound must be 1) - # are enforced by the resolver, not here. - tdims = [x.strip() for x in isplit if len(x) > 0] - for tdim in tdims: - try: - int(tdim) - valid = True - except ValueError: - valid = check_fortran_id(tdim, None, error, - max_len=max_len) is not None - if not valid and tdim.strip().lower()[0:4] == 'size': - if -1 in check_balanced_paren(tdim[4:]): - raise CCPPError( - 'Invalid dimension component, {}'.format(tdim)) - valid = True - if not valid: - if error: - raise CCPPError(f"'{item}' is an invalid dimension name") - return None + isplit = test_val.split(':') + if len(isplit) > 3: + raise CCPPError("'{}' is an invalid dimension range".format(test_val)) + for tdim in [x.strip() for x in isplit if len(x) > 0]: + try: + int(tdim) + except ValueError: + check_fortran_id(tdim, None, error=True) return test_val @@ -116,27 +92,25 @@ def check_dimensions(test_val, prop_dict, error, max_len=0): __CFID_RE = re.compile(CF_ID + r"$") -def check_cf_standard_name(test_val, prop_dict, error): - """Return if a valid CF Standard Name, otherwise, None. +def check_cf_standard_name(test_val): + """Return the lowercased if a valid CCPP Standard Name, + otherwise raise CCPPError. http://cfconventions.org/Data/cf-standard-names/docs/guidelines.html - if is True, raise an Exception if is not valid. - >>> check_cf_standard_name("hi_mom", None, False) + >>> check_cf_standard_name("hi_mom") 'hi_mom' - >>> check_cf_standard_name("hi mom", None, False) - - >>> check_cf_standard_name("", None, False) #doctest: +IGNORE_EXCEPTION_DETAIL + >>> check_cf_standard_name("Agood4tranID") + 'agood4tranid' + >>> check_cf_standard_name("") #doctest: +IGNORE_EXCEPTION_DETAIL Traceback (most recent call last): CCPPError: CCPP Standard Name cannot be blank - >>> check_cf_standard_name("Agood4tranID", None, False) - 'agood4tranid' + >>> check_cf_standard_name("hi mom") #doctest: +IGNORE_EXCEPTION_DETAIL + Traceback (most recent call last): + CCPPError: 'hi mom' is not a valid CCPP Standard Name """ if len(test_val) == 0: raise CCPPError("CCPP Standard Name cannot be blank") if __CFID_RE.match(test_val) is None: - if error: - raise CCPPError( - "'{}' is not a valid CCPP Standard Name".format(test_val)) - return None + raise CCPPError("'{}' is not a valid CCPP Standard Name".format(test_val)) return test_val.lower() @@ -156,8 +130,6 @@ def check_cf_standard_name(test_val, prop_dict, error): "double precision", "character"] FORTRAN_DP_RE = re.compile(r"(?i)double\s*precision") -_REGISTERED_FORTRAN_DDT_NAMES = ["ccpp_constituent_prop_ptr_t"] - def check_fortran_id(test_val, prop_dict, error, max_len=0): """Return if a valid Fortran identifier, otherwise, None @@ -261,31 +233,6 @@ def check_fortran_intrinsic(typestr, error=False): return typestr -def check_fortran_type(typestr, prop_dict, error): - """Return if a valid Fortran type, otherwise, None - if is True, raise an Exception if is not valid. - >>> check_fortran_type("real", None, False) - 'real' - >>> check_fortran_type("char", {}, True) #doctest: +IGNORE_EXCEPTION_DETAIL - Traceback (most recent call last): - CCPPError: 'char' is not a valid Fortran type - >>> check_fortran_type("type", {}, True) #doctest: +IGNORE_EXCEPTION_DETAIL - Traceback (most recent call last): - CCPPError: 'type' is not a valid derived Fortran type - """ - dt = "" - match = check_fortran_intrinsic(typestr, error=False) - if match is None: - match = registered_fortran_ddt_name(typestr) - dt = " derived" - if match is None: - if error: - raise CCPPError( - "'{}' is not a valid{} Fortran type".format(typestr, dt)) - return None - return typestr - - def check_diagnostic_fixed(test_val, prop_dict, error): """Return if a valid descriptor for a CCPP diagnostic, otherwise, None. @@ -601,62 +548,3 @@ def check_mixing_ratio_type(test_val, prop_dict, error): return None # auto-clone-constituents: END legacy-shim checkers. - - -def check_balanced_paren(string, start=0, error=False): - """Return indices delineating a balance set of parentheses. - Parentheses in character context do not count. - Left parenthesis search begins at . - Return start and end indices if found - If no parentheses are found, return (-1, -1). - If a left parenthesis is found but no balancing right, return (begin, -1) - where begin is the index where the left parenthesis was found. - If error is True, raise a CCPPError. - >>> check_balanced_paren("foo") - (-1, -1) - >>> check_balanced_paren("(foo, bar)") - (0, 9) - >>> check_balanced_paren("(size(foo,1), qux)") - (0, 17) - >>> check_balanced_paren("(foo('bar()'))") - (0, 13) - >>> check_balanced_paren("(foo('bar()')") - (0, -1) - >>> check_balanced_paren("(foo('bar()')", error=True) #doctest: +IGNORE_EXCEPTION_DETAIL - Traceback (most recent call last): - CCPPError: ERROR: Unbalanced parenthesis in '(foo('bar()')' - """ - index = start - begin = -1 - end = -1 - depth = 0 - inchar = None - str_len = len(string) - while index < str_len: - c = string[index] - if c in ('"', "'"): - if inchar == c: - inchar = None - elif inchar is None: - inchar = c - elif inchar is not None: - pass - elif c == '(': - if depth == 0: - begin = index - depth += 1 - elif c == ')': - depth -= 1 - if depth == 0: - end = index - break - index += 1 - if begin >= 0 and end < 0 and error: - raise CCPPError("ERROR: Unbalanced parenthesis in '{}'".format(string)) - return begin, end - - -def registered_fortran_ddt_name(name): - if name in _REGISTERED_FORTRAN_DDT_NAMES: - return name - return None From d2cb10b3b0009d5caba243b7ca6b7a4c0b6d9a13 Mon Sep 17 00:00:00 2001 From: Courtney Peverley Date: Thu, 9 Jul 2026 20:07:08 -0600 Subject: [PATCH 8/9] [feature/capgen-v1-cleanup-01] Make constituent index lookup routines case insensitive (#766) Adds to_lower routine to capgen/src/ccpp_constituent_prop_mod.F90 and uses that to make the ccpp_constituent_index, ccpp_constituent_indices, and _const_get_index interfaces case insensitive. --- capgen/generator/host_constituents.py | 3 +- capgen/src/ccpp_constituent_prop_mod.F90 | 30 ++++++++++++++++++- capgen/src/ccpp_scheme_utils.F90 | 7 +++-- end-to-end-tests/advection/cld_liq.F90 | 2 +- end-to-end-tests/advection/test_host_data.F90 | 2 +- 5 files changed, 37 insertions(+), 7 deletions(-) diff --git a/capgen/generator/host_constituents.py b/capgen/generator/host_constituents.py index 6109bb10..72ce6db6 100644 --- a/capgen/generator/host_constituents.py +++ b/capgen/generator/host_constituents.py @@ -418,7 +418,7 @@ def _wrap_method_subs(host_dict) -> List[str]: 'ccpp_const_get_index', 'const_index', [ ('stdname', 'character(len=*), intent(in) :: stdname', - 'standard_name=stdname'), + 'standard_name=to_lower(stdname)'), ('const_index', 'integer, intent(out) :: const_index', 'index=const_index'), ], @@ -580,6 +580,7 @@ def _generate_host_constituents( lines.append('module {}'.format(_HOST_CONST_MOD)) lines.append('') lines.append('{}use ccpp_kinds, only: kind_phys'.format(_INDENT)) + lines.append('{}use ccpp_constituent_prop_mod, only: to_lower'.format(_INDENT)) lines.append('{}use {}, only: &'.format(_INDENT, _CONST_PROP_MOD)) lines.append('{}{}, &'.format(_INDENT * 2, _CONST_DDT)) lines.append('{}{}, &'.format(_INDENT * 2, _CONST_PROP_TYPE)) diff --git a/capgen/src/ccpp_constituent_prop_mod.F90 b/capgen/src/ccpp_constituent_prop_mod.F90 index dbe33f84..060277d2 100644 --- a/capgen/src/ccpp_constituent_prop_mod.F90 +++ b/capgen/src/ccpp_constituent_prop_mod.F90 @@ -211,6 +211,9 @@ module ccpp_constituent_prop_mod procedure :: constituent_props_ptr => ccp_constituent_props_ptr end type ccpp_model_constituents_t + ! Public interfaces + public to_lower + ! Private interfaces private to_str private initialize_errvars @@ -416,7 +419,7 @@ subroutine ccp_instantiate(this, std_name, long_name, diag_name, units, & else errcode = 0 errmsg = '' - this%var_std_name = trim(std_name) + this%var_std_name = trim(to_lower(std_name)) end if if (errcode == 0) then this%var_long_name = trim(long_name) @@ -2676,4 +2679,29 @@ subroutine ccpt_set_water_species(this, water_flag, errcode, errmsg) end subroutine ccpt_set_water_species + !####################################################################### + + function to_lower(str) + + character(len=*), intent(in) :: str ! String to convert to lower case + character(len=len(str)) :: to_lower + + ! Local variables + integer :: i ! Index + integer :: aseq ! ascii collating sequence + integer :: upper_to_lower ! integer to convert case + character(len=1) :: ctmp ! Character temporary + + upper_to_lower = iachar("a") - iachar("A") + + do i = 1, len(str) + ctmp = str(i:i) + aseq = iachar(ctmp) + if (aseq >= iachar("A") .and. aseq <= iachar("Z")) & + ctmp = achar(aseq + upper_to_lower) + to_lower(i:i) = ctmp + end do + + end function to_lower + end module ccpp_constituent_prop_mod diff --git a/capgen/src/ccpp_scheme_utils.F90 b/capgen/src/ccpp_scheme_utils.F90 index d4de6499..d91f0580 100644 --- a/capgen/src/ccpp_scheme_utils.F90 +++ b/capgen/src/ccpp_scheme_utils.F90 @@ -3,7 +3,7 @@ module ccpp_scheme_utils ! Module of utilities available to CCPP schemes use ccpp_constituent_prop_mod, only: ccpp_model_constituents_t, & - int_unassigned + int_unassigned, to_lower implicit none private @@ -12,6 +12,7 @@ module ccpp_scheme_utils public :: ccpp_initialize_constituent_ptr ! Used by framework to initialize public :: ccpp_constituent_index ! Lookup index constituent by name public :: ccpp_constituent_indices ! Lookup indices of consitutents by name + public :: to_lower ! Utility to convert string to lowercase !! Private module variables & interfaces @@ -81,7 +82,7 @@ subroutine ccpp_constituent_index(standard_name, const_index, errcode, errmsg) call check_initialization(caller=subname, errcode=errcode, errmsg=errmsg) if (status_ok(errcode)) then - call constituent_obj%const_index(const_index, standard_name, & + call constituent_obj%const_index(const_index, to_lower(standard_name), & errcode, errmsg) else const_index = int_unassigned @@ -110,7 +111,7 @@ subroutine ccpp_constituent_indices(standard_names, const_inds, errcode, errmsg) do indx = 1, size(standard_names) ! For each std name in , find the const. index call constituent_obj%const_index(const_inds(indx), & - standard_names(indx), errcode, errmsg) + to_lower(standard_names(indx)), errcode, errmsg) if (errcode /= 0) then exit end if diff --git a/end-to-end-tests/advection/cld_liq.F90 b/end-to-end-tests/advection/cld_liq.F90 index c0d00a43..53cd68c1 100644 --- a/end-to-end-tests/advection/cld_liq.F90 +++ b/end-to-end-tests/advection/cld_liq.F90 @@ -30,7 +30,7 @@ subroutine cld_liq_register(dyn_const, errmsg, errcode) errmsg = 'Error allocating dyn_const in cld_liq_register' return end if - call dyn_const(1)%instantiate(std_name="dyn_const3_wrt_moist_air_and_condensed_water", long_name='dyn const3', & + call dyn_const(1)%instantiate(std_name="DYN_const3_wrt_moist_air_and_condensed_water", long_name='dyn const3', & diag_name='DYNCONST3', units='kg kg-1', default_value=1._kind_phys, & vertical_dim='vertical_layer_dimension', advected=.true., & water_species=.true., mixing_ratio_type='dry', & diff --git a/end-to-end-tests/advection/test_host_data.F90 b/end-to-end-tests/advection/test_host_data.F90 index 4bcb753b..991a61ce 100644 --- a/end-to-end-tests/advection/test_host_data.F90 +++ b/end-to-end-tests/advection/test_host_data.F90 @@ -16,7 +16,7 @@ module test_host_data !! \htmlinclude arg_table_test_host_data.html integer, public, parameter :: num_consts = 3 character(len=32), public, parameter :: std_name_array(num_consts) = (/ & - 'specific_humidity ', & + 'SPECIFIC_HUMIDITY ', & 'cloud_liquid_dry_mixing_ratio', & 'cloud_ice_dry_mixing_ratio ' /) character(len=32), public, parameter :: const_std_name = std_name_array(1) From 82606d7b6b4c9feb4c1c38fb0ed65e1e37b66815 Mon Sep 17 00:00:00 2001 From: Dom Heinzeller Date: Thu, 9 Jul 2026 20:13:22 -0600 Subject: [PATCH 9/9] Follow-up to d2cb10b3b0009d5caba243b7ca6b7a4c0b6d9a13 in unit-tests/test_host_constituents.py to fix failing unit test --- unit-tests/test_host_constituents.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/unit-tests/test_host_constituents.py b/unit-tests/test_host_constituents.py index 0511c692..5f9fc867 100644 --- a/unit-tests/test_host_constituents.py +++ b/unit-tests/test_host_constituents.py @@ -628,10 +628,11 @@ def test_update_constituents(self): def test_const_get_index(self): # Keyword args ensure unambiguous mapping to the DDT's signature - # (index, standard_name, errcode, errmsg). + # (index, standard_name, errcode, errmsg). The query name is passed + # through to_lower() so constituent lookups are case-insensitive. self.assertIn( 'call ccpp_model_constituents_obj(inst_num)%const_index(' - 'standard_name=stdname, index=const_index, ' + 'standard_name=to_lower(stdname), index=const_index, ' 'errcode=errcode, errmsg=errmsg)', self.text, )