Skip to content
Merged
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 pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "rda_python_miscs"
version = "2.0.11"
version = "2.0.12"
authors = [
{ name="Zaihua Ji", email="zji@ucar.edu" },
]
Expand Down
122 changes: 110 additions & 12 deletions src/rda_python_miscs/pg_rst.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
import inspect
import argparse
import importlib
import importlib.util
from os import path as op
from rda_python_common.pg_file import PgFile
from rda_python_common.pg_util import PgUtil
Expand Down Expand Up @@ -1164,6 +1165,62 @@ def load_opts_alias(self, docname):

return opts, alias, origin

def load_opts_alias_from_pyfile(self, pyfile):
"""Load OPTS and ALIAS from a Python file given by path.

Uses ``importlib.util.spec_from_file_location`` to import the file
without requiring it to be on ``sys.path``. Resolution order mirrors
:meth:`load_opts_alias`: class attributes first, then module-level.

Args:
pyfile (str): Absolute or relative path to the Python source file.

Returns:
tuple[dict, dict]: ``(OPTS, ALIAS)`` where ALIAS defaults to ``{}``
when not found.

Raises:
SystemExit: via :func:`PgLOG.pglog` (``LGWNEX``) if the file
cannot be loaded or ``OPTS`` cannot be found.
"""
pyfile = op.abspath(pyfile)
modname = op.splitext(op.basename(pyfile))[0]
try:
spec = importlib.util.spec_from_file_location(modname, pyfile)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
except Exception as exc:
self.pglog(
"Cannot load module from '{}': {}".format(pyfile, exc),
self.LGWNEX,
)

cls = next(
(obj for _, obj in inspect.getmembers(mod, inspect.isclass)
if obj.__module__ == modname),
None,
)

if cls is not None:
obj = cls()
opts = getattr(obj, 'OPTS', None)
alias = getattr(obj, 'ALIAS', None)
Comment on lines +1200 to +1207

Copilot AI Apr 13, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

load_opts_alias_from_pyfile() selects the first class defined in the module, but it doesn’t verify that the class actually defines OPTS (or ALIAS). If the module defines any helper class before the config class, opts becomes None and the function errors even when module-level OPTS (or another class’s OPTS) exists. Consider iterating classes defined in the module and picking the first one where getattr(cls, 'OPTS', None) is not None, otherwise fall back to module-level OPTS/ALIAS (and avoid instantiating the class just to read class attributes).

Suggested change
if obj.__module__ == modname),
None,
)
if cls is not None:
obj = cls()
opts = getattr(obj, 'OPTS', None)
alias = getattr(obj, 'ALIAS', None)
if obj.__module__ == modname and getattr(obj, 'OPTS', None) is not None),
None,
)
if cls is not None:
opts = getattr(cls, 'OPTS', None)
alias = getattr(cls, 'ALIAS', None)

Copilot uses AI. Check for mistakes.
else:
opts = getattr(mod, 'OPTS', None)
alias = getattr(mod, 'ALIAS', None)

if opts is None:
self.pglog(
"File '{}' does not define OPTS (checked class and "
"module level)".format(pyfile),
self.LGWNEX,
)

if alias is None:
alias = {}

return opts, alias


# ---------------------------------------------------------------------------
# Command-line entry point
Expand All @@ -1174,40 +1231,81 @@ def main():
parser = argparse.ArgumentParser(
description=(
"Convert a .usg help document to reStructuredText (.rst) using RST templates. "
"OPTS and ALIAS are loaded from rda_python_<docname>/<docname>.py: "
"the module is searched first for module-level OPTS/ALIAS variables, "
"then for a class defined in that module that carries both as class "
"attributes."
"OPTS and ALIAS are loaded from rda_python_<docname>/<docname>.py "
"(or from --pyfile if given): "
"the module is searched first for a class that carries both as class "
"attributes, then for module-level OPTS/ALIAS variables."
Comment on lines +1236 to +1237

Copilot AI Apr 13, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The CLI description says the module is searched for “a class that carries both as class attributes”, but ALIAS is treated as optional (it defaults to {} when missing). Consider rewording this help text to match behavior (e.g., “class that defines OPTS (and optionally ALIAS)”).

Suggested change
"the module is searched first for a class that carries both as class "
"attributes, then for module-level OPTS/ALIAS variables."
"the module is searched first for a class that defines OPTS "
"(and optionally ALIAS) as class attributes, then for module-level "
"OPTS (and optionally ALIAS) variables."

Copilot uses AI. Check for mistakes.
),
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument(
'docname',
nargs='?',
default=None,
help=(
"Short document name, e.g. 'dsarch' or 'dsupdt'. "
"The module rda_python_<docname>/<docname>.py must be importable "
"and must define OPTS (and optionally ALIAS) either at module "
"level or as class attributes."
"Required unless --usgfile is given, in which case the name is "
"derived from the .usg filename by removing the extension."
),
)
parser.add_argument(
'--docdir',
'-u', '--usgfile',
default=None,
metavar='FILE',
help=(
"Path to the .usg source document. When given, docname is derived "
"from the filename by removing the .usg extension, and ORIGIN is set "
"to the directory containing the file."
),
)
parser.add_argument(
'-p', '--pyfile',
default=None,
metavar='FILE',
help=(
"Path to a Python file that defines OPTS (and optionally ALIAS) "
"either at module level or as class attributes. When given, the "
"module-import convention (rda_python_<docname>/<docname>.py) is "
"bypassed."
),
)
parser.add_argument(
'-d', '--docdir',
default=None,
metavar='DIR',
help=(
"Root directory under which the per-document RST output directory "
"is created (default: current working directory). "
"The final output lands in <docdir>/<docname>/."
"The final output lands in <docdir>/."
),
Comment on lines 1276 to 1280

Copilot AI Apr 13, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

--docdir help text says a “per-document RST output directory is created”, but process_docs() writes directly into DOCS['DOCDIR'] (no <docdir>/<docname>/ subdir is created). Consider adjusting the wording to avoid implying a per-document subdirectory.

Copilot uses AI. Check for mistakes.
)
args = parser.parse_args()

pg = PgRST()
opts, alias, origin = pg.load_opts_alias(args.docname)
pg.DOCS['ORIGIN'] = origin

# Resolve docname: explicit arg takes priority, then derive from --usgfile.
if args.docname:
docname = args.docname
elif args.usgfile:
docname = op.splitext(op.basename(args.usgfile))[0]
else:
parser.error("docname is required when --usgfile is not given")

# Set ORIGIN from --usgfile directory when provided.
if args.usgfile:
pg.DOCS['ORIGIN'] = op.dirname(op.abspath(args.usgfile)) or os.getcwd()

# Load OPTS/ALIAS: from --pyfile path or via module-import convention.
if args.pyfile:
opts, alias = pg.load_opts_alias_from_pyfile(args.pyfile)
else:
opts, alias, origin = pg.load_opts_alias(docname)
if not args.usgfile:
pg.DOCS['ORIGIN'] = origin
Comment on lines +1298 to +1304

Copilot AI Apr 13, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When --pyfile is provided (and --usgfile is not), ORIGIN remains the default os.getcwd(). This makes parse_docs() look for <cwd>/<docname>.usg, which is inconsistent with the load_opts_alias() path-based behavior (it derives ORIGIN from the module’s location). Consider setting pg.DOCS['ORIGIN'] to the directory containing --pyfile (or returning an origin from load_opts_alias_from_pyfile()) so the default .usg lookup works when bypassing the import convention.

Copilot uses AI. Check for mistakes.

if args.docdir is not None:
pg.DOCS['DOCDIR'] = args.docdir
pg.process_docs(args.docname, opts, alias)
pg.process_docs(docname, opts, alias)

if __name__ == "__main__":
main()
Loading