Skip to content
Open
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions src/config/helper.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,13 @@ def validate_percent(v: int) -> int:
return v


def validate_greater_affix_count(v: int) -> int:
if not 0 <= v <= 4:
msg = "must be in [0, 4]"
raise ValueError(msg)
return v


def validate_hotkey(k: str) -> str:
keyboard.parse_hotkey(k)
return k
Expand Down
165 changes: 143 additions & 22 deletions src/config/profile_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,10 @@

from pydantic import BaseModel, ConfigDict, Field, RootModel, field_validator, model_validator

from src.config.helper import check_greater_than_zero, validate_percent
from src.config.helper import check_greater_than_zero, validate_greater_affix_count, validate_percent
from src.item.data.item_type import ItemType # noqa: TC001
from src.item.data.rarity import ItemRarity
from src.scripts import correct_name

MODULE_LOGGER = logging.getLogger(__name__)

Expand All @@ -19,6 +20,36 @@ def _parse_item_type_or_rarities(data: str | list[str]) -> list[str]:
return data


def _coerce_name_rarity_filter_data(data: str | list[str] | dict[str, str | list[str]]) -> dict[str, str | list[str]]:
Comment thread
cjshrader marked this conversation as resolved.
Outdated
if isinstance(data, dict):
return data
if isinstance(data, str):
if any(rarity.value.lower() == data.lower() for rarity in ItemRarity):
return {"rarities": [data]}
return {"name": data}
if isinstance(data, list):
if not data:
msg = "list cannot be empty"
raise ValueError(msg)
return {"rarities": data}
msg = "must be str or list"
raise ValueError(msg)


def _normalize_existing_set_name(name: str | None, field_name: str) -> str | None:
if not name:
return None

# This on module level would be a circular import, so we do it lazy for now
from src.dataloader import Dataloader # noqa: PLC0415

name = correct_name(name)
if name not in Dataloader().set_list:
msg = f"{field_name} {name} does not exist"
raise ValueError(msg)
return name


class AffixAspectFilterModel(BaseModel):
model_config = ConfigDict(extra="forbid")
name: str
Expand Down Expand Up @@ -149,10 +180,7 @@ def check_min_power(cls, v: int) -> int:
@field_validator("min_greater_affix_count")
@classmethod
def count_validator(cls, v: int) -> int:
if not 0 <= v <= 4:
msg = "must be in [0, 4]"
raise ValueError(msg)
return v
return validate_greater_affix_count(v)

@field_validator("min_percent_of_aspect")
@classmethod
Expand All @@ -177,10 +205,7 @@ def check_min_power(cls, v: int) -> int:
@field_validator("min_greater_affix_count")
@classmethod
def min_greater_affix_in_range(cls, v: int) -> int:
if not 0 <= v <= 4:
msg = "must be in [0, 4]"
raise ValueError(msg)
return v
return validate_greater_affix_count(v)

@field_validator("item_type", mode="before")
@classmethod
Expand All @@ -207,6 +232,91 @@ def unique_aspect_names_must_be_unique(self) -> ItemFilterModel:
DynamicItemFilterModel = RootModel[dict[str, ItemFilterModel]]


class SealCharmFilterModel(BaseModel):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Make sure we keep everything alphabetical, this should be lower in the file

model_config = ConfigDict(extra="forbid", populate_by_name=True)
affix_pool: list[AffixFilterCountModel] = Field(default=[], alias="affixPool")
min_greater_affix_count: int = Field(default=0, alias="minGreaterAffixCount")
rarities: list[ItemRarity] = []

@field_validator("min_greater_affix_count")
@classmethod
def min_greater_affix_in_range(cls, v: int) -> int:
return validate_greater_affix_count(v)

@field_validator("rarities", mode="before")
@classmethod
def parse_rarities(cls, data: str | list[str]) -> list[str]:
return _parse_item_type_or_rarities(data)


class CharmFilterModel(SealCharmFilterModel):
set_name: str | None = Field(default=None, alias="set")
unique_aspect: str | None = Field(default=None, alias="uniqueAspect")

@field_validator("set_name")
@classmethod
def set_must_exist(cls, name: str | None) -> str | None:
return _normalize_existing_set_name(name, "set")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

unique_aspect is only normalized here; it is never checked against Dataloader().aspect_unique_dict. An editable combo can still save an invalid value, and the filter will silently never match. This should mirror the set_name validator.


@field_validator("unique_aspect")
@classmethod
def normalize_unique_aspect(cls, name: str | None) -> str | None:
return correct_name(name)
Comment thread
cjshrader marked this conversation as resolved.


class BoostedSetFilterModel(BaseModel):
model_config = ConfigDict(extra="forbid", populate_by_name=True)
affix: AffixFilterModel | None = None
required: bool = False
set_name: str = Field(alias="set")

@field_validator("set_name")
@classmethod
def set_must_exist(cls, name: str) -> str:
normalized_name = _normalize_existing_set_name(name, "set")
if normalized_name is None:
msg = "set must not be empty"
raise ValueError(msg)
return normalized_name

@model_validator(mode="after")
def required_boosted_affix_must_have_affix(self) -> BoostedSetFilterModel:
if self.required and self.affix is None:
msg = "required boostedSets entries need affix"
Comment thread
cjshrader marked this conversation as resolved.
raise ValueError(msg)
return self


class SealFilterModel(SealCharmFilterModel):
boosted_affix: AffixFilterModel | None = Field(default=None, alias="boostedAffix")
Comment thread
cjshrader marked this conversation as resolved.
Outdated
boosted_affix_required: bool = Field(default=False, alias="boostedAffixRequired")
boosted_set: str | None = Field(default=None, alias="boostedSet")
boosted_sets: list[BoostedSetFilterModel] = Field(default=[], alias="boostedSets")
charm_slots: int = Field(default=0, alias="charmSlots")
Comment thread
cjshrader marked this conversation as resolved.
Outdated

@field_validator("boosted_set")
@classmethod
def boosted_set_must_exist(cls, name: str | None) -> str | None:
return _normalize_existing_set_name(name, "boostedSet")

@field_validator("charm_slots")
@classmethod
def charm_slots_must_be_positive(cls, v: int) -> int:
return check_greater_than_zero(v)

@model_validator(mode="after")
def required_boosted_affix_must_have_affix(self) -> SealFilterModel:
if self.boosted_affix_required and (self.boosted_affix is None or self.boosted_set is None):
msg = "boostedAffixRequired needs boostedSet and boostedAffix"
raise ValueError(msg)
return self


DynamicSealCharmFilterModel = RootModel[dict[str, SealCharmFilterModel]]
Comment thread
cjshrader marked this conversation as resolved.
DynamicCharmFilterModel = RootModel[dict[str, CharmFilterModel]]
DynamicSealFilterModel = RootModel[dict[str, SealFilterModel]]


class SigilPriority(enum.StrEnum):
blacklist = enum.auto()
whitelist = enum.auto()
Expand Down Expand Up @@ -295,19 +405,28 @@ def name_must_exist(cls, name: str) -> str:
@model_validator(mode="before")
@classmethod
def parse_data(cls, data: str | list[str] | dict[str, str | list[str]]) -> dict[str, str | list[str]]:
if isinstance(data, dict):
return data
if isinstance(data, str):
if any(rarity.value.lower() == data.lower() for rarity in ItemRarity):
return {"rarities": [data]}
return {"name": data}
if isinstance(data, list):
if not data:
msg = "list cannot be empty"
raise ValueError(msg)
return {"rarities": data}
msg = "must be str or list"
raise ValueError(msg)
return _coerce_name_rarity_filter_data(data)

@field_validator("rarities", mode="before")
@classmethod
def parse_rarities(cls, data: str | list[str]) -> list[str]:
return _parse_item_type_or_rarities(data)


class NameRarityFilterModel(BaseModel):
Comment thread
cjshrader marked this conversation as resolved.
Outdated
model_config = ConfigDict(extra="forbid")
name: str | None = None
rarities: list[ItemRarity] = []

@field_validator("name")
@classmethod
def normalize_name(cls, name: str | None) -> str | None:
return correct_name(name)

@model_validator(mode="before")
@classmethod
def parse_data(cls, data: str | list[str] | dict[str, str | list[str]]) -> dict[str, str | list[str]]:
return _coerce_name_rarity_filter_data(data)

@field_validator("rarities", mode="before")
@classmethod
Expand All @@ -319,8 +438,10 @@ class ProfileModel(BaseModel):
model_config = ConfigDict(extra="forbid", populate_by_name=True)
affixes: list[DynamicItemFilterModel] = Field(default=[], alias="Affixes")
aspect_upgrades: list[str] = Field(default=[], alias="AspectUpgrades")
charms: list[DynamicCharmFilterModel] = Field(default=[], alias="Charms")
global_uniques: list[GlobalUniqueModel] = Field(default=[], alias="GlobalUniques")
name: str
seals: list[DynamicSealFilterModel] = Field(default=[], alias="Seals")
sigils: SigilFilterModel = Field(
default=SigilFilterModel(blacklist=[], whitelist=[], priority=SigilPriority.blacklist), alias="Sigils"
)
Expand Down
6 changes: 6 additions & 0 deletions src/dataloader.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ class Dataloader:
filter_after_keyword = []
filter_words = []
item_types_dict = {}
set_list = []
tooltips = {}
tribute_dict = {}

Expand Down Expand Up @@ -95,3 +96,8 @@ def load_data(self):
encoding="utf-8"
) as f:
self.aspect_unique_dict = json.load(f)

with pathlib.Path(BASE_DIR / f"assets/lang/{IniConfigLoader().general.language}/sets.json").open(
encoding="utf-8"
) as f:
self.set_list = json.load(f)
41 changes: 35 additions & 6 deletions src/gui/importer/d4builds.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,17 @@
AffixFilterCountModel,
AffixFilterModel,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wish you had saved this for a future PR, but I guess we'll leave it here now

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For now though I'm not going to be looking at importer code very closely, we'll come back to it after we know filtering works.

AspectUniqueFilterModel,
CharmFilterModel,
ItemFilterModel,
ProfileModel,
SealFilterModel,
)
from src.dataloader import Dataloader
from src.gui.importer.gui_common import (
add_mythics_to_filters,
add_to_profiles,
build_default_profile_file_name,
create_seal_charm_filter,
fix_offhand_type,
fix_weapon_type,
get_class_name,
Expand Down Expand Up @@ -95,6 +98,8 @@ def import_d4builds(config: ImportConfig, driver: ChromiumDriver = None):
raise D4BuildsError(msg)
slot_to_unique_name_map = _get_item_slots(data=data)
finished_filters = []
charm_filters = []
seal_filters = []
mythic_names = []
aspect_upgrade_filters = _get_legendary_aspects(data=data)
for item in items[0]:
Expand Down Expand Up @@ -177,6 +182,20 @@ def import_d4builds(config: ImportConfig, driver: ChromiumDriver = None):
else:
item_filter.item_type = [item_type]

if item_type in [ItemType.HoradricSeal, ItemType.Charm]:
seal_charm_filters = charm_filters if item_type == ItemType.Charm else seal_filters
filter_name = _unique_filter_name(item_type.name, seal_charm_filters)
seal_charm_model = CharmFilterModel if item_type == ItemType.Charm else SealFilterModel
seal_charm_filters.append({
filter_name: create_seal_charm_filter(
affixes=affixes,
rarity=rarity,
require_gas=config.require_greater_affixes,
model_type=seal_charm_model,
)
})
continue

# We don't bother importing affixes for mythics
if rarity != ItemRarity.Mythic:
item_filter.affix_pool = [
Expand All @@ -192,15 +211,16 @@ def import_d4builds(config: ImportConfig, driver: ChromiumDriver = None):
]
item_filter.min_power = 100
filter_name_template = item_filter.item_type[0].name if item_type else slot.replace(" ", "")
filter_name = filter_name_template
i = 2
while any(filter_name == next(iter(x)) for x in finished_filters):
filter_name = f"{filter_name_template}{i}"
i += 1
filter_name = _unique_filter_name(filter_name_template, finished_filters)
finished_filters.append({filter_name: item_filter})
# Place all mythics in a single filter
add_mythics_to_filters(mythic_names, finished_filters)
profile = ProfileModel(name="imported profile", Affixes=sort_profile_filters(finished_filters))
profile = ProfileModel(
name="imported profile",
Affixes=sort_profile_filters(finished_filters),
Charms=sort_profile_filters(charm_filters),
Seals=sort_profile_filters(seal_filters),
)
if config.import_aspect_upgrades and aspect_upgrade_filters:
profile.aspect_upgrades = aspect_upgrade_filters

Expand Down Expand Up @@ -324,6 +344,15 @@ def _get_affix_name(stat: lxml.html.HtmlElement) -> str:
return ""


def _unique_filter_name(filter_name_template: str, filters: list[dict]) -> str:
filter_name = filter_name_template
i = 2
while any(filter_name == next(iter(existing_filter)) for existing_filter in filters):
filter_name = f"{filter_name_template}{i}"
i += 1
return filter_name


if __name__ == "__main__":
src.logger.setup()
URLS = ["https://d4builds.gg/builds/whirlwind-barbarian-endgame/?var=4"]
Expand Down
29 changes: 28 additions & 1 deletion src/gui/importer/gui_common.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,16 @@

from src import __version__
from src.config.loader import IniConfigLoader
from src.config.profile_models import AspectUniqueFilterModel, ItemFilterModel, ProfileModel
from src.config.profile_models import (
AffixFilterCountModel,
AffixFilterModel,
AspectUniqueFilterModel,
ItemFilterModel,
ProfileModel,
SealCharmFilterModel,
)
from src.config.settings_models import BrowserType
from src.item.data.affix import Affix, AffixType
from src.item.data.item_type import ItemType

if TYPE_CHECKING:
Expand Down Expand Up @@ -188,6 +196,25 @@ def update_mingreateraffixcount(item_filter: ItemFilterModel, require_gas: bool)
item_filter.min_greater_affix_count = 0


def create_seal_charm_filter(
affixes: list[Affix], rarity, require_gas: bool, model_type: type[SealCharmFilterModel] = SealCharmFilterModel
) -> SealCharmFilterModel:
seal_charm_filter = model_type(
affix_pool=[
AffixFilterCountModel(
count=[
AffixFilterModel(name=affix.name, want_greater=affix.type == AffixType.greater) for affix in affixes
],
min_count=min(3, len(affixes)),
)
],
rarities=[rarity] if rarity is not None else [],
)
if require_gas:
seal_charm_filter.min_greater_affix_count = len([affix for affix in affixes if affix.type == AffixType.greater])
return seal_charm_filter


def add_mythics_to_filters(mythic_names, finished_filters):
if mythic_names:
mythic_filter = ItemFilterModel()
Expand Down
Loading
Loading