Skip to content
Merged
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
278 changes: 135 additions & 143 deletions src/rda_python_miscs/pg_rst.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@
import argparse
import importlib
from os import path as op
from rda_python_common.pg_LOG import PgLOG
from rda_python_common.pg_file import PgFile
from rda_python_common.pg_util import PgUtil

Expand Down Expand Up @@ -169,8 +168,10 @@ def parse_docs(self, docname):

Lines beginning with ``#`` are treated as comments and skipped. Inline
trailing comments are also stripped. Angle-bracketed uppercase tokens
(e.g. ``<FILENAME>``) are temporarily escaped to ``&lt;FILENAME&gt;``
so they are not misidentified as option markers later in processing.
(e.g. ``<FILENAME>``) are temporarily escaped to ``&ltFILENAME&gt``
so they are not misidentified as option markers (``<:>``, ``<=>``,
``<!>``) later in processing. They are unescaped back to ``<FILENAME>``
in :meth:`replace_option_link` before appearing in RST output.

Args:
docname (str): Short document name used to locate ``<ORIGIN>/<docname>.usg``.
Expand All @@ -179,48 +180,47 @@ def parse_docs(self, docname):
self.pglog("Parsing info for Document '{}'".format(docname), self.LOGWRN)
section = self.init_section('0', "Preface")
option = example = None
fh = open(docfile, 'r')
line = fh.readline()
while line:
if re.match(r'\s*#', line):
line = fh.readline()
continue # skip comment lines
ms = re.match(r'^(.*\S)\s+#', line)
if ms:
line = ms.group(1) # remove comments
else:
line = line.rstrip() # remove trailing white spaces

# Temporarily escape <UPPERCASE> tokens so they are not confused
# with special markers like <:>, <=>, <!> used in option parsing.
while True:
ms = re.search(r'(<([A-Z/\-\.]+)>)', line)
with open(docfile, 'r') as fh:
line = fh.readline()
while line:
if re.match(r'\s*#', line):
line = fh.readline()
continue # skip comment lines
ms = re.match(r'^(.*\S)\s+#', line)
if ms:
line = line.replace(ms.group(1), "&lt{}&gt".format(ms.group(2)))
line = ms.group(1) # remove comments
else:
break
ms = re.match(r'^([\d\.]+)\s+(.+)$', line)
if ms: # start new section
section = self.record_section(section, option, example, ms.group(1), ms.group(2))
option = example = None
else:
ms = re.match(r'^ -([A-Z]{2}) or -\w+(.*)$', line)
if ms: # found new option
option = self.record_option(section, option, example, ms.group(1), ms.group(2))
example = None
elif option:
ms = re.match(r'^ For( | another )example, (.*)$', line)
if ms: # found example
example = self.record_example(option, example, ms.group(2))
elif example:
example['desc'] += line + "\n"
line = line.rstrip() # remove trailing white spaces

# Temporarily escape <UPPERCASE> tokens so they are not confused
# with special markers like <:>, <=>, <!> used in option parsing.
while True:
ms = re.search(r'(<([A-Z/\-\.]+)>)', line)
if ms:
line = line.replace(ms.group(1), "&lt{}&gt".format(ms.group(2)))
else:
option['desc'] += line + "\n"
break
ms = re.match(r'^([\d\.]+)\s+(.+)$', line)
if ms: # start new section
section = self.record_section(section, option, example, ms.group(1), ms.group(2))
option = example = None
else:
section['desc'] += line + "\n"
ms = re.match(r'^ -([A-Z]{2}) or -\w+(.*)$', line)
if ms: # found new option
option = self.record_option(section, option, example, ms.group(1), ms.group(2))
example = None
elif option:
ms = re.match(r'^ For( | another )example, (.*)$', line)
if ms: # found example
example = self.record_example(option, example, ms.group(2))
elif example:
example['desc'] += line + "\n"
else:
option['desc'] += line + "\n"
else:
section['desc'] += line + "\n"

line = fh.readline()
fh.close()
line = fh.readline()

self.record_section(section, option, example)

Expand Down Expand Up @@ -323,15 +323,14 @@ def init_section(self, secid, title):
dict: New section dict with keys ``secid``, ``title``, ``desc``,
``level``, and ``opts``.
"""
level = secid.count('.') + 1
section = {
'secid' : secid,
'title' : title,
'desc' : "",
'level' : 0,
'level' : level,
'opts' : []
}
level = len(re.split(r'\.', secid))
section['level'] = level
if level == 1:
if re.match(r'^ACTION', section['title']):
self.SECIDS['Action'] = secid
Expand Down Expand Up @@ -397,9 +396,7 @@ def init_example(self, opt, desc):
Returns:
dict: New example dict with keys ``opt``, ``title``, and ``desc``.
"""
example = {'opt' : opt, 'title' : "", 'desc' : desc.title() + "\n"}

return example
return {'opt' : opt, 'title' : "", 'desc' : desc.title() + "\n"}

#
# write the entry file: index.rst
Expand Down Expand Up @@ -471,32 +468,28 @@ def template_to_rst(self, template, hash, extra=None):
if extra is None: extra = ""
rstfile = "{}/{}{}.rst".format(self.DOCS['DOCDIR'], template, extra)

tf = open(tempfile, 'r')
rf = open(rstfile, 'w')
idx = 0
line = tf.readline()
while line:
idx += 1
if re.match(r'\s*#', line):
line = tf.readline()
continue # skip comment lines
ms = re.match(r'^(.*\S)\s+#', line)
if ms:
line = ms.group(1) # remove comments
else:
line = line.rstrip() # remove trailing white spaces

matches = re.findall(r'__([A-Z]+)__', line)
if matches:
for key in matches:
if key not in hash: self.pglog("{}: not defined at {}({}) {}".format(key, line, idx, tempfile), self.LGWNEX)
if not hash[key]: self.pglog(key + ": empty content", self.LGWNEX)
line = line.replace("__{}__".format(key), hash[key])
rf.write(line + "\n")
with open(tempfile, 'r') as tf, open(rstfile, 'w') as rf:
idx = 0
line = tf.readline()

tf.close()
rf.close()
while line:
idx += 1
if re.match(r'\s*#', line):
line = tf.readline()
continue # skip comment lines
ms = re.match(r'^(.*\S)\s+#', line)
if ms:
line = ms.group(1) # remove comments
else:
line = line.rstrip() # remove trailing white spaces

matches = re.findall(r'__([A-Z]+)__', line)
if matches:
for key in matches:
if key not in hash: self.pglog("{}: not defined at {}({}) {}".format(key, line, idx, tempfile), self.LGWNEX)
if not hash[key]: self.pglog(key + ": empty content", self.LGWNEX)
line = line.replace("__{}__".format(key), hash[key])
rf.write(line + "\n")
line = tf.readline()
self.pglog("{}{}.rst created from {}.rst.temp".format(template, extra, template), self.LOGWRN)

#
Expand Down Expand Up @@ -685,12 +678,10 @@ def replace_option_link(self, line, csecid, ptype=None, dtype=None):
elif ptype == 2:
opts = re.findall(r'(-\(*)([a-zA-Z]{2,})(\W|$)', line)
ms = re.match(r'^\s*%s(\s+[\w\.]+\s+|\s+)([a-zA-Z]{2})(\s)' % self.DOCS['DOCNAM'], line)
# list.insert() returns None; prepend the match groups explicitly.
if ms: opts = [ms.groups()] + opts
else:
opts = re.findall(r'(^-\(*|\W-\(*)([a-zA-Z]{2,})(\W|$)', line)

if opts is None: opts = []
for optary in opts:
opt = self.get_short_option(optary[1])
pre = optary[0]
Expand All @@ -712,7 +703,6 @@ def replace_option_link(self, line, csecid, ptype=None, dtype=None):
after = ')'

replace = pre + opt + after
if re.search(r'<!>', after): after = after.replace(r'<!>', '<&#33;>')
link = "{}`{} <{}>`_{}".format(pre, opt, link, after)
line = line.replace(replace, link)

Expand Down Expand Up @@ -753,6 +743,10 @@ def replace_option_link(self, line, csecid, ptype=None, dtype=None):
link = self.Q1 + opt + self.Q2
line = line.replace(replace, link)

# Unescape <UPPERCASE> tokens that were temporarily escaped during
# parsing to avoid confusion with option markers (<:>, <=>, <!>).
line = line.replace('&lt', '<').replace('&gt', '>')

return line

#
Expand Down Expand Up @@ -781,7 +775,7 @@ def create_description(self, desc, secid, dtype):
ptype = 0 # paragraph type: 0 - normal, 1 - table, 2 = synopsis
content = ''
cnt = 0
alllines = re.split(r'\n', desc)
alllines = desc.split('\n')
lines = []
for line in alllines:
if re.match(r'^\s*\S', line):
Expand Down Expand Up @@ -1078,7 +1072,7 @@ def get_short_option(self, p):

for opt in self.ALIAS:
for alias in self.ALIAS[opt]:
if re.match(r'^{}$'.format(alias), p, re.I): return opt
if re.match(r'^{}$'.format(alias), p, re.I): return opt

self.pglog("{} - unknown option for {}".format(p, self.DOCS['DOCNAM']), self.LGWNEX)

Expand Down Expand Up @@ -1120,84 +1114,82 @@ def get_section(self, secid):

self.pglog("Unknown Section ID {}".format(secid), self.LGWNEX)

def load_opts_alias(self, docname):
"""Import ``rda_python_<docname>.<docname>`` and return its ``(OPTS, ALIAS, origin)`` triple.

# ---------------------------------------------------------------------------
# Command-line entry point
# ---------------------------------------------------------------------------

def _load_opts_alias(docname):
"""Import ``rda_python_<docname>.<docname>`` and return its ``(OPTS, ALIAS, origin)`` triple.
Resolution order for OPTS / ALIAS:

Resolution order for OPTS / ALIAS:
1. Module-level ``OPTS`` / ``ALIAS`` attributes.
2. The first class *defined in that module* that carries both ``OPTS``
and ``ALIAS`` as class-level attributes.

1. Module-level ``OPTS`` / ``ALIAS`` attributes.
2. The first class *defined in that module* that carries both ``OPTS``
and ``ALIAS`` as class-level attributes.
``ALIAS`` is optional; an empty dict is returned when not found.

``ALIAS`` is optional; an empty dict is returned when not found.
The ``origin`` value is the absolute path of the directory that contains
``<docname>.py`` (i.e. ``rda_python_<docname>/``), derived from
``mod.__file__``. It is intended to be assigned to
``PgRST.DOCS['ORIGIN']`` so that :meth:`PgRST.parse_docs` looks for the

Copilot AI Mar 25, 2026

Copy link

Choose a reason for hiding this comment

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

The docstring refers to assigning the returned origin to PgRST.DOCS['ORIGIN'], but DOCS is an instance attribute initialized in __init__. To avoid confusing consumers, update this to self.DOCS['ORIGIN'] (or describe it as an instance field).

Suggested change
``PgRST.DOCS['ORIGIN']`` so that :meth:`PgRST.parse_docs` looks for the
``self.DOCS['ORIGIN']`` so that :meth:`PgRST.parse_docs` looks for the

Copilot uses AI. Check for mistakes.
``.usg`` source file in the same location as the document module.

The ``origin`` value is the absolute path of the directory that contains
``<docname>.py`` (i.e. ``rda_python_<docname>/``), derived from
``mod.__file__``. It is intended to be assigned to
``PgRST.DOCS['ORIGIN']`` so that :meth:`PgRST.parse_docs` looks for the
``.usg`` source file in the same location as the document module.
Args:
docname (str): Short document name used to build the module path
``rda_python_<docname>.<docname>``.

Args:
docname (str): Short document name used to build the module path
``rda_python_<docname>.<docname>``.
Returns:
tuple[dict, dict, str]: ``(OPTS, ALIAS, origin)`` where *origin* is
the absolute directory path of the imported module file.

Returns:
tuple[dict, dict, str]: ``(OPTS, ALIAS, origin)`` where *origin* is
the absolute directory path of the imported module file.
Raises:
SystemExit: via :func:`PgLOG.pglog` (``LGWNEX``) if the module

Copilot AI Mar 25, 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 now reports errors via self.pglog(...), but the docstring still says the SystemExit is raised via PgLOG.pglog. Please update the docstring to match the current behavior (and the instance log level constant used).

Suggested change
SystemExit: via :func:`PgLOG.pglog` (``LGWNEX``) if the module
SystemExit: via :meth:`PgRST.pglog` (``self.LGWNEX``) if the module

Copilot uses AI. Check for mistakes.
cannot be imported or ``OPTS`` cannot be found.
"""
modname = "rda_python_{}.{}".format(docname, docname)
try:
mod = importlib.import_module(modname)
except ImportError as exc:
self.pglog(
"Cannot import module '{}': {}".format(modname, exc),
self.LGWNEX,
)

# Derive ORIGIN from the module's own file path.
origin = op.dirname(op.abspath(mod.__file__))

# 1. Try module-level attributes first.
opts = getattr(mod, 'OPTS', None)
alias = getattr(mod, 'ALIAS', None)

# 2. Fall back to the first class in the module that owns both.
if opts is None or alias is None:
for _, obj in inspect.getmembers(mod, inspect.isclass):
if obj.__module__ == modname:
cls_opts = getattr(obj, 'OPTS', None)
cls_alias = getattr(obj, 'ALIAS', None)
if cls_opts is not None:
if opts is None: opts = cls_opts
if alias is None: alias = cls_alias
break

if opts is None:
self.pglog(
"Module '{}' does not define OPTS (checked module level and "
"all classes defined in the module)".format(modname),
self.LGWNEX,
)

# ALIAS is optional; default to empty dict.
if alias is None:
alias = {}

return opts, alias, origin

Raises:
SystemExit: via :func:`PgLOG.pglog` (``LGWNEX``) if the module
cannot be imported or ``OPTS`` cannot be found.
"""
modname = "rda_python_{}.{}".format(docname, docname)
try:
mod = importlib.import_module(modname)
except ImportError as exc:
PgLOG.pglog(
"Cannot import module '{}': {}".format(modname, exc),
PgLOG.LGWNEX,
)

# Derive ORIGIN from the module's own file path.
origin = op.dirname(op.abspath(mod.__file__))

# 1. Try module-level attributes first.
opts = getattr(mod, 'OPTS', None)
alias = getattr(mod, 'ALIAS', None)

# 2. Fall back to the first class in the module that owns both.
if opts is None or alias is None:
for _, obj in inspect.getmembers(mod, inspect.isclass):
if obj.__module__ == modname:
cls_opts = getattr(obj, 'OPTS', None)
cls_alias = getattr(obj, 'ALIAS', None)
if cls_opts is not None:
if opts is None: opts = cls_opts
if alias is None: alias = cls_alias
break

if opts is None:
PgLOG.pglog(
"Module '{}' does not define OPTS (checked module level and "
"all classes defined in the module)".format(modname),
PgLOG.LGWNEX,
)

# ALIAS is optional; default to empty dict.
if alias is None:
alias = {}

return opts, alias, origin

# ---------------------------------------------------------------------------
# Command-line entry point
# ---------------------------------------------------------------------------

def main():
"""Entry point for command-line usage of pg_rst.py."""
import argparse
parser = argparse.ArgumentParser(
description=(
"Convert a .usg help document to reStructuredText (.rst) using RST templates. "
Expand Down Expand Up @@ -1229,8 +1221,8 @@ def main():
)
args = parser.parse_args()

opts, alias, origin = _load_opts_alias(args.docname)
pg = PgRST()
opts, alias, origin = pg.load_opts_alias(args.docname)
pg.DOCS['ORIGIN'] = origin
if args.docdir is not None:
pg.DOCS['DOCDIR'] = args.docdir
Expand Down
Loading