mirror of
https://github.com/RYDE-WORK/ballistica.git
synced 2026-02-03 05:53:15 +08:00
moving bacloud guts to batools package
This commit is contained in:
parent
f011110967
commit
f25dd7bcc5
32
.efrocachemap
generated
32
.efrocachemap
generated
@ -4076,18 +4076,18 @@
|
|||||||
"build/prefab/full/linux_x86_64_gui/release/ballisticakit": "https://files.ballistica.net/cache/ba1/5f/b0/3fce5a36c251f4090efa8d5c17f6",
|
"build/prefab/full/linux_x86_64_gui/release/ballisticakit": "https://files.ballistica.net/cache/ba1/5f/b0/3fce5a36c251f4090efa8d5c17f6",
|
||||||
"build/prefab/full/linux_x86_64_server/debug/dist/ballisticakit_headless": "https://files.ballistica.net/cache/ba1/41/76/508f80aa2cad4248be0f644ac577",
|
"build/prefab/full/linux_x86_64_server/debug/dist/ballisticakit_headless": "https://files.ballistica.net/cache/ba1/41/76/508f80aa2cad4248be0f644ac577",
|
||||||
"build/prefab/full/linux_x86_64_server/release/dist/ballisticakit_headless": "https://files.ballistica.net/cache/ba1/e2/8c/71a04c5829575d085aef4e36a77f",
|
"build/prefab/full/linux_x86_64_server/release/dist/ballisticakit_headless": "https://files.ballistica.net/cache/ba1/e2/8c/71a04c5829575d085aef4e36a77f",
|
||||||
"build/prefab/full/mac_arm64_gui/debug/ballisticakit": "https://files.ballistica.net/cache/ba1/50/ea/3425b68d9cbd018b591620ea21c8",
|
"build/prefab/full/mac_arm64_gui/debug/ballisticakit": "https://files.ballistica.net/cache/ba1/a4/90/eb9eebb974e9d40476e9d42709ef",
|
||||||
"build/prefab/full/mac_arm64_gui/release/ballisticakit": "https://files.ballistica.net/cache/ba1/ab/c8/e638cf97e0f18c247c87c453d017",
|
"build/prefab/full/mac_arm64_gui/release/ballisticakit": "https://files.ballistica.net/cache/ba1/ab/c8/e638cf97e0f18c247c87c453d017",
|
||||||
"build/prefab/full/mac_arm64_server/debug/dist/ballisticakit_headless": "https://files.ballistica.net/cache/ba1/3a/16/39bbdf1fd716309eb9bc00a3914c",
|
"build/prefab/full/mac_arm64_server/debug/dist/ballisticakit_headless": "https://files.ballistica.net/cache/ba1/9c/05/179138bdfd06d1fb54828acdaed6",
|
||||||
"build/prefab/full/mac_arm64_server/release/dist/ballisticakit_headless": "https://files.ballistica.net/cache/ba1/60/ff/8ad1713b4fbd20d43b3a791095b3",
|
"build/prefab/full/mac_arm64_server/release/dist/ballisticakit_headless": "https://files.ballistica.net/cache/ba1/60/ff/8ad1713b4fbd20d43b3a791095b3",
|
||||||
"build/prefab/full/mac_x86_64_gui/debug/ballisticakit": "https://files.ballistica.net/cache/ba1/81/38/d5beff6112f26a82271e6f8430ba",
|
"build/prefab/full/mac_x86_64_gui/debug/ballisticakit": "https://files.ballistica.net/cache/ba1/5c/d8/29ef01a424be79e7b5d7de8fdcf2",
|
||||||
"build/prefab/full/mac_x86_64_gui/release/ballisticakit": "https://files.ballistica.net/cache/ba1/c2/ed/95e885a5bc785a1e595c74ff76dc",
|
"build/prefab/full/mac_x86_64_gui/release/ballisticakit": "https://files.ballistica.net/cache/ba1/c2/ed/95e885a5bc785a1e595c74ff76dc",
|
||||||
"build/prefab/full/mac_x86_64_server/debug/dist/ballisticakit_headless": "https://files.ballistica.net/cache/ba1/e2/1a/a14431e08d923eb01f6a06132b30",
|
"build/prefab/full/mac_x86_64_server/debug/dist/ballisticakit_headless": "https://files.ballistica.net/cache/ba1/f1/f9/97a7f6b6cd9b460f25dbb5cb5a96",
|
||||||
"build/prefab/full/mac_x86_64_server/release/dist/ballisticakit_headless": "https://files.ballistica.net/cache/ba1/6d/ab/bb8b72e8764292d17803fbecfcb0",
|
"build/prefab/full/mac_x86_64_server/release/dist/ballisticakit_headless": "https://files.ballistica.net/cache/ba1/6d/ab/bb8b72e8764292d17803fbecfcb0",
|
||||||
"build/prefab/full/windows_x86_gui/debug/BallisticaKit.exe": "https://files.ballistica.net/cache/ba1/3f/7a/37310338d005ba12cab72fdd990d",
|
"build/prefab/full/windows_x86_gui/debug/BallisticaKit.exe": "https://files.ballistica.net/cache/ba1/ec/f1/74692bee69a1c8dd0e58058d838c",
|
||||||
"build/prefab/full/windows_x86_gui/release/BallisticaKit.exe": "https://files.ballistica.net/cache/ba1/de/09/c0e6a848c89c20279a8d562821f7",
|
"build/prefab/full/windows_x86_gui/release/BallisticaKit.exe": "https://files.ballistica.net/cache/ba1/73/92/ba936c09947ada91109477075f59",
|
||||||
"build/prefab/full/windows_x86_server/debug/dist/BallisticaKitHeadless.exe": "https://files.ballistica.net/cache/ba1/59/3a/97b3e97889a8111b1d4fa54e28e9",
|
"build/prefab/full/windows_x86_server/debug/dist/BallisticaKitHeadless.exe": "https://files.ballistica.net/cache/ba1/a8/18/110496e9d31cc4810ef19e377a61",
|
||||||
"build/prefab/full/windows_x86_server/release/dist/BallisticaKitHeadless.exe": "https://files.ballistica.net/cache/ba1/9e/e5/ec1673fdc92998ee8952225b46ec",
|
"build/prefab/full/windows_x86_server/release/dist/BallisticaKitHeadless.exe": "https://files.ballistica.net/cache/ba1/e0/bf/c93d5851379821f4d043819b11ef",
|
||||||
"build/prefab/lib/linux_arm64_gui/debug/libballistica_plus.a": "https://files.ballistica.net/cache/ba1/be/19/b5458933dfc7371d91ecfcd2e06f",
|
"build/prefab/lib/linux_arm64_gui/debug/libballistica_plus.a": "https://files.ballistica.net/cache/ba1/be/19/b5458933dfc7371d91ecfcd2e06f",
|
||||||
"build/prefab/lib/linux_arm64_gui/release/libballistica_plus.a": "https://files.ballistica.net/cache/ba1/4e/48/123b806cbe6ddb3d9a8368bbb4f8",
|
"build/prefab/lib/linux_arm64_gui/release/libballistica_plus.a": "https://files.ballistica.net/cache/ba1/4e/48/123b806cbe6ddb3d9a8368bbb4f8",
|
||||||
"build/prefab/lib/linux_arm64_server/debug/libballistica_plus.a": "https://files.ballistica.net/cache/ba1/be/19/b5458933dfc7371d91ecfcd2e06f",
|
"build/prefab/lib/linux_arm64_server/debug/libballistica_plus.a": "https://files.ballistica.net/cache/ba1/be/19/b5458933dfc7371d91ecfcd2e06f",
|
||||||
@ -4104,14 +4104,14 @@
|
|||||||
"build/prefab/lib/mac_x86_64_gui/release/libballistica_plus.a": "https://files.ballistica.net/cache/ba1/7e/fa/291fd7e935502ced7e99b8c8f7f0",
|
"build/prefab/lib/mac_x86_64_gui/release/libballistica_plus.a": "https://files.ballistica.net/cache/ba1/7e/fa/291fd7e935502ced7e99b8c8f7f0",
|
||||||
"build/prefab/lib/mac_x86_64_server/debug/libballistica_plus.a": "https://files.ballistica.net/cache/ba1/e1/cb/7e8440699e59e8646da25aa5782b",
|
"build/prefab/lib/mac_x86_64_server/debug/libballistica_plus.a": "https://files.ballistica.net/cache/ba1/e1/cb/7e8440699e59e8646da25aa5782b",
|
||||||
"build/prefab/lib/mac_x86_64_server/release/libballistica_plus.a": "https://files.ballistica.net/cache/ba1/7e/fa/291fd7e935502ced7e99b8c8f7f0",
|
"build/prefab/lib/mac_x86_64_server/release/libballistica_plus.a": "https://files.ballistica.net/cache/ba1/7e/fa/291fd7e935502ced7e99b8c8f7f0",
|
||||||
"build/prefab/lib/windows/Debug_Win32/BallisticaKitGenericPlus.lib": "https://files.ballistica.net/cache/ba1/5d/1c/956723a220dbd69a6fccbdec29df",
|
"build/prefab/lib/windows/Debug_Win32/BallisticaKitGenericPlus.lib": "https://files.ballistica.net/cache/ba1/5c/96/b8a42d96d3af5ffff38c57efbd24",
|
||||||
"build/prefab/lib/windows/Debug_Win32/BallisticaKitGenericPlus.pdb": "https://files.ballistica.net/cache/ba1/e5/5c/c7737071206b53292cdc954bfde5",
|
"build/prefab/lib/windows/Debug_Win32/BallisticaKitGenericPlus.pdb": "https://files.ballistica.net/cache/ba1/4d/f3/1a23c221637c7b7d477b44e4fd81",
|
||||||
"build/prefab/lib/windows/Debug_Win32/BallisticaKitHeadlessPlus.lib": "https://files.ballistica.net/cache/ba1/50/78/552f6c71715a4939b0ca67468efc",
|
"build/prefab/lib/windows/Debug_Win32/BallisticaKitHeadlessPlus.lib": "https://files.ballistica.net/cache/ba1/e6/dd/ec3db84a6f9c3a94f924dbbaf88a",
|
||||||
"build/prefab/lib/windows/Debug_Win32/BallisticaKitHeadlessPlus.pdb": "https://files.ballistica.net/cache/ba1/71/5f/bcbdc8e71a29515ed95ec3c33fae",
|
"build/prefab/lib/windows/Debug_Win32/BallisticaKitHeadlessPlus.pdb": "https://files.ballistica.net/cache/ba1/c3/dd/6767011fcea297758ee6f05f1a2a",
|
||||||
"build/prefab/lib/windows/Release_Win32/BallisticaKitGenericPlus.lib": "https://files.ballistica.net/cache/ba1/de/64/60396bbaba483ff09a421998372a",
|
"build/prefab/lib/windows/Release_Win32/BallisticaKitGenericPlus.lib": "https://files.ballistica.net/cache/ba1/fd/ce/93577e4d73568f2a866f2da07ffd",
|
||||||
"build/prefab/lib/windows/Release_Win32/BallisticaKitGenericPlus.pdb": "https://files.ballistica.net/cache/ba1/8b/92/725858ebddf7f1d35562ce516e59",
|
"build/prefab/lib/windows/Release_Win32/BallisticaKitGenericPlus.pdb": "https://files.ballistica.net/cache/ba1/f0/03/af61323f340c42b2be9ebd7f8364",
|
||||||
"build/prefab/lib/windows/Release_Win32/BallisticaKitHeadlessPlus.lib": "https://files.ballistica.net/cache/ba1/15/08/f62cc35c4746f7767059e0e358b5",
|
"build/prefab/lib/windows/Release_Win32/BallisticaKitHeadlessPlus.lib": "https://files.ballistica.net/cache/ba1/97/55/985b572fd6cf68c782a93fcd891d",
|
||||||
"build/prefab/lib/windows/Release_Win32/BallisticaKitHeadlessPlus.pdb": "https://files.ballistica.net/cache/ba1/e4/39/8964fd8d1781fe1d5b09bce43d98",
|
"build/prefab/lib/windows/Release_Win32/BallisticaKitHeadlessPlus.pdb": "https://files.ballistica.net/cache/ba1/7f/ac/e2870829d60fe499298bfec614bc",
|
||||||
"src/assets/ba_data/python/babase/_mgen/__init__.py": "https://files.ballistica.net/cache/ba1/f8/85/fed7f2ed98ff2ba271f9dbe3391c",
|
"src/assets/ba_data/python/babase/_mgen/__init__.py": "https://files.ballistica.net/cache/ba1/f8/85/fed7f2ed98ff2ba271f9dbe3391c",
|
||||||
"src/assets/ba_data/python/babase/_mgen/enums.py": "https://files.ballistica.net/cache/ba1/48/4b/e6974f0a4d14be8213dc00d971c3",
|
"src/assets/ba_data/python/babase/_mgen/enums.py": "https://files.ballistica.net/cache/ba1/48/4b/e6974f0a4d14be8213dc00d971c3",
|
||||||
"src/ballistica/base/mgen/pyembed/binding_base.inc": "https://files.ballistica.net/cache/ba1/3e/7a/203e2a5d2b5bb42cfe3fd2fe16c2",
|
"src/ballistica/base/mgen/pyembed/binding_base.inc": "https://files.ballistica.net/cache/ba1/3e/7a/203e2a5d2b5bb42cfe3fd2fe16c2",
|
||||||
|
|||||||
380
tools/bacloud
380
tools/bacloud
@ -7,377 +7,17 @@ This facilitates workflows such as creating asset-packages, etc.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
|
||||||
import sys
|
import sys
|
||||||
import zlib
|
|
||||||
import time
|
|
||||||
import datetime
|
|
||||||
import subprocess
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
from dataclasses import dataclass
|
|
||||||
|
|
||||||
import requests
|
from batools.bacloud import App
|
||||||
|
|
||||||
from efro.terminal import Clr
|
|
||||||
from efro.error import CleanError
|
from efro.error import CleanError
|
||||||
from efro.dataclassio import (
|
|
||||||
dataclass_from_json,
|
|
||||||
dataclass_to_dict,
|
|
||||||
dataclass_to_json,
|
|
||||||
ioprepped,
|
|
||||||
)
|
|
||||||
from bacommon.bacloud import RequestData, ResponseData, BACLOUD_VERSION
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
try:
|
||||||
from typing import IO
|
App().run()
|
||||||
|
except KeyboardInterrupt:
|
||||||
TOOL_NAME = 'bacloud'
|
# Let's do a clean fail on keyboard interrupt.
|
||||||
|
# Can make this optional if a backtrace is ever useful.
|
||||||
TIMEOUT_SECONDS = 60 * 5
|
sys.exit(1)
|
||||||
|
except CleanError as clean_exc:
|
||||||
# Server we talk to (can override via env var).
|
clean_exc.pretty_print()
|
||||||
BACLOUD_SERVER_URL = os.getenv('BACLOUD_SERVER_URL', 'https://ballistica.net')
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
@ioprepped
|
|
||||||
@dataclass
|
|
||||||
class StateData:
|
|
||||||
"""Persistent state data stored to disk."""
|
|
||||||
|
|
||||||
login_token: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
def get_tz_offset_seconds() -> float:
|
|
||||||
"""Return the offset between utc and local time in seconds."""
|
|
||||||
tval = time.time()
|
|
||||||
utc_offset = (
|
|
||||||
datetime.datetime.fromtimestamp(tval)
|
|
||||||
- datetime.datetime.utcfromtimestamp(tval)
|
|
||||||
).total_seconds()
|
|
||||||
return utc_offset
|
|
||||||
|
|
||||||
|
|
||||||
class App:
|
|
||||||
"""Context for a run of the tool."""
|
|
||||||
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self._state = StateData()
|
|
||||||
self._project_root: Path | None = None
|
|
||||||
self._end_command_args: dict = {}
|
|
||||||
|
|
||||||
def run(self) -> None:
|
|
||||||
"""Run the tool."""
|
|
||||||
|
|
||||||
# Make sure we can locate the project bacloud is being run from.
|
|
||||||
self._project_root = Path(sys.argv[0]).parents[1]
|
|
||||||
if not all(
|
|
||||||
Path(self._project_root, name).is_dir()
|
|
||||||
for name in ('tools', 'config', 'tests')
|
|
||||||
):
|
|
||||||
raise CleanError('Unable to locate project directory.')
|
|
||||||
|
|
||||||
# Also run project prereqs checks so we can hopefully inform the user
|
|
||||||
# of missing Python modules/etc. instead of just failing cryptically.
|
|
||||||
try:
|
|
||||||
subprocess.run(
|
|
||||||
['make', '--quiet', 'prereqs'],
|
|
||||||
check=True,
|
|
||||||
cwd=self._project_root,
|
|
||||||
)
|
|
||||||
except subprocess.CalledProcessError as exc:
|
|
||||||
raise CleanError(
|
|
||||||
'"make prereqs" check failed. '
|
|
||||||
'Install missing requirements and try again.'
|
|
||||||
) from exc
|
|
||||||
|
|
||||||
self._load_state()
|
|
||||||
|
|
||||||
# Simply pass all args to the server and let it do the thing.
|
|
||||||
self.run_interactive_command(sys.argv[1:])
|
|
||||||
|
|
||||||
self._save_state()
|
|
||||||
|
|
||||||
@property
|
|
||||||
def _state_dir(self) -> Path:
|
|
||||||
"""The full path to the state dir."""
|
|
||||||
assert self._project_root is not None
|
|
||||||
return Path(self._project_root, '.cache/bacloud')
|
|
||||||
|
|
||||||
@property
|
|
||||||
def _state_data_path(self) -> Path:
|
|
||||||
"""The full path to the state data file."""
|
|
||||||
return Path(self._state_dir, 'state')
|
|
||||||
|
|
||||||
def _load_state(self) -> None:
|
|
||||||
if not os.path.exists(self._state_data_path):
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
with open(self._state_data_path, 'r', encoding='utf-8') as infile:
|
|
||||||
self._state = dataclass_from_json(StateData, infile.read())
|
|
||||||
except Exception:
|
|
||||||
print(
|
|
||||||
f'{Clr.RED}Error loading {TOOL_NAME} data;'
|
|
||||||
f' resetting to defaults.{Clr.RST}'
|
|
||||||
)
|
|
||||||
|
|
||||||
def _save_state(self) -> None:
|
|
||||||
if not self._state_dir.exists():
|
|
||||||
self._state_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
with open(self._state_data_path, 'w', encoding='utf-8') as outfile:
|
|
||||||
outfile.write(dataclass_to_json(self._state))
|
|
||||||
|
|
||||||
def _servercmd(
|
|
||||||
self, cmd: str, payload: dict, files: dict[str, IO] | None = None
|
|
||||||
) -> ResponseData:
|
|
||||||
"""Issue a command to the server and get a response."""
|
|
||||||
|
|
||||||
response_content: str | None = None
|
|
||||||
|
|
||||||
url = f'{BACLOUD_SERVER_URL}/bacloudcmd'
|
|
||||||
headers = {'User-Agent': f'bacloud/{BACLOUD_VERSION}'}
|
|
||||||
|
|
||||||
rdata = {
|
|
||||||
'v': BACLOUD_VERSION,
|
|
||||||
'r': dataclass_to_json(
|
|
||||||
RequestData(
|
|
||||||
command=cmd,
|
|
||||||
token=self._state.login_token,
|
|
||||||
payload=payload,
|
|
||||||
tzoffset=get_tz_offset_seconds(),
|
|
||||||
isatty=sys.stdout.isatty(),
|
|
||||||
)
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
# Trying urllib for comparison (note that this doesn't support
|
|
||||||
# files arg so not actually production ready)
|
|
||||||
if bool(False):
|
|
||||||
import urllib.request
|
|
||||||
import urllib.parse
|
|
||||||
|
|
||||||
with urllib.request.urlopen(
|
|
||||||
urllib.request.Request(
|
|
||||||
url, urllib.parse.urlencode(rdata).encode(), headers
|
|
||||||
)
|
|
||||||
) as raw_response:
|
|
||||||
if raw_response.getcode() != 200:
|
|
||||||
raise RuntimeError('Error talking to server')
|
|
||||||
response_content = raw_response.read().decode()
|
|
||||||
|
|
||||||
# Using requests module.
|
|
||||||
else:
|
|
||||||
with requests.post(
|
|
||||||
url,
|
|
||||||
headers=headers,
|
|
||||||
data=rdata,
|
|
||||||
files=files,
|
|
||||||
timeout=TIMEOUT_SECONDS,
|
|
||||||
) as response_raw:
|
|
||||||
response_raw.raise_for_status()
|
|
||||||
assert isinstance(response_raw.content, bytes)
|
|
||||||
response_content = response_raw.content.decode()
|
|
||||||
|
|
||||||
assert response_content is not None
|
|
||||||
response = dataclass_from_json(ResponseData, response_content)
|
|
||||||
|
|
||||||
# Handle a few things inline.
|
|
||||||
# (so this functionality is available even to recursive commands, etc.)
|
|
||||||
if response.message is not None:
|
|
||||||
print(response.message, end=response.message_end, flush=True)
|
|
||||||
|
|
||||||
if response.error is not None:
|
|
||||||
raise CleanError(response.error)
|
|
||||||
|
|
||||||
if response.delay_seconds > 0.0:
|
|
||||||
time.sleep(response.delay_seconds)
|
|
||||||
|
|
||||||
return response
|
|
||||||
|
|
||||||
def _upload_file(self, filename: str, call: str, args: dict) -> None:
|
|
||||||
import tempfile
|
|
||||||
|
|
||||||
print(f'Uploading {Clr.BLU}{filename}{Clr.RST}', flush=True)
|
|
||||||
with tempfile.TemporaryDirectory() as tempdir:
|
|
||||||
srcpath = Path(filename)
|
|
||||||
gzpath = Path(tempdir, 'file.gz')
|
|
||||||
subprocess.run(
|
|
||||||
f'gzip --stdout "{srcpath}" > "{gzpath}"',
|
|
||||||
shell=True,
|
|
||||||
check=True,
|
|
||||||
)
|
|
||||||
with open(gzpath, 'rb') as infile:
|
|
||||||
putfiles: dict = {'file': infile}
|
|
||||||
_response = self._servercmd(
|
|
||||||
call,
|
|
||||||
args,
|
|
||||||
files=putfiles,
|
|
||||||
)
|
|
||||||
|
|
||||||
def _handle_dir_manifest_response(self, dirmanifest: str) -> None:
|
|
||||||
from bacommon.transfer import DirectoryManifest
|
|
||||||
|
|
||||||
self._end_command_args['manifest'] = dataclass_to_dict(
|
|
||||||
DirectoryManifest.create_from_disk(Path(dirmanifest))
|
|
||||||
)
|
|
||||||
|
|
||||||
def _handle_uploads(self, uploads: tuple[list[str], str, dict]) -> None:
|
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
|
||||||
|
|
||||||
assert len(uploads) == 3
|
|
||||||
filenames, uploadcmd, uploadargs = uploads
|
|
||||||
assert isinstance(filenames, list)
|
|
||||||
assert isinstance(uploadcmd, str)
|
|
||||||
assert isinstance(uploadargs, dict)
|
|
||||||
|
|
||||||
def _do_filename(filename: str) -> None:
|
|
||||||
self._upload_file(filename, uploadcmd, uploadargs)
|
|
||||||
|
|
||||||
# Here we can run uploads concurrently if that goes faster...
|
|
||||||
# (should keep an eye on this to make sure its thread safe
|
|
||||||
# and behaves itself)
|
|
||||||
with ThreadPoolExecutor(max_workers=4) as executor:
|
|
||||||
# Convert the generator to a list to trigger any
|
|
||||||
# exceptions that occurred.
|
|
||||||
list(executor.map(_do_filename, filenames))
|
|
||||||
|
|
||||||
def _handle_deletes(self, deletes: list[str]) -> None:
|
|
||||||
"""Handle file deletes."""
|
|
||||||
for fname in deletes:
|
|
||||||
# Server shouldn't be sending us dir paths here.
|
|
||||||
assert not os.path.isdir(fname)
|
|
||||||
os.unlink(fname)
|
|
||||||
|
|
||||||
def _handle_downloads_inline(
|
|
||||||
self,
|
|
||||||
downloads_inline: dict[str, str],
|
|
||||||
) -> None:
|
|
||||||
"""Handle inline file data to be saved to the client."""
|
|
||||||
import base64
|
|
||||||
|
|
||||||
for fname, fdata in downloads_inline.items():
|
|
||||||
# If there's a directory where we want our file to go, clear it
|
|
||||||
# out first. File deletes should have run before this so
|
|
||||||
# everything under it should be empty and thus killable via rmdir.
|
|
||||||
if os.path.isdir(fname):
|
|
||||||
for basename, dirnames, _fn in os.walk(fname, topdown=False):
|
|
||||||
for dirname in dirnames:
|
|
||||||
os.rmdir(os.path.join(basename, dirname))
|
|
||||||
os.rmdir(fname)
|
|
||||||
|
|
||||||
dirname = os.path.dirname(fname)
|
|
||||||
if dirname:
|
|
||||||
os.makedirs(dirname, exist_ok=True)
|
|
||||||
data_zipped = base64.b64decode(fdata)
|
|
||||||
data = zlib.decompress(data_zipped)
|
|
||||||
with open(fname, 'wb') as outfile:
|
|
||||||
outfile.write(data)
|
|
||||||
|
|
||||||
def _handle_dir_prune_empty(self, prunedir: str) -> None:
|
|
||||||
"""Handle pruning empty directories."""
|
|
||||||
# Walk the tree bottom-up so we can properly kill recursive empty dirs.
|
|
||||||
for basename, dirnames, filenames in os.walk(prunedir, topdown=False):
|
|
||||||
# It seems that child dirs we kill during the walk are still
|
|
||||||
# listed when the parent dir is visited, so lets make sure
|
|
||||||
# to only acknowledge still-existing ones.
|
|
||||||
dirnames = [
|
|
||||||
d for d in dirnames if os.path.exists(os.path.join(basename, d))
|
|
||||||
]
|
|
||||||
if not dirnames and not filenames and basename != prunedir:
|
|
||||||
os.rmdir(basename)
|
|
||||||
|
|
||||||
def _handle_uploads_inline(self, uploads_inline: list[str]) -> None:
|
|
||||||
"""Handle uploading files inline."""
|
|
||||||
import base64
|
|
||||||
|
|
||||||
files: dict[str, str] = {}
|
|
||||||
for filepath in uploads_inline:
|
|
||||||
if not os.path.exists(filepath):
|
|
||||||
raise CleanError(f'File not found: {filepath}')
|
|
||||||
with open(filepath, 'rb') as infile:
|
|
||||||
data = infile.read()
|
|
||||||
data_zipped = zlib.compress(data)
|
|
||||||
data_base64 = base64.b64encode(data_zipped).decode()
|
|
||||||
files[filepath] = data_base64
|
|
||||||
self._end_command_args['uploads_inline'] = files
|
|
||||||
|
|
||||||
def _handle_open_url(self, url: str) -> None:
|
|
||||||
import webbrowser
|
|
||||||
|
|
||||||
print(f'{Clr.CYN}(url: {url}){Clr.RST}')
|
|
||||||
webbrowser.open(url)
|
|
||||||
|
|
||||||
def _handle_input_prompt(self, prompt: str, as_password: bool) -> None:
|
|
||||||
if as_password:
|
|
||||||
from getpass import getpass
|
|
||||||
|
|
||||||
self._end_command_args['input'] = getpass(prompt=prompt)
|
|
||||||
else:
|
|
||||||
if prompt:
|
|
||||||
print(prompt, end='', flush=True)
|
|
||||||
self._end_command_args['input'] = input()
|
|
||||||
|
|
||||||
def run_interactive_command(self, args: list[str]) -> None:
|
|
||||||
"""Run a single user command to completion."""
|
|
||||||
# pylint: disable=too-many-branches
|
|
||||||
|
|
||||||
nextcall: tuple[str, dict] | None = ('_interactive', {'a': args})
|
|
||||||
|
|
||||||
# Now talk to the server in a loop until there's nothing left to do.
|
|
||||||
while nextcall is not None:
|
|
||||||
self._end_command_args = {}
|
|
||||||
response = self._servercmd(*nextcall)
|
|
||||||
nextcall = None
|
|
||||||
|
|
||||||
if response.login is not None:
|
|
||||||
self._state.login_token = response.login
|
|
||||||
if response.logout:
|
|
||||||
self._state.login_token = None
|
|
||||||
if response.dir_manifest is not None:
|
|
||||||
self._handle_dir_manifest_response(response.dir_manifest)
|
|
||||||
if response.uploads_inline is not None:
|
|
||||||
self._handle_uploads_inline(response.uploads_inline)
|
|
||||||
if response.uploads is not None:
|
|
||||||
self._handle_uploads(response.uploads)
|
|
||||||
|
|
||||||
# Note: we handle file deletes *before* downloads. This
|
|
||||||
# way our file-download code only has to worry about creating or
|
|
||||||
# removing directories and not files, and corner cases such as
|
|
||||||
# a file getting replaced with a directory should just work.
|
|
||||||
if response.deletes:
|
|
||||||
self._handle_deletes(response.deletes)
|
|
||||||
if response.downloads_inline:
|
|
||||||
self._handle_downloads_inline(response.downloads_inline)
|
|
||||||
if response.dir_prune_empty:
|
|
||||||
self._handle_dir_prune_empty(response.dir_prune_empty)
|
|
||||||
|
|
||||||
if response.open_url is not None:
|
|
||||||
self._handle_open_url(response.open_url)
|
|
||||||
if response.input_prompt is not None:
|
|
||||||
self._handle_input_prompt(
|
|
||||||
prompt=response.input_prompt[0],
|
|
||||||
as_password=response.input_prompt[1],
|
|
||||||
)
|
|
||||||
if response.end_message is not None:
|
|
||||||
print(
|
|
||||||
response.end_message,
|
|
||||||
end=response.end_message_end,
|
|
||||||
flush=True,
|
|
||||||
)
|
|
||||||
if response.end_command is not None:
|
|
||||||
nextcall = response.end_command
|
|
||||||
for key, val in self._end_command_args.items():
|
|
||||||
# noinspection PyUnresolvedReferences
|
|
||||||
nextcall[1][key] = val
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
try:
|
|
||||||
App().run()
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
# Let's do a clean fail on keyboard interrupt.
|
|
||||||
# Can make this optional if a backtrace is ever useful.
|
|
||||||
sys.exit(1)
|
|
||||||
except CleanError as clean_exc:
|
|
||||||
clean_exc.pretty_print()
|
|
||||||
sys.exit(1)
|
|
||||||
|
|||||||
383
tools/batools/bacloud.py
Executable file
383
tools/batools/bacloud.py
Executable file
@ -0,0 +1,383 @@
|
|||||||
|
#!/usr/bin/env python3.11
|
||||||
|
# Released under the MIT License. See LICENSE for details.
|
||||||
|
#
|
||||||
|
"""A tool for interacting with ballistica's cloud services.
|
||||||
|
This facilitates workflows such as creating asset-packages, etc.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import zlib
|
||||||
|
import time
|
||||||
|
import datetime
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from efro.terminal import Clr
|
||||||
|
from efro.error import CleanError
|
||||||
|
from efro.dataclassio import (
|
||||||
|
dataclass_from_json,
|
||||||
|
dataclass_to_dict,
|
||||||
|
dataclass_to_json,
|
||||||
|
ioprepped,
|
||||||
|
)
|
||||||
|
from bacommon.bacloud import RequestData, ResponseData, BACLOUD_VERSION
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from typing import IO
|
||||||
|
|
||||||
|
TOOL_NAME = 'bacloud'
|
||||||
|
|
||||||
|
TIMEOUT_SECONDS = 60 * 5
|
||||||
|
|
||||||
|
# Server we talk to (can override via env var).
|
||||||
|
BACLOUD_SERVER_URL = os.getenv('BACLOUD_SERVER_URL', 'https://ballistica.net')
|
||||||
|
|
||||||
|
|
||||||
|
@ioprepped
|
||||||
|
@dataclass
|
||||||
|
class StateData:
|
||||||
|
"""Persistent state data stored to disk."""
|
||||||
|
|
||||||
|
login_token: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_tz_offset_seconds() -> float:
|
||||||
|
"""Return the offset between utc and local time in seconds."""
|
||||||
|
tval = time.time()
|
||||||
|
utc_offset = (
|
||||||
|
datetime.datetime.fromtimestamp(tval)
|
||||||
|
- datetime.datetime.utcfromtimestamp(tval)
|
||||||
|
).total_seconds()
|
||||||
|
return utc_offset
|
||||||
|
|
||||||
|
|
||||||
|
class App:
|
||||||
|
"""Context for a run of the tool."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._state = StateData()
|
||||||
|
self._project_root: Path | None = None
|
||||||
|
self._end_command_args: dict = {}
|
||||||
|
|
||||||
|
def run(self) -> None:
|
||||||
|
"""Run the tool."""
|
||||||
|
|
||||||
|
# Make sure we can locate the project bacloud is being run from.
|
||||||
|
self._project_root = Path(sys.argv[0]).parents[1]
|
||||||
|
if not all(
|
||||||
|
Path(self._project_root, name).is_dir()
|
||||||
|
for name in ('tools', 'config', 'tests')
|
||||||
|
):
|
||||||
|
raise CleanError('Unable to locate project directory.')
|
||||||
|
|
||||||
|
# Also run project prereqs checks so we can hopefully inform the user
|
||||||
|
# of missing Python modules/etc. instead of just failing cryptically.
|
||||||
|
try:
|
||||||
|
subprocess.run(
|
||||||
|
['make', '--quiet', 'prereqs'],
|
||||||
|
check=True,
|
||||||
|
cwd=self._project_root,
|
||||||
|
)
|
||||||
|
except subprocess.CalledProcessError as exc:
|
||||||
|
raise CleanError(
|
||||||
|
'"make prereqs" check failed. '
|
||||||
|
'Install missing requirements and try again.'
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
self._load_state()
|
||||||
|
|
||||||
|
# Simply pass all args to the server and let it do the thing.
|
||||||
|
self.run_interactive_command(sys.argv[1:])
|
||||||
|
|
||||||
|
self._save_state()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _state_dir(self) -> Path:
|
||||||
|
"""The full path to the state dir."""
|
||||||
|
assert self._project_root is not None
|
||||||
|
return Path(self._project_root, '.cache/bacloud')
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _state_data_path(self) -> Path:
|
||||||
|
"""The full path to the state data file."""
|
||||||
|
return Path(self._state_dir, 'state')
|
||||||
|
|
||||||
|
def _load_state(self) -> None:
|
||||||
|
if not os.path.exists(self._state_data_path):
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
with open(self._state_data_path, 'r', encoding='utf-8') as infile:
|
||||||
|
self._state = dataclass_from_json(StateData, infile.read())
|
||||||
|
except Exception:
|
||||||
|
print(
|
||||||
|
f'{Clr.RED}Error loading {TOOL_NAME} data;'
|
||||||
|
f' resetting to defaults.{Clr.RST}'
|
||||||
|
)
|
||||||
|
|
||||||
|
def _save_state(self) -> None:
|
||||||
|
if not self._state_dir.exists():
|
||||||
|
self._state_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
with open(self._state_data_path, 'w', encoding='utf-8') as outfile:
|
||||||
|
outfile.write(dataclass_to_json(self._state))
|
||||||
|
|
||||||
|
def _servercmd(
|
||||||
|
self, cmd: str, payload: dict, files: dict[str, IO] | None = None
|
||||||
|
) -> ResponseData:
|
||||||
|
"""Issue a command to the server and get a response."""
|
||||||
|
|
||||||
|
response_content: str | None = None
|
||||||
|
|
||||||
|
url = f'{BACLOUD_SERVER_URL}/bacloudcmd'
|
||||||
|
headers = {'User-Agent': f'bacloud/{BACLOUD_VERSION}'}
|
||||||
|
|
||||||
|
rdata = {
|
||||||
|
'v': BACLOUD_VERSION,
|
||||||
|
'r': dataclass_to_json(
|
||||||
|
RequestData(
|
||||||
|
command=cmd,
|
||||||
|
token=self._state.login_token,
|
||||||
|
payload=payload,
|
||||||
|
tzoffset=get_tz_offset_seconds(),
|
||||||
|
isatty=sys.stdout.isatty(),
|
||||||
|
)
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Trying urllib for comparison (note that this doesn't support
|
||||||
|
# files arg so not actually production ready)
|
||||||
|
if bool(False):
|
||||||
|
import urllib.request
|
||||||
|
import urllib.parse
|
||||||
|
|
||||||
|
with urllib.request.urlopen(
|
||||||
|
urllib.request.Request(
|
||||||
|
url, urllib.parse.urlencode(rdata).encode(), headers
|
||||||
|
)
|
||||||
|
) as raw_response:
|
||||||
|
if raw_response.getcode() != 200:
|
||||||
|
raise RuntimeError('Error talking to server')
|
||||||
|
response_content = raw_response.read().decode()
|
||||||
|
|
||||||
|
# Using requests module.
|
||||||
|
else:
|
||||||
|
with requests.post(
|
||||||
|
url,
|
||||||
|
headers=headers,
|
||||||
|
data=rdata,
|
||||||
|
files=files,
|
||||||
|
timeout=TIMEOUT_SECONDS,
|
||||||
|
) as response_raw:
|
||||||
|
response_raw.raise_for_status()
|
||||||
|
assert isinstance(response_raw.content, bytes)
|
||||||
|
response_content = response_raw.content.decode()
|
||||||
|
|
||||||
|
assert response_content is not None
|
||||||
|
response = dataclass_from_json(ResponseData, response_content)
|
||||||
|
|
||||||
|
# Handle a few things inline.
|
||||||
|
# (so this functionality is available even to recursive commands, etc.)
|
||||||
|
if response.message is not None:
|
||||||
|
print(response.message, end=response.message_end, flush=True)
|
||||||
|
|
||||||
|
if response.error is not None:
|
||||||
|
raise CleanError(response.error)
|
||||||
|
|
||||||
|
if response.delay_seconds > 0.0:
|
||||||
|
time.sleep(response.delay_seconds)
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
def _upload_file(self, filename: str, call: str, args: dict) -> None:
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
print(f'Uploading {Clr.BLU}{filename}{Clr.RST}', flush=True)
|
||||||
|
with tempfile.TemporaryDirectory() as tempdir:
|
||||||
|
srcpath = Path(filename)
|
||||||
|
gzpath = Path(tempdir, 'file.gz')
|
||||||
|
subprocess.run(
|
||||||
|
f'gzip --stdout "{srcpath}" > "{gzpath}"',
|
||||||
|
shell=True,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
with open(gzpath, 'rb') as infile:
|
||||||
|
putfiles: dict = {'file': infile}
|
||||||
|
_response = self._servercmd(
|
||||||
|
call,
|
||||||
|
args,
|
||||||
|
files=putfiles,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _handle_dir_manifest_response(self, dirmanifest: str) -> None:
|
||||||
|
from bacommon.transfer import DirectoryManifest
|
||||||
|
|
||||||
|
self._end_command_args['manifest'] = dataclass_to_dict(
|
||||||
|
DirectoryManifest.create_from_disk(Path(dirmanifest))
|
||||||
|
)
|
||||||
|
|
||||||
|
def _handle_uploads(self, uploads: tuple[list[str], str, dict]) -> None:
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
|
||||||
|
assert len(uploads) == 3
|
||||||
|
filenames, uploadcmd, uploadargs = uploads
|
||||||
|
assert isinstance(filenames, list)
|
||||||
|
assert isinstance(uploadcmd, str)
|
||||||
|
assert isinstance(uploadargs, dict)
|
||||||
|
|
||||||
|
def _do_filename(filename: str) -> None:
|
||||||
|
self._upload_file(filename, uploadcmd, uploadargs)
|
||||||
|
|
||||||
|
# Here we can run uploads concurrently if that goes faster...
|
||||||
|
# (should keep an eye on this to make sure its thread safe
|
||||||
|
# and behaves itself)
|
||||||
|
with ThreadPoolExecutor(max_workers=4) as executor:
|
||||||
|
# Convert the generator to a list to trigger any
|
||||||
|
# exceptions that occurred.
|
||||||
|
list(executor.map(_do_filename, filenames))
|
||||||
|
|
||||||
|
def _handle_deletes(self, deletes: list[str]) -> None:
|
||||||
|
"""Handle file deletes."""
|
||||||
|
for fname in deletes:
|
||||||
|
# Server shouldn't be sending us dir paths here.
|
||||||
|
assert not os.path.isdir(fname)
|
||||||
|
os.unlink(fname)
|
||||||
|
|
||||||
|
def _handle_downloads_inline(
|
||||||
|
self,
|
||||||
|
downloads_inline: dict[str, str],
|
||||||
|
) -> None:
|
||||||
|
"""Handle inline file data to be saved to the client."""
|
||||||
|
import base64
|
||||||
|
|
||||||
|
for fname, fdata in downloads_inline.items():
|
||||||
|
# If there's a directory where we want our file to go, clear it
|
||||||
|
# out first. File deletes should have run before this so
|
||||||
|
# everything under it should be empty and thus killable via rmdir.
|
||||||
|
if os.path.isdir(fname):
|
||||||
|
for basename, dirnames, _fn in os.walk(fname, topdown=False):
|
||||||
|
for dirname in dirnames:
|
||||||
|
os.rmdir(os.path.join(basename, dirname))
|
||||||
|
os.rmdir(fname)
|
||||||
|
|
||||||
|
dirname = os.path.dirname(fname)
|
||||||
|
if dirname:
|
||||||
|
os.makedirs(dirname, exist_ok=True)
|
||||||
|
data_zipped = base64.b64decode(fdata)
|
||||||
|
data = zlib.decompress(data_zipped)
|
||||||
|
with open(fname, 'wb') as outfile:
|
||||||
|
outfile.write(data)
|
||||||
|
|
||||||
|
def _handle_dir_prune_empty(self, prunedir: str) -> None:
|
||||||
|
"""Handle pruning empty directories."""
|
||||||
|
# Walk the tree bottom-up so we can properly kill recursive empty dirs.
|
||||||
|
for basename, dirnames, filenames in os.walk(prunedir, topdown=False):
|
||||||
|
# It seems that child dirs we kill during the walk are still
|
||||||
|
# listed when the parent dir is visited, so lets make sure
|
||||||
|
# to only acknowledge still-existing ones.
|
||||||
|
dirnames = [
|
||||||
|
d for d in dirnames if os.path.exists(os.path.join(basename, d))
|
||||||
|
]
|
||||||
|
if not dirnames and not filenames and basename != prunedir:
|
||||||
|
os.rmdir(basename)
|
||||||
|
|
||||||
|
def _handle_uploads_inline(self, uploads_inline: list[str]) -> None:
|
||||||
|
"""Handle uploading files inline."""
|
||||||
|
import base64
|
||||||
|
|
||||||
|
files: dict[str, str] = {}
|
||||||
|
for filepath in uploads_inline:
|
||||||
|
if not os.path.exists(filepath):
|
||||||
|
raise CleanError(f'File not found: {filepath}')
|
||||||
|
with open(filepath, 'rb') as infile:
|
||||||
|
data = infile.read()
|
||||||
|
data_zipped = zlib.compress(data)
|
||||||
|
data_base64 = base64.b64encode(data_zipped).decode()
|
||||||
|
files[filepath] = data_base64
|
||||||
|
self._end_command_args['uploads_inline'] = files
|
||||||
|
|
||||||
|
def _handle_open_url(self, url: str) -> None:
|
||||||
|
import webbrowser
|
||||||
|
|
||||||
|
print(f'{Clr.CYN}(url: {url}){Clr.RST}')
|
||||||
|
webbrowser.open(url)
|
||||||
|
|
||||||
|
def _handle_input_prompt(self, prompt: str, as_password: bool) -> None:
|
||||||
|
if as_password:
|
||||||
|
from getpass import getpass
|
||||||
|
|
||||||
|
self._end_command_args['input'] = getpass(prompt=prompt)
|
||||||
|
else:
|
||||||
|
if prompt:
|
||||||
|
print(prompt, end='', flush=True)
|
||||||
|
self._end_command_args['input'] = input()
|
||||||
|
|
||||||
|
def run_interactive_command(self, args: list[str]) -> None:
|
||||||
|
"""Run a single user command to completion."""
|
||||||
|
# pylint: disable=too-many-branches
|
||||||
|
|
||||||
|
nextcall: tuple[str, dict] | None = ('_interactive', {'a': args})
|
||||||
|
|
||||||
|
# Now talk to the server in a loop until there's nothing left to do.
|
||||||
|
while nextcall is not None:
|
||||||
|
self._end_command_args = {}
|
||||||
|
response = self._servercmd(*nextcall)
|
||||||
|
nextcall = None
|
||||||
|
|
||||||
|
if response.login is not None:
|
||||||
|
self._state.login_token = response.login
|
||||||
|
if response.logout:
|
||||||
|
self._state.login_token = None
|
||||||
|
if response.dir_manifest is not None:
|
||||||
|
self._handle_dir_manifest_response(response.dir_manifest)
|
||||||
|
if response.uploads_inline is not None:
|
||||||
|
self._handle_uploads_inline(response.uploads_inline)
|
||||||
|
if response.uploads is not None:
|
||||||
|
self._handle_uploads(response.uploads)
|
||||||
|
|
||||||
|
# Note: we handle file deletes *before* downloads. This
|
||||||
|
# way our file-download code only has to worry about creating or
|
||||||
|
# removing directories and not files, and corner cases such as
|
||||||
|
# a file getting replaced with a directory should just work.
|
||||||
|
if response.deletes:
|
||||||
|
self._handle_deletes(response.deletes)
|
||||||
|
if response.downloads_inline:
|
||||||
|
self._handle_downloads_inline(response.downloads_inline)
|
||||||
|
if response.dir_prune_empty:
|
||||||
|
self._handle_dir_prune_empty(response.dir_prune_empty)
|
||||||
|
|
||||||
|
if response.open_url is not None:
|
||||||
|
self._handle_open_url(response.open_url)
|
||||||
|
if response.input_prompt is not None:
|
||||||
|
self._handle_input_prompt(
|
||||||
|
prompt=response.input_prompt[0],
|
||||||
|
as_password=response.input_prompt[1],
|
||||||
|
)
|
||||||
|
if response.end_message is not None:
|
||||||
|
print(
|
||||||
|
response.end_message,
|
||||||
|
end=response.end_message_end,
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
if response.end_command is not None:
|
||||||
|
nextcall = response.end_command
|
||||||
|
for key, val in self._end_command_args.items():
|
||||||
|
# noinspection PyUnresolvedReferences
|
||||||
|
nextcall[1][key] = val
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
try:
|
||||||
|
App().run()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
# Let's do a clean fail on keyboard interrupt.
|
||||||
|
# Can make this optional if a backtrace is ever useful.
|
||||||
|
sys.exit(1)
|
||||||
|
except CleanError as clean_exc:
|
||||||
|
clean_exc.pretty_print()
|
||||||
|
sys.exit(1)
|
||||||
Loading…
x
Reference in New Issue
Block a user