# Built-ins
import base64
import decimal
import struct
from typing import Any, Callable, Iterator, List, Union
import zlib
# Project
from pobapi.constants import TREE_OFFSET
# Third-party
import requests
[docs]class CachedProperty:
"""Used as a decorator for caching properties. Works like the built-in @property decorator, except that a result is
computed on first access only, with subsequent access returning the computed result directly.
.. note:: The result replaces the decorated function on first access.
:return: Cached result."""
def __init__(self, func: Callable):
self.__name__ = func.__name__
# self.__module__ = func.__module__
# __module__ not yet implemented for collections.abc.Callable
self.__doc__ = func.__doc__
self._func = func
def __get__(self, obj: Callable, cls: Callable = None) -> Any:
if obj is None:
return self
value = self._func(obj)
setattr(obj, self._func.__name__, value)
return value
[docs]def accumulate(func: Callable) -> Callable:
"""Used as a decorator to accumulate the results a generator yields into a list.
.. note:: This is useful for list comprehensions that are cleaner written with a generator approach.
:return: Generator results."""
def _accumulate_helper(*args, **kwargs) -> List:
return list(func(*args, **kwargs))
return _accumulate_helper
[docs]def fetch_xml_from_url(url: str, timeout: float = 6.0) -> bytes:
"""Get a Path Of Building import code shared with pastebin.com.
:return: Decompressed XML build document."""
if url.startswith("https://pastebin.com/"):
raw = url.replace("https://pastebin.com/", "https://pastebin.com/raw/")
try:
request = requests.get(raw, timeout=timeout)
except requests.URLRequired as e:
raise ValueError(e, url, "is not a valid URL.") from e
except requests.Timeout as e:
print(e, "Connection timed out, try again or raise the timeout.")
except (
requests.ConnectionError,
requests.HTTPError,
requests.RequestException,
requests.TooManyRedirects,
) as e:
print(e, "Something went wrong, check it out.")
else:
return fetch_xml_from_import_code(request.text)
else:
raise ValueError(url, "is not a valid pastebin.com URL.")
[docs]def fetch_xml_from_import_code(import_code: str) -> bytes:
"""Decodes and unzips a Path Of Building import code.
:return: Decompressed XML build document."""
try:
base64_decode = base64.urlsafe_b64decode(import_code)
print(base64_decode)
decompressed_xml = zlib.decompress(base64_decode)
except (TypeError, ValueError) as e:
print(e, "Something went wrong while decoding. Fix it.")
except zlib.error as e:
print(e, "Something went wrong while decompressing. Fix it.")
else:
return decompressed_xml
[docs]def _skill_tree_nodes(url: str) -> List[int]:
"""Get a list of passive tree node IDs.
:return: Passive tree node IDs."""
bin_tree = base64.urlsafe_b64decode(url)
return list(
struct.unpack_from(
"!" + "H" * ((len(bin_tree) - TREE_OFFSET) // 2),
bin_tree,
offset=TREE_OFFSET,
)
)
[docs]def _get_stat(text: List[str], stat: str) -> Union[str, type(True)]:
"""Get the value of an item affix.
If an affix is found without a value, returns True instead.
:return: Item affix value or True."""
for line in text:
if line.startswith(stat):
_, _, result = line.partition(stat)
return result or True
[docs]def _item_text(text: List[str]) -> Iterator[str]:
"""Get all affixes on an item.
:return: Generator for an item's affixes."""
for index, line in enumerate(text):
if line.startswith("Implicits: "):
try:
yield from text[index + 1 :]
except KeyError:
return ""
def _get_text(
text: List[str], variant: str, alt_variant: str, mod_ranges: List[float]
) -> str:
def _parse_text(text_, variant_, alt_variant_, mod_ranges_):
"""Get the correct variant and item affix values for items made in Path Of Building.
:return: Multiline string of correct item variants and item affix values."""
counter = 0
# We have to advance this every time we get a line with text to replace, not every time we substitute.
for line in _item_text(text_):
if line.startswith(
"{variant:"
): # We want to skip all mods of alternative item versions.
if variant_ not in line.partition("{variant:")[-1].partition("}")[
0
].split(","):
if alt_variant_ not in line.partition("{variant:")[-1].partition(
"}"
)[0].split(","):
continue
# We have to check for '{range:' used in range tags to filter unsupported mods.
if "Adds (" in line and "{range:" in line:
# 'Adds (A-B) to (C-D) to something' mods need to be replaced twice.
value = mod_ranges_[counter]
line = _calculate_mod_text(line, value)
if "(" in line and "{range:" in line:
value = mod_ranges_[counter]
line = _calculate_mod_text(line, value)
counter += 1
# We are only interested in everything past the '{variant: *}' and '{range: *}' tags.
_, _, mod = line.rpartition("}")
yield mod
return "\n".join(_parse_text(text, variant, alt_variant, mod_ranges))
[docs]def _calculate_mod_text(line: str, value: float) -> str:
"""Calculate an item affix's correct value from range and offset.
:return: Corrected item affix value."""
start, stop = line.partition("(")[-1].partition(")")[0].split("-")
width = float(stop) - float(start) + 1
# Python's round() function uses banker's rounding from 3.0 onwards, we have to emulate Lua's 'towards 0' rounding.
# https://en.wikipedia.org/w/index.php?title=IEEE_754#Rounding_rules
offset = decimal.Decimal(width * value).to_integral(decimal.ROUND_HALF_DOWN)
result = float(start) + float(offset)
replace_string = f"({start}-{stop})"
result_string = f"{result if result % 1 else int(result)}"
return line.replace(replace_string, result_string)