mirror of
https://github.com/imayushsaini/Bombsquad-Ballistica-Modded-Server.git
synced 2026-08-22 13:13:23 +00:00
383 lines
15 KiB
Python
383 lines
15 KiB
Python
# Released under the MIT License. See LICENSE for details.
|
|
#
|
|
"""Scanner for ``# ba_meta`` directives in Python source.
|
|
|
|
Recognized directive shapes:
|
|
|
|
- ``# ba_meta require api <N>`` — module-level API version
|
|
requirement. When ``expected_api_version`` is supplied, modules
|
|
whose value doesn't match are skipped (and listed in
|
|
:attr:`ScanResults.incorrect_api_modules`). When it is ``None``
|
|
the line is parsed for validity but no filtering occurs.
|
|
- ``# ba_meta export <TYPE>`` — export the class defined on the
|
|
next non-blank source line under the export-type ``<TYPE>``.
|
|
- ``# ba_meta require asset-package <ID>`` — module declares that
|
|
it needs the named asset-package at runtime.
|
|
|
|
Other shapes are reported as malformed.
|
|
|
|
This module has no dependencies beyond the standard library so it
|
|
can run anywhere — in the game runtime (wrapped by
|
|
:class:`babase._meta.MetadataSubsystem`), in build/tooling contexts
|
|
(e.g. ``tools/pcommand assetpins``), or in tests.
|
|
|
|
Higher-level concerns — background-thread scheduling, UI feedback,
|
|
expansion of legacy export-type shortcuts, deprecation warnings —
|
|
live in the consumer.
|
|
"""
|
|
|
|
import os
|
|
import time
|
|
import logging
|
|
from pathlib import Path
|
|
from dataclasses import dataclass, field
|
|
|
|
|
|
@dataclass
|
|
class ScanResults:
|
|
"""Final results from a meta-scan."""
|
|
|
|
exports: dict[str, list[str]] = field(default_factory=dict)
|
|
asset_packages: dict[str, list[str]] = field(default_factory=dict)
|
|
incorrect_api_modules: list[str] = field(default_factory=list)
|
|
announce_errors_occurred: bool = False
|
|
|
|
def exports_by_name(self, name: str) -> list[str]:
|
|
"""Return exports matching a given name."""
|
|
return self.exports.get(name, [])
|
|
|
|
|
|
class DirectoryScan:
|
|
"""Scans directories for ``# ba_meta`` directives.
|
|
|
|
Pure-Python; no runtime dependencies. Construct with a list of
|
|
paths (which must already be on PYTHONPATH if discovered
|
|
modules will be imported by the consumer), then call
|
|
:meth:`run`. Results land in ``results``.
|
|
|
|
If ``expected_api_version`` is set, modules whose
|
|
``# ba_meta require api`` value doesn't match are skipped and
|
|
listed in :attr:`ScanResults.incorrect_api_modules`. Pass
|
|
``None`` to scan regardless of api version (the typical
|
|
tooling-side mode).
|
|
|
|
If ``deprecated_export_shortcuts`` is provided, export-type
|
|
strings that appear as keys are substituted with the
|
|
corresponding canonical class path and a deprecation warning
|
|
is emitted with file:line context. Pass ``None`` to perform
|
|
no substitution.
|
|
|
|
If ``expects_extras`` is True, :meth:`run` will block after
|
|
finishing the base paths until :meth:`set_extras` is called
|
|
from another thread. This supports the runtime pattern of
|
|
kicking off the scan immediately and providing extra paths
|
|
(workspace dirs, etc.) once they are known. Synchronous
|
|
tooling callers should leave it at the default ``False``;
|
|
:meth:`run` will then finish after the base paths.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
paths: list[str],
|
|
expected_api_version: int | None = None,
|
|
deprecated_export_shortcuts: dict[str, str] | None = None,
|
|
*,
|
|
expects_extras: bool = False,
|
|
) -> None:
|
|
"""Given one or more paths, parses available meta information.
|
|
|
|
It is assumed that these paths are also in PYTHONPATH.
|
|
It is also assumed that any subdirectories are Python
|
|
packages.
|
|
"""
|
|
# Skip non-existent paths completely.
|
|
self.base_paths = [Path(p) for p in paths if os.path.isdir(p)]
|
|
self.expected_api_version = expected_api_version
|
|
self.deprecated_export_shortcuts: dict[str, str] = (
|
|
dict(deprecated_export_shortcuts)
|
|
if deprecated_export_shortcuts is not None
|
|
else {}
|
|
)
|
|
self.extra_paths: list[Path] = []
|
|
# When extras are expected, run() blocks until set_extras()
|
|
# flips this to True. Synchronous callers skip the wait
|
|
# entirely by leaving expects_extras=False.
|
|
self.extra_paths_set = not expects_extras
|
|
self.results = ScanResults()
|
|
|
|
def set_extras(self, paths: list[str]) -> None:
|
|
"""Set extra portion."""
|
|
# Skip non-existent paths completely.
|
|
self.extra_paths += [Path(p) for p in paths if os.path.isdir(p)]
|
|
self.extra_paths_set = True
|
|
|
|
def run(self) -> None:
|
|
"""Do the thing."""
|
|
for pathlist in [self.base_paths, self.extra_paths]:
|
|
# Spin and wait until extra paths are provided before doing them.
|
|
if pathlist is self.extra_paths:
|
|
while not self.extra_paths_set:
|
|
time.sleep(0.001)
|
|
|
|
modules: list[tuple[Path, Path]] = []
|
|
for path in pathlist:
|
|
self._get_path_module_entries(path, '', modules)
|
|
for moduledir, subpath in modules:
|
|
try:
|
|
self._scan_module(moduledir, subpath)
|
|
except Exception:
|
|
logging.exception("metascan: Error scanning '%s'.", subpath)
|
|
|
|
# Sort our results.
|
|
for exportlist in self.results.exports.values():
|
|
exportlist.sort()
|
|
for modlist in self.results.asset_packages.values():
|
|
modlist.sort()
|
|
|
|
def _get_path_module_entries(
|
|
self, path: Path, subpath: str | Path, modules: list[tuple[Path, Path]]
|
|
) -> None:
|
|
"""Scan provided path and add module entries to provided list."""
|
|
try:
|
|
fullpath = Path(path, subpath)
|
|
# Note: skipping hidden dirs (starting with '.').
|
|
entries = [
|
|
(path, Path(subpath, name))
|
|
for name in os.listdir(fullpath)
|
|
if not name.startswith('.')
|
|
]
|
|
except PermissionError:
|
|
# Expected sometimes.
|
|
entries = []
|
|
except Exception:
|
|
# Unexpected; report this.
|
|
logging.exception('metascan: Error in _get_path_module_entries.')
|
|
self.results.announce_errors_occurred = True
|
|
entries = []
|
|
|
|
# Now identify python packages/modules out of what we found.
|
|
for entry in entries:
|
|
if entry[1].name.endswith('.py'):
|
|
modules.append(entry)
|
|
elif (
|
|
Path(entry[0], entry[1]).is_dir()
|
|
and Path(entry[0], entry[1], '__init__.py').is_file()
|
|
):
|
|
modules.append(entry)
|
|
|
|
def _scan_module(self, moduledir: Path, subpath: Path) -> None:
|
|
"""Scan an individual module and add the findings to results."""
|
|
if subpath.name.endswith('.py'):
|
|
fpath = Path(moduledir, subpath)
|
|
ispackage = False
|
|
else:
|
|
fpath = Path(moduledir, subpath, '__init__.py')
|
|
ispackage = True
|
|
with fpath.open(encoding='utf-8') as infile:
|
|
flines = infile.readlines()
|
|
meta_lines = {
|
|
lnum: l[1:].split()
|
|
for lnum, l in enumerate(flines)
|
|
# Do a simple 'in' check for speed but then make sure its
|
|
# also at the beginning of the line. This allows disabling
|
|
# meta-lines and avoids false positives from code that
|
|
# wrangles them.
|
|
if ('# ba_meta' in l and l.strip().startswith('# ba_meta '))
|
|
}
|
|
is_top_level = len(subpath.parts) <= 1
|
|
required_api = self._get_api_requirement(
|
|
subpath, meta_lines, is_top_level
|
|
)
|
|
|
|
# Top level modules with no discernible api version get ignored.
|
|
if is_top_level and required_api is None:
|
|
return
|
|
|
|
# If we find a module requiring a different api version than the
|
|
# consumer expects, warn and ignore. If no api was supplied, do
|
|
# no filtering.
|
|
if (
|
|
required_api is not None
|
|
and self.expected_api_version is not None
|
|
and required_api != self.expected_api_version
|
|
):
|
|
logging.warning(
|
|
'metascan: %s requires api %s but we are running'
|
|
' %s. Ignoring module.',
|
|
subpath,
|
|
required_api,
|
|
self.expected_api_version,
|
|
)
|
|
self.results.incorrect_api_modules.append(
|
|
self._module_name_for_subpath(subpath)
|
|
)
|
|
return
|
|
|
|
# Ok; can proceed with a full scan of this module.
|
|
self._process_module_meta_tags(subpath, flines, meta_lines)
|
|
|
|
# If its a package, recurse into its subpackages.
|
|
if ispackage:
|
|
try:
|
|
submodules: list[tuple[Path, Path]] = []
|
|
self._get_path_module_entries(moduledir, subpath, submodules)
|
|
for submodule in submodules:
|
|
if submodule[1].name != '__init__.py':
|
|
self._scan_module(submodule[0], submodule[1])
|
|
except Exception:
|
|
logging.exception('metascan: Error scanning %s.', subpath)
|
|
|
|
def _module_name_for_subpath(self, subpath: Path) -> str:
|
|
# (should not be getting these)
|
|
assert '__init__.py' not in str(subpath)
|
|
|
|
return '.'.join(subpath.parts).removesuffix('.py')
|
|
|
|
def _process_module_meta_tags(
|
|
self,
|
|
subpath: Path,
|
|
flines: list[str],
|
|
meta_lines: dict[int, list[str]],
|
|
) -> None:
|
|
"""Pull data from a module based on its ba_meta tags."""
|
|
for lindex, mline in meta_lines.items():
|
|
# meta_lines is just anything containing the marker; make
|
|
# sure the directive token is in the right place.
|
|
if mline[0] != 'ba_meta':
|
|
logging.warning(
|
|
'metascan: %s:%d: malformed ba_meta statement.',
|
|
subpath,
|
|
lindex + 1,
|
|
)
|
|
self.results.announce_errors_occurred = True
|
|
elif (
|
|
len(mline) == 4 and mline[1] == 'require' and mline[2] == 'api'
|
|
):
|
|
# Ignore 'require api X' lines in this pass; handled
|
|
# already by _get_api_requirement.
|
|
pass
|
|
elif (
|
|
len(mline) == 4
|
|
and mline[1] == 'require'
|
|
and mline[2] == 'asset-package'
|
|
):
|
|
# 'require asset-package <ID>' — record the dependency.
|
|
modulename = self._module_name_for_subpath(subpath)
|
|
pkg_id = mline[3]
|
|
self.results.asset_packages.setdefault(pkg_id, []).append(
|
|
modulename
|
|
)
|
|
elif len(mline) != 3 or mline[1] != 'export':
|
|
# No other directive shapes are recognized.
|
|
logging.warning(
|
|
'metascan: %s:%d: unrecognized ba_meta statement.',
|
|
subpath,
|
|
lindex + 1,
|
|
)
|
|
self.results.announce_errors_occurred = True
|
|
else:
|
|
# Looks like we've got a valid export line!
|
|
modulename = self._module_name_for_subpath(subpath)
|
|
exporttypestr = mline[2]
|
|
export_class_name = self._get_export_class_name(
|
|
subpath, flines, lindex
|
|
)
|
|
if export_class_name is not None:
|
|
classname = modulename + '.' + export_class_name
|
|
|
|
# If the export type is in the consumer-provided
|
|
# deprecated-shortcut map, sub in the canonical class
|
|
# path and warn.
|
|
canonical = self.deprecated_export_shortcuts.get(
|
|
exporttypestr
|
|
)
|
|
if canonical is not None:
|
|
logging.warning(
|
|
"metascan: %s:%d: '# ba_meta export %s'"
|
|
' tag is deprecated and should be replaced'
|
|
" by '# ba_meta export %s'.",
|
|
subpath,
|
|
lindex + 1,
|
|
exporttypestr,
|
|
canonical,
|
|
)
|
|
self.results.announce_errors_occurred = True
|
|
exporttypestr = canonical
|
|
|
|
self.results.exports.setdefault(exporttypestr, []).append(
|
|
classname
|
|
)
|
|
|
|
def _get_export_class_name(
|
|
self, subpath: Path, lines: list[str], lindex: int
|
|
) -> str | None:
|
|
"""Given line num of an export tag, returns its operand class name."""
|
|
lindexorig = lindex
|
|
classname = None
|
|
while True:
|
|
lindex += 1
|
|
if lindex >= len(lines):
|
|
break
|
|
lbits = lines[lindex].split()
|
|
if not lbits:
|
|
continue # Skip empty lines.
|
|
if lbits[0] != 'class':
|
|
break
|
|
if len(lbits) > 1:
|
|
cbits = lbits[1].split('(')
|
|
if len(cbits) > 1 and cbits[0].isidentifier():
|
|
classname = cbits[0]
|
|
break # Success!
|
|
if classname is None:
|
|
logging.warning(
|
|
'metascan: %s:%d: class definition not found below'
|
|
" 'ba_meta export' statement.",
|
|
subpath,
|
|
lindexorig + 1,
|
|
)
|
|
self.results.announce_errors_occurred = True
|
|
return classname
|
|
|
|
def _get_api_requirement(
|
|
self,
|
|
subpath: Path,
|
|
meta_lines: dict[int, list[str]],
|
|
toplevel: bool,
|
|
) -> int | None:
|
|
"""Return an API requirement integer or None if none present.
|
|
|
|
Malformed api requirement strings will be logged as warnings.
|
|
"""
|
|
lines = [
|
|
l
|
|
for l in meta_lines.values()
|
|
if len(l) == 4
|
|
and l[0] == 'ba_meta'
|
|
and l[1] == 'require'
|
|
and l[2] == 'api'
|
|
and l[3].isdigit()
|
|
]
|
|
|
|
# We're successful if we find exactly one properly formatted
|
|
# line.
|
|
if len(lines) == 1:
|
|
return int(lines[0][3])
|
|
|
|
# Ok; not successful. lets issue warnings for a few error cases.
|
|
if len(lines) > 1:
|
|
logging.warning(
|
|
"metascan: %s: multiple '# ba_meta require api <NUM>'"
|
|
' lines found; ignoring module.',
|
|
subpath,
|
|
)
|
|
self.results.announce_errors_occurred = True
|
|
elif not lines and toplevel and meta_lines:
|
|
# If we're a top-level module containing meta lines but no
|
|
# valid "require api" line found, complain.
|
|
logging.warning(
|
|
"metascan: %s: no valid '# ba_meta require api <NUM>"
|
|
' line found; ignoring module.',
|
|
subpath,
|
|
)
|
|
self.results.announce_errors_occurred = True
|
|
return None
|