ballistica/tools/efrotools/__init__.py
2020-10-12 12:30:45 -07:00

218 lines
7.5 KiB
Python

# Released under the MIT License. See LICENSE for details.
#
"""Build/tool functionality shared between all efro projects.
This stuff can be a bit more sloppy/loosey-goosey since it is not used in
live client or server code.
"""
# FIXME: should migrate everything here into submodules since this adds
# overhead to anything importing from any efrotools submodule.
from __future__ import annotations
import os
import json
import subprocess
import platform
from pathlib import Path
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from typing import Dict, Union, Sequence, Optional, Any, Literal
# Python major version we're using for all this stuff.
PYVER = '3.8'
# Python binary assumed by these tools.
PYTHON_BIN = f'python{PYVER}' if platform.system() != 'Windows' else 'python'
def explicit_bool(value: bool) -> bool:
"""Simply return input value; can avoid unreachable-code type warnings."""
return value
def getlocalconfig(projroot: Path) -> Dict[str, Any]:
"""Return a project's localconfig contents (or default if missing)."""
localconfig: Dict[str, Any]
try:
with open(Path(projroot, 'config/localconfig.json')) as infile:
localconfig = json.loads(infile.read())
except FileNotFoundError:
localconfig = {}
return localconfig
def getconfig(projroot: Path) -> Dict[str, Any]:
"""Return a project's config contents (or default if missing)."""
config: Dict[str, Any]
try:
with open(Path(projroot, 'config/config.json')) as infile:
config = json.loads(infile.read())
except FileNotFoundError:
config = {}
return config
def setconfig(projroot: Path, config: Dict[str, Any]) -> None:
"""Set the project config contents."""
os.makedirs(Path(projroot, 'config'), exist_ok=True)
with Path(projroot, 'config/config.json').open('w') as outfile:
outfile.write(json.dumps(config, indent=2))
def get_public_license(style: str) -> str:
"""Return the license notice as used for our public facing stuff.
'style' arg can be 'python', 'c++', or 'makefile, or 'raw'.
"""
if style == 'raw':
return 'Released under the MIT License. See LICENSE for details.'
if style == 'python':
# Add a line at the bottom since our python-formatters tend to smush
# our code up against the license; this keeps things a bit more
# visually separated.
return '# Released under the MIT License. See LICENSE for details.'
if style == 'makefile':
# Basically same as python except without the last line.
return '# Released under the MIT License. See LICENSE for details.'
if style == 'c++':
return '// Released under the MIT License. See LICENSE for details.'
raise RuntimeError(f'Invalid style: {style}')
def readfile(path: Union[str, Path]) -> str:
"""Read a text file and return a str."""
with open(path) as infile:
return infile.read()
def writefile(path: Union[str, Path], txt: str) -> None:
"""Write a string to a file."""
with open(path, 'w') as outfile:
outfile.write(txt)
def replace_one(opstr: str, old: str, new: str) -> str:
"""Replace text ensuring that exactly one occurrence is replaced."""
count = opstr.count(old)
if count != 1:
raise Exception(
f'expected 1 string occurrence; found {count}. String = {old}')
return opstr.replace(old, new)
def run(cmd: str) -> None:
"""Run a shell command, checking errors."""
subprocess.run(cmd, shell=True, check=True)
def get_files_hash(filenames: Sequence[Union[str, Path]],
extrahash: str = '',
int_only: bool = False,
hashtype: Literal['md5', 'sha256'] = 'md5') -> str:
"""Return a md5 hash for the given files."""
import hashlib
if not isinstance(filenames, list):
raise Exception('expected a list')
if TYPE_CHECKING:
# Help Mypy infer the right type for this.
hashobj = hashlib.md5()
else:
hashobj = getattr(hashlib, hashtype)()
for fname in filenames:
with open(fname, 'rb') as infile:
while True:
data = infile.read(2**20)
if not data:
break
hashobj.update(data)
hashobj.update(extrahash.encode())
if int_only:
return str(int.from_bytes(hashobj.digest(), byteorder='big'))
return hashobj.hexdigest()
def _py_symbol_at_column(line: str, col: int) -> str:
start = col
while start > 0 and line[start - 1] != ' ':
start -= 1
end = col
while end < len(line) and line[end] != ' ':
end += 1
return line[start:end]
def py_examine(projroot: Path, filename: Path, line: int, column: int,
selection: Optional[str], operation: str) -> None:
"""Given file position info, performs some code inspection."""
# pylint: disable=too-many-locals
# pylint: disable=cyclic-import
import astroid
import re
from efrotools import code
# Pull in our pylint plugin which really just adds astroid filters.
# That way our introspection here will see the same thing as pylint's does.
with open(filename) as infile:
fcontents = infile.read()
if '#@' in fcontents:
raise Exception('#@ marker found in file; this breaks examinations.')
flines = fcontents.splitlines()
if operation == 'pylint_infer':
# See what asteroid can infer about the target symbol.
symbol = (selection if selection is not None else _py_symbol_at_column(
flines[line - 1], column))
# Insert a line after the provided one which is just the symbol so we
# can ask for its value alone.
match = re.match(r'\s*', flines[line - 1])
whitespace = match.group() if match is not None else ''
sline = whitespace + symbol + ' #@'
flines = flines[:line] + [sline] + flines[line:]
node = astroid.extract_node('\n'.join(flines))
inferred = list(node.infer())
print(symbol + ':', ', '.join([str(i) for i in inferred]))
elif operation in ('mypy_infer', 'mypy_locals'):
# Ask mypy for the type of the target symbol.
symbol = (selection if selection is not None else _py_symbol_at_column(
flines[line - 1], column))
# Insert a line after the provided one which is just the symbol so we
# can ask for its value alone.
match = re.match(r'\s*', flines[line - 1])
whitespace = match.group() if match is not None else ''
if operation == 'mypy_infer':
sline = whitespace + 'reveal_type(' + symbol + ')'
else:
sline = whitespace + 'reveal_locals()'
flines = flines[:line] + [sline] + flines[line:]
# Write a temp file and run the check on it.
# Let's use ' flycheck_*' for the name since pipeline scripts
# are already set to ignore those files.
tmppath = Path(filename.parent, 'flycheck_mp_' + filename.name)
with tmppath.open('w') as outfile:
outfile.write('\n'.join(flines))
try:
code.runmypy(projroot, [str(tmppath)], check=False)
except Exception as exc:
print('error running mypy:', exc)
tmppath.unlink()
elif operation == 'pylint_node':
flines[line - 1] += ' #@'
node = astroid.extract_node('\n'.join(flines))
print(node)
elif operation == 'pylint_tree':
flines[line - 1] += ' #@'
node = astroid.extract_node('\n'.join(flines))
print(node.repr_tree())
else:
print('unknown operation: ' + operation)