Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/end-to-end-tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/unit-tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
3 changes: 2 additions & 1 deletion capgen/generator/host_constituents.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
],
Expand Down Expand Up @@ -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))
Expand Down
4 changes: 2 additions & 2 deletions capgen/generator/suite_xml.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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

Expand Down
6 changes: 3 additions & 3 deletions capgen/metadata/metadata_table.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@
ParseSyntaxError,
check_cf_standard_name,
check_units,
check_dimensions,
check_dimension,
check_diagnostic_fixed,
check_diagnostic_id,
check_fortran_id,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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':
Expand Down
7 changes: 2 additions & 5 deletions capgen/metadata/parse_tools/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,17 @@
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_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
Expand Down
23 changes: 22 additions & 1 deletion capgen/metadata/parse_tools/fortran_conditional.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]))
64 changes: 29 additions & 35 deletions capgen/metadata/parse_tools/io_helpers.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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 <path>"`` if the file was newly written or
rewritten, or ``"Unchanged: <path>"`` 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)
Expand All @@ -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:
Expand Down
Loading
Loading