"""Discover Apache Ant targets by parsing build.xml, without running Ant."""
from __future__ import annotations
import logging
import re
import xml.etree.ElementTree as ET
import xml.parsers.expat
from dataclasses import replace
from typing import TYPE_CHECKING
from nur.core.models import Task
if TYPE_CHECKING:
from pathlib import Path
__all__ = ["AntProvider", "parse_build_xml"]
log = logging.getLogger("nur")
_SOURCE_FILE = "build.xml"
# Both run as `ant <name>`: extension-points are target-like entry points with
# a name, a depends list, and a description, but no task body of their own.
_RUNNABLE = frozenset({"target", "extension-point"})
# Ant installs <target>/<extension-point> only from the empty namespace or its
# core antlib URI (ProjectHelper.ANT_CORE_URI); elements of the same local
# name in any other namespace are ordinary task/type uses, not entry points.
_ANT_CORE_URI = "antlib:org.apache.tools.ant"
# The XML declaration, when present, is the first markup in the file (after an
# optional BOM), so the encoding name is read from there only — never from
# later lookalikes inside comments or document text.
_DECL_ENCODING_RE = re.compile(
rb"\A(?:\xef\xbb\xbf)?\s*<\?xml\s[^?]*?encoding\s*=\s*[\"']([^\"']+)[\"']"
)
_STR_DECL_ENCODING_RE = re.compile(
r"(?P<head><\?xml\s[^?]*?encoding\s*=\s*[\"'])[^\"']+(?P<quote>[\"'])"
)
# Encodings the stdlib XML stack parses natively; anything else declared is
# transcoded to UTF-8 below (multibyte ones like Shift_JIS, which pyexpat
# rejects but Ant's JAXP reader accepts).
_NATIVE_ENCODINGS = frozenset({"utf-8", "utf8", "ascii", "us-ascii"})
# Xerces-supported aliases Python's codec registry does not know, derived from
# the fIANA2JavaMap table in Xerces' EncodingMap.java: every IANA name Python
# cannot resolve whose Java target has a verified Python equivalent. Keys are
# lowercased with `_` folded to `-`, matching the normalization in
# `_codec_for`. Java targets Python cannot decode (CP277/278/..., JIS0208,
# ...) are deliberately absent: those files keep the graceful skip.
_XERCES_ALIASES = {
"ccsid00858": "cp858",
"ccsid01140": "cp1140",
"cseucpkdfmtjapanese": "euc_jp",
"csgb2312": "gb2312",
"cswindows31j": "cp932",
"extended-unix-code-packed-format-for-japanese": "euc_jp",
"ibm-1026": "cp1026",
"ibm-1140": "cp1140",
"ibm-273": "cp273",
"ibm-367": "ascii",
"ibm-37": "cp037",
"ibm-437": "cp437",
"ibm-500": "cp500",
"ibm-775": "cp775",
"ibm-819": "iso8859_1",
"ibm-850": "cp850",
"ibm-852": "cp852",
"ibm-855": "cp855",
"ibm-857": "cp857",
"ibm-858": "cp858",
"ibm-860": "cp860",
"ibm-861": "cp861",
"ibm-862": "cp862",
"ibm-863": "cp863",
"ibm-864": "cp864",
"ibm-865": "cp865",
"ibm-866": "cp866",
"ibm-869": "cp869",
"ibm-1208": "utf-8",
"eucjis": "euc-jp",
# Korean aliases for KS X 1001: Python resolves KOREAN/KS_C_5601-1987 to
# euc_kr itself, and these remaining Xerces names render the same way.
"iso-ir-149": "euc_kr",
"ks-c-5601-1989": "euc_kr",
"ksc-5601": "euc_kr",
"csksc56011987": "euc_kr",
"windows-936": "gbk",
}
# Byte signatures for UTF-32/UTF-16 with the codec that decodes them (XML spec
# Appendix F). Order matters: `FF FE` opens both UTF-32LE and UTF-16LE, so the
# longer UTF-32 entries come first. Matched input is always transcoded, which
# also maps names Python does not know (e.g. ISO-10646-UCS-2) to the right
# decoder via the byte signature rather than the declared name.
_UNICODE_SIGNATURES: tuple[tuple[bytes, str], ...] = (
(b"\x00\x00\xfe\xff", "utf-32-be"),
(b"\xff\xfe\x00\x00", "utf-32-le"),
(b"\x00\x00\x00\x3c", "utf-32-be"),
(b"\x3c\x00\x00\x00", "utf-32-le"),
(b"\xfe\xff", "utf-16"),
(b"\xff\xfe", "utf-16"),
(b"\x00\x3c\x00\x3f", "utf-16-be"),
(b"\x3c\x00\x3f\x00", "utf-16-le"),
)
# `<?xml` in EBCDIC, shared by CP037/CP500/CP1140/CP273; the exact variant is
# read from the declaration itself (see `_transcode_ebcdic`).
_EBCDIC_PREFIX = b"\x4c\x6f\xa7\x94\x93"
_EBCDIC_CODECS = ("cp037", "cp500", "cp1140", "cp273")
_XML_VERSION_RE = re.compile(r"<\?xml\s+version\s*=")
_STR_DECL_NAME_RE = re.compile(r"<\?xml\s[^?]*?encoding\s*=\s*[\"']([^\"']+)[\"']")
def _to_utf8(text: str) -> bytes:
"""Patch the declaration of *text* to UTF-8 and encode it."""
text = _STR_DECL_ENCODING_RE.sub(r"\g<head>UTF-8\g<quote>", text, count=1)
return text.encode("utf-8")
def _codec_for(declared: str) -> str:
"""Map a declared encoding name to a Python codec.
Xerces aliases Python's registry does not know are translated first;
anything unmapped is returned unchanged for Python (or the graceful
skip) to judge.
"""
return _XERCES_ALIASES.get(declared.lower().replace("_", "-"), declared)
def _recode(source: bytes, codec: str) -> str | None:
"""Decode *source* with *codec*, or None when it cannot be that codec."""
try:
return source.decode(codec).lstrip("\ufeff")
except LookupError:
return None
except UnicodeError:
return None
def _transcode_signature(source: bytes) -> bytes:
"""Transcode byte input whose declaration is not ASCII-readable.
UTF-32/UTF-16 byte patterns are unambiguous (NUL bytes are illegal in XML
content), so a match is decoded directly. EBCDIC input goes through
`_transcode_ebcdic`; anything else passes through for the real parse to
report.
"""
for prefix, codec in _UNICODE_SIGNATURES:
if source.startswith(prefix):
text = _recode(source, codec)
return _to_utf8(text) if text is not None else source
if source.startswith(_EBCDIC_PREFIX):
return _transcode_ebcdic(source)
return source
def _transcode_ebcdic(source: bytes) -> bytes:
"""Transcode EBCDIC input with the codec its own declaration names.
Sibling EBCDIC codecs share one `<?xml` prefix, so the prefix alone cannot
select the codec: decoding a CP500 file as CP037 silently mangles every
character whose mapping differs (a `[` target would be listed as `¢`).
Instead any sibling bootstrap-decodes the declaration — whose characters
are variant-invariant — and the complete document is decoded with the
codec the declaration specifies (Xerces aliases resolved via
`_codec_for`). Anything unresolvable passes through for the real
parse to report.
"""
for codec in _EBCDIC_CODECS:
bootstrap = _recode(source, codec)
if bootstrap is None:
continue
match = _STR_DECL_NAME_RE.search(bootstrap)
if match is None:
continue
text = _recode(source, _codec_for(match.group(1)))
if text is None or _XML_VERSION_RE.match(text) is None:
continue
return _to_utf8(text)
return source
def _transcode(source: bytes) -> bytes:
"""Return *source* as bytes ElementTree can parse.
The declared encoding is honoured: UTF-8/ASCII pass through untouched,
Xerces aliases Python does not know are mapped first, and anything else
declared is decoded and re-encoded as UTF-8 with the declaration patched
to match. Undeclared input without a recognised signature passes through
to native handling. Undecodable declarations pass through; the real parse
below reports them.
"""
match = _DECL_ENCODING_RE.search(source)
if match is None:
return _transcode_signature(source)
try:
declared = match.group(1).decode("ascii")
except UnicodeDecodeError:
return source
normalized = declared.lower().replace("_", "-")
if normalized in _NATIVE_ENCODINGS:
return source
text = _recode(source, _codec_for(declared))
return _to_utf8(text) if text is not None else source
def _prepare(text: str | bytes) -> str | bytes:
"""Return *text* in a form the stdlib XML stack parses (see `_transcode`)."""
if isinstance(text, bytes):
return _transcode(text)
return text
class _DoctypeFoundError(Exception):
"""Control flow: the pre-scan spotted a document type declaration."""
def _reject_doctype(*_args: object) -> None:
raise _DoctypeFoundError
def _has_doctype(text: str | bytes) -> bool:
"""Return whether *text* carries a ``<!DOCTYPE`` declaration.
The declaration is the only carrier of inline ``<!ENTITY>`` definitions,
whose expansion ElementTree performs without bounds (billion laughs), and
of external entity references. Genuine Ant build files never need a
DOCTYPE, so reject it up front and keep the stdlib parser without adding
defusedxml.
Detection itself runs on expat with a handler that aborts the parse the
moment a declaration starts, so no entity is ever expanded and encodings,
comments, processing instructions, and CDATA sections are all handled by
a real parser rather than pattern matching. Anything expat rejects is
left for the ElementTree parse below, which skips the file the same way.
"""
parser = xml.parsers.expat.ParserCreate()
parser.StartDoctypeDeclHandler = _reject_doctype
try:
# pyexpat's Parse takes no keyword arguments, hence the positional flag.
parser.Parse(text, True) # noqa: FBT003
except _DoctypeFoundError:
return True
# Parenthesized with a binding on purpose: ruff format's preview style
# strips parens from binding-less tuples, which CPython before 3.14 cannot
# parse (the prek check-ast hook runs 3.10). The binding is intentionally
# unused: the ElementTree parse below logs the skip with the real error.
except (
xml.parsers.expat.ExpatError,
LookupError,
ValueError,
UnicodeError,
) as _exc:
# Unparsable input (unknown encoding, malformed XML, ...): no
# declaration was seen; the ElementTree parse below skips the file.
return False
return False
def _runnable(tag: str) -> bool:
"""Whether *tag* is an Ant core target-like element.
ElementTree reports namespaced tags as ``{uri}local``; only the empty
namespace and Ant's core URI count (``<plugin:target>`` is a task use).
"""
if tag.startswith("{"):
uri, _, local = tag[1:].partition("}")
return local in _RUNNABLE and uri == _ANT_CORE_URI
return tag in _RUNNABLE
def _is_project_root(tag: str) -> bool:
"""Whether *tag* is an Ant ``<project>`` root element.
Ant's root handler only accepts ``project`` in the empty namespace or its
core URI and rejects anything else before installing any target.
"""
if tag.startswith("{"):
uri, _, local = tag[1:].partition("}")
return local == "project" and uri == _ANT_CORE_URI
return tag == "project"
def _attr(element: ET.Element, local: str) -> str | None:
"""Return the attribute with local name *local*, Ant-style.
Ant interprets attributes by local name when unqualified or qualified with
the element's own namespace, ignores other namespaces, and assigns while
iterating so the later duplicate wins. ElementTree keys a namespaced
attribute as ``{uri}local`` but preserves document order in
``element.attrib``, so the last accepted form is returned.
"""
core = f"{{{_ANT_CORE_URI}}}{local}"
namespaced = element.tag.startswith(f"{{{_ANT_CORE_URI}}}")
value: str | None = None
for key, val in element.attrib.items():
if key == local or (namespaced and key == core):
value = val
return value
def _description(element: ET.Element) -> str | None:
value = _attr(element, "description")
if value is None:
return None
# XML attribute normalization already folds literal newlines to spaces, so
# only ` ` character references still split lines; strip the first one.
lines = value.splitlines()
first = lines[0].strip() if lines else ""
return first or None
[docs]
def parse_build_xml(text: str | bytes, source_file: str = _SOURCE_FILE) -> list[Task]:
"""Extract runnable entry names from ``build.xml`` without executing anything.
Deliberately does NOT shell out to ``ant -p``: the file is declarative XML,
so ``<target name=...>`` attributes can be read directly and a task body
(a sequence of XML tasks, not shell commands) never runs during discovery.
``<extension-point name=...>`` entries are listed the same way: they are
target-like states with a name and an optional description, each running
as ``ant <name>``. Descriptions come from the ``description``
attribute; every entry is listed, including ones without a description
(which ``ant -p`` hides as internal). Names are kept verbatim — Ant permits
spaces in target names and ``argv_base`` passes the name as a single token,
so normalizing would change the target's identity. Only a missing name or
an actually empty one is skipped (Ant rejects the latter); anything else,
including whitespace-only names, stays listed exactly as Ant accepts it.
Names beginning with a
hyphen are skipped: Ant parses such arguments as options, so they cannot
run via ``ant <name>`` (the conventional internal-target form); the check
mirrors Ant's own ``startsWith("-")`` on the same verbatim token, so
padded variants (`` -init ``) stay listed. Only core-namespace
``<target>``/``<extension-point>`` elements count: other namespaces are
ordinary task/type uses, not entry points. Documents whose root is not an
Ant ``<project>`` element are skipped: Ant rejects them before installing
any target. Task bodies stay
opaque, so ``definition`` is empty. Imported/included build files,
``macrodef``-defined
behaviour, ``if``/``unless`` conditions, ``depends`` ordering, and
``${property}`` interpolation are not resolved; names are left literal.
Parser errors skip the file. Files with a ``<!DOCTYPE>`` declaration are
skipped as well: it is the only carrier of ``<!ENTITY>`` definitions,
whose expansion is unbounded, and genuine Ant build files never need one.
Detection is an expat pre-scan that aborts at the first declaration, so no
entity is ever expanded during discovery.
Results are syntactic task candidates, not a
promise that Ant will accept or successfully execute each target.
Byte input is preferred: ElementTree honours the XML encoding declaration
only for bytes; a `str` carrying one is skipped as unparsable. Byte input
in an encoding outside the stdlib's native set (multibyte ones like
Shift_JIS, UTF-32, or EBCDIC variants, which Ant accepts) is transcoded to
UTF-8 first.
"""
text = _prepare(text)
if _has_doctype(text):
log.warning(
"nur: skipping %s (document type declaration not allowed)", source_file
)
return []
try:
root = ET.fromstring(text)
except (ET.ParseError, LookupError, ValueError, UnicodeError) as exc:
log.warning("nur: skipping %s (%s)", source_file, exc)
return []
if not _is_project_root(root.tag):
log.warning("nur: skipping %s (not an Ant project file)", source_file)
return []
tasks: dict[str, Task] = {}
# Only direct children of the project root: Ant only accepts <target> and
# <extension-point> there, so nested elements of the same name (e.g. inside
# <macrodef>) are not runnable entry points.
for element in root:
if not _runnable(element.tag):
continue
raw = _attr(element, "name")
if not raw:
continue
if raw.startswith("-"):
# Ant checks the original command-line token with startsWith("-"),
# so only position-zero hyphens read as options; padded variants
# (` -init `) stay runnable as single-token target names, exactly
# as Ant itself treats them. Make skips `.`-prefixed names for the
# same reason.
continue
name = raw
declared = Task(
name=name,
prefix="ant",
argv_base=("ant", name),
description=_description(element),
source_file=source_file,
)
existing = tasks.get(name)
if existing is not None:
if existing.description is None and declared.description is not None:
tasks[name] = replace(existing, description=declared.description)
continue
tasks[name] = declared
return list(tasks.values())
class AntProvider:
prefix = "ant"
def detect(self, cwd: Path) -> bool:
return (cwd / _SOURCE_FILE).is_file()
def discover(self, cwd: Path) -> list[Task]:
# Bytes (not text): ElementTree honours the XML encoding declaration
# only for byte input; decoding first would misread non-UTF-8 files
# and reject declarations outright.
try:
source = (cwd / _SOURCE_FILE).read_bytes()
except OSError as exc:
log.warning("nur: skipping %s (%s)", _SOURCE_FILE, exc)
return []
return parse_build_xml(source)