diff --git a/.gitignore b/.gitignore
index 5c63dce..f6d1e5a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -60,4 +60,7 @@ _dummy_pxmodrim.py
# OpenCode
.opencode/**
-opencode.json
\ No newline at end of file
+opencode.json
+
+# Steam workshop plugin
+src/pxmodrim/ui/plugins/steam_workshop/
\ No newline at end of file
diff --git a/companion-mods/rimworld-utils b/companion-mods/rimworld-utils
index 60438f5..796809a 160000
--- a/companion-mods/rimworld-utils
+++ b/companion-mods/rimworld-utils
@@ -1 +1 @@
-Subproject commit 60438f539271420abe5ebcf1505abfeb0d84b65c
+Subproject commit 796809a007be73abd1a5de19da59c321825faa48
diff --git a/justfile b/justfile
index 445b0d4..4bff771 100644
--- a/justfile
+++ b/justfile
@@ -40,6 +40,21 @@ linux-copy-desktop:
sed "s|Icon=pxmodrim|Icon={{justfile_directory()}}/src/pxmodrim/ui/assets/logo.svg|" packaging/linux/pxmodrim.desktop > ~/.local/share/applications/pxmodrim.desktop
kbuildsycoca6 --noincremental
+bench name="":
+ #!/usr/bin/env python3
+ import glob, subprocess, sys
+ from pathlib import Path
+ root = Path("{{justfile_directory()}}") / "tests" / "benchmarks"
+ scripts = sorted(glob.glob(str(root / "bench_*.py")))
+ name = "{{name}}"
+ if name and name != "all":
+ scripts = [s for s in scripts if name in s]
+ if not scripts:
+ print("no benchmark found"); sys.exit(1)
+ for s in scripts:
+ print(f" === {Path(s).stem} ===")
+ subprocess.run(["uv", "run", "python", s])
+
build:
uv run python packaging/build.py
diff --git a/packaging/build.py b/packaging/build.py
index 82aba30..8b2b014 100644
--- a/packaging/build.py
+++ b/packaging/build.py
@@ -16,6 +16,7 @@ def get_standalone_args(release: bool = False) -> list[str]:
"nuitka",
"--standalone",
"--enable-plugin=pyside6",
+ "--nofollow-import-to=pytest,pygments",
"--include-package-data=pxmodrim",
f"--output-dir={project_root / 'dist'}",
"--assume-yes-for-downloads",
diff --git a/pyproject.toml b/pyproject.toml
index 39ba7b6..375c43f 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -9,9 +9,7 @@ dependencies = [
"loguru>=0.7.3",
"lxml>=6.1.1",
"msgspec>=0.21.1",
- "natsort>=8.4.0",
- "networkx>=3.6.1",
- "pyside6>=6.11.1,<6.12",
+ "pyside6>=6.11.1",
"qasync>=0.28.0",
"toposort>=1.10",
"ttimer>=0.0.2",
diff --git a/src/pxmodrim/core/checker/sort.py b/src/pxmodrim/core/checker/sort.py
index 8bfde5b..e9fc0b5 100644
--- a/src/pxmodrim/core/checker/sort.py
+++ b/src/pxmodrim/core/checker/sort.py
@@ -1,5 +1,6 @@
from __future__ import annotations
+import heapq
from typing import TYPE_CHECKING
from pxmodrim.core.checker.graph import ConstraintGraph, EdgeType
@@ -62,17 +63,16 @@ def topological_sort(
def _key(pid: PackageId, config_priority=config_priority) -> tuple:
return (config_priority.get(pid, len(config_priority)), pid.lower())
- queue = [pid for pid, deg in indegree.items() if deg == 0]
- queue.sort(key=_key)
+ queue = [(_key(pid), pid) for pid, deg in indegree.items() if deg == 0]
+ heapq.heapify(queue)
while queue:
- pid = queue.pop(0)
+ _, pid = heapq.heappop(queue)
order.append(pid)
for dependent in rev.get(pid, set()):
indegree[dependent] -= 1
if indegree[dependent] == 0:
- queue.append(dependent)
- queue.sort(key=_key)
+ heapq.heappush(queue, (_key(dependent), dependent))
remaining = tier_pids - set(order)
if remaining:
@@ -93,20 +93,18 @@ def _build_deps_and_rev_deps(
if pid not in all_pids:
continue
- for edge in graph.edges_of_type(pid, EdgeType.LOAD_BEFORE):
- if edge.target in all_pids:
- deps.setdefault(edge.target, set()).add(pid)
- rev_deps.setdefault(pid, set()).add(edge.target)
-
- for edge in graph.edges_of_type(pid, EdgeType.LOAD_AFTER):
- if edge.target in all_pids:
- deps.setdefault(pid, set()).add(edge.target)
- rev_deps.setdefault(edge.target, set()).add(pid)
-
- for edge in graph.edges_of_type(pid, EdgeType.DEPENDENCY):
- if edge.target in all_pids:
- deps.setdefault(pid, set()).add(edge.target)
- rev_deps.setdefault(edge.target, set()).add(pid)
+ deps_pid = deps[pid]
+ rev_pid = rev_deps[pid]
+ for edge in graph.outgoing(pid):
+ target = edge.target
+ if target not in all_pids:
+ continue
+ if edge.type == EdgeType.LOAD_BEFORE:
+ deps[target].add(pid)
+ rev_pid.add(target)
+ elif edge.type in (EdgeType.LOAD_AFTER, EdgeType.DEPENDENCY):
+ deps_pid.add(target)
+ rev_deps[target].add(pid)
return deps, rev_deps
diff --git a/src/pxmodrim/core/models/metadata/parsing.py b/src/pxmodrim/core/models/metadata/parsing.py
index 2ef2f48..ebd1af0 100644
--- a/src/pxmodrim/core/models/metadata/parsing.py
+++ b/src/pxmodrim/core/models/metadata/parsing.py
@@ -6,6 +6,7 @@
from pathlib import Path
from typing import Any
+import lxml.etree as ET
from loguru import logger
from pxmodrim.core.constants import (
@@ -22,7 +23,7 @@
ListedMod,
)
from pxmodrim.core.utils import find_about_xml
-from pxmodrim.core.xml import xml_path_to_json
+from pxmodrim.core.xml import _text
class MalformedDataException(Exception):
@@ -424,6 +425,264 @@ def create_about_mod(
return mod.valid, mod
+def _match_versioned_child(
+ parent: ET._Element, target_version: str
+) -> ET._Element | None:
+ """Find the versioned child element matching target_version (*.*)."""
+ try:
+ major, minor = target_version.split(".")[:2]
+ except ValueError:
+ return None
+ key = f"v{major}.{minor}"
+ found = parent.find(key)
+ if found is None:
+ found = parent.find(f"{major}.{minor}")
+ if found is not None:
+ return found
+ for child in parent:
+ if re.match(key, child.tag):
+ return child
+ return None
+
+
+def _dep_from_li(li: ET._Element) -> DependencyMod:
+ """Parse a
element from into a DependencyMod."""
+ dep = DependencyMod()
+ pid = _text(li, "packageId")
+ if pid:
+ dep.package_id = CaseInsensitiveStr(pid)
+ name = _text(li, "displayName")
+ if name:
+ dep.name = name
+ url = _text(li, "workshopUrl")
+ if url:
+ dep.workshop_url = url
+ alt_el = li.find("alternativePackageIds")
+ if alt_el is not None:
+ for alt_li in alt_el:
+ if alt_li.tag == "li" and alt_li.text and alt_li.text.strip():
+ dep.alternative_package_ids.add(
+ CaseInsensitiveStr(alt_li.text.strip())
+ )
+ return dep
+
+
+def _element_value(el: ET._Element | None) -> str | list[str] | None:
+ """Extract text or children as a string or list of strings."""
+ if el is None:
+ return None
+ children = [ch for ch in el if ch.tag == "li"]
+ if children:
+ return [
+ c.text.strip()
+ for c in children
+ if c.text and c.text.strip()
+ ]
+ if el.text:
+ text = el.text.strip()
+ return text or None
+ return None
+
+
+def _create_about_mod_from_element(
+ root: ET._Element,
+ target_version: str,
+ prefer_versioned: bool = True,
+) -> AboutXmlMod:
+ """Build AboutXmlMod in a single pass over the element children."""
+ mod = AboutXmlMod()
+ rules = BaseRules()
+
+ deps_el: ET._Element | None = None
+ deps_bv: bool = False
+ load_before_li: list[str] = []
+ load_after_li: list[str] = []
+ incompat_li: list[str] = []
+ force_before_li: list[str] = []
+ force_after_li: list[str] = []
+ load_before_bv_el: ET._Element | None = None
+ load_after_bv_el: ET._Element | None = None
+ incompat_bv_el: ET._Element | None = None
+
+ for child in root:
+ tag = child.tag
+ if tag == "packageId":
+ t = _element_value(child)
+ if isinstance(t, str) and t:
+ mod.package_id = CaseInsensitiveStr(t)
+ else:
+ _set_mod_invalid(
+ mod,
+ f"packageId missing or invalid: {t}. "
+ f"Assigned sentinel '{DEFAULT_MISSING_PACKAGEID}'.",
+ )
+ mod.package_id = CaseInsensitiveStr(DEFAULT_MISSING_PACKAGEID)
+
+ elif tag == "steamAppId":
+ t = _element_value(child)
+ if isinstance(t, str) and t.isdigit():
+ mod.steam_app_id = int(t)
+
+ elif tag == "name":
+ t = _element_value(child)
+ if isinstance(t, str):
+ mod.name = t
+
+ elif tag == "description":
+ t = _element_value(child)
+ if isinstance(t, str):
+ mod.description = t
+
+ elif tag == "author":
+ t = _element_value(child)
+ if isinstance(t, str):
+ mod.authors.append(t)
+
+ elif tag == "authors":
+ t = _element_value(child)
+ if isinstance(t, list):
+ mod.authors.extend(t)
+
+ elif tag == "supportedVersions":
+ t = _element_value(child)
+ if isinstance(t, list):
+ mod.supported_versions = set(t)
+
+ elif tag == "modVersion":
+ t = _element_value(child)
+ if isinstance(t, str):
+ mod.mod_version = t
+
+ elif tag == "modIconPath":
+ t = _element_value(child)
+ if isinstance(t, str):
+ mod.mod_icon_path = Path(t)
+
+ elif tag == "url":
+ t = _element_value(child)
+ if isinstance(t, str):
+ mod.url = t
+
+ elif tag == "modDependencies":
+ if not deps_bv:
+ deps_el = child
+ elif tag == "modDependenciesByVersion":
+ if prefer_versioned:
+ matched = _match_versioned_child(child, target_version)
+ if matched is not None:
+ deps_el = matched
+ deps_bv = True
+
+ elif tag == "loadBefore":
+ t = _element_value(child)
+ if isinstance(t, list):
+ load_before_li = t
+ elif tag == "loadBeforeByVersion":
+ if prefer_versioned:
+ matched = _match_versioned_child(child, target_version)
+ if matched is not None:
+ load_before_bv_el = matched
+
+ elif tag == "loadAfter":
+ t = _element_value(child)
+ if isinstance(t, list):
+ load_after_li = t
+ elif tag == "loadAfterByVersion":
+ if prefer_versioned:
+ matched = _match_versioned_child(child, target_version)
+ if matched is not None:
+ load_after_bv_el = matched
+
+ elif tag == "forceLoadBefore":
+ t = _element_value(child)
+ if isinstance(t, list):
+ force_before_li = t
+ elif tag == "forceLoadAfter":
+ t = _element_value(child)
+ if isinstance(t, list):
+ force_after_li = t
+
+ elif tag == "incompatibleWith":
+ t = _element_value(child)
+ if isinstance(t, list):
+ incompat_li = t
+ elif tag == "incompatibleWithByVersion":
+ if prefer_versioned:
+ matched = _match_versioned_child(child, target_version)
+ if matched is not None:
+ incompat_bv_el = matched
+
+ elif tag == "descriptionsByVersion":
+ matched = _match_versioned_child(child, target_version)
+ if matched is not None and matched.text and matched.text.strip():
+ mod.description = matched.text.strip()
+
+ # Apply versioned overrides
+ if load_before_bv_el is not None:
+ t = _element_value(load_before_bv_el)
+ if isinstance(t, list):
+ load_before_li = t
+ if load_after_bv_el is not None:
+ t = _element_value(load_after_bv_el)
+ if isinstance(t, list):
+ load_after_li = t
+ if incompat_bv_el is not None:
+ t = _element_value(incompat_bv_el)
+ if isinstance(t, list):
+ incompat_li = t
+
+ # Combine force + regular
+ load_before_li.extend(force_before_li)
+ load_after_li.extend(force_after_li)
+
+ # Build rules
+ rules.load_before = CaseInsensitiveSet(load_before_li)
+ rules.load_after = CaseInsensitiveSet(load_after_li)
+ rules.incompatible_with = CaseInsensitiveSet(incompat_li)
+
+ # Process dependencies
+ if deps_el is not None:
+ for li in deps_el:
+ if li.tag != "li":
+ continue
+ if li.attrib.get("isNull") == "True":
+ continue
+ if not li.attrib and len(li) == 0:
+ continue
+ dep = _dep_from_li(li)
+ if dep.package_id in rules.dependencies:
+ logger.warning(
+ f"Duplicate dependency found: {dep.package_id}. Skipping."
+ )
+ else:
+ rules.dependencies[dep.package_id] = dep
+
+ mod.about_rules = rules
+
+ # DLC fallback for name/description/steamAppId
+ str_pid = str(mod.package_id)
+ dlc_appid = _get_dlc_packageid_map().get(str_pid)
+ dlc_meta = RIMWORLD_DLC_METADATA.get(dlc_appid, {}) if dlc_appid else {}
+ if dlc_meta:
+ if not mod.name or mod.name == str_pid:
+ mod.name = dlc_meta["name"]
+ if not mod.description:
+ mod.description = dlc_meta["description"]
+
+ # DLC -> RimWorld dependency
+ dlc_map = _get_dlc_packageid_map()
+ if str_pid in dlc_map and dlc_map[str_pid] != RIMWORLD_STEAM_APP_ID:
+ rimworld_pid = CaseInsensitiveStr("ludeon.rimworld")
+ if rimworld_pid not in rules.dependencies:
+ rules.dependencies[rimworld_pid] = DependencyMod(
+ package_id=rimworld_pid,
+ name="RimWorld",
+ workshop_url="https://store.steampowered.com/app/294100/RimWorld",
+ )
+
+ return mod
+
+
def _create_about_mod_from_xml(
base_path: Path,
mod_xml_path: Path,
@@ -432,38 +691,31 @@ def _create_about_mod_from_xml(
) -> tuple[bool, AboutXmlMod]:
"""Parse an About.xml file and return a validated AboutXmlMod with its path set."""
try:
- mod_data = xml_path_to_json(str(mod_xml_path))
+ tree = ET.parse(str(mod_xml_path))
except (OSError, TypeError):
logger.error(f"Unable to parse {mod_xml_path}: {traceback.format_exc()}")
return False, AboutXmlMod(valid=False)
- mod_data = {k.lower(): v for k, v in mod_data.items()}
- mod_data = mod_data.get("modmetadata", {})
-
- if not mod_data:
+ root = tree.getroot()
+ if root is None:
logger.error(f"Could not parse {mod_xml_path}.")
return False, AboutXmlMod(valid=False)
- valid, mod = create_about_mod(mod_data, target_version, prefer_versioned)
-
+ mod = _create_about_mod_from_element(root, target_version, prefer_versioned)
mod.mod_path = base_path
- return valid, mod
+ return mod.valid, mod
def create_listed_mod_from_path(
path: Path,
target_version: str,
prefer_versioned: bool = True,
- case_insensitive_about_xml: bool = True,
+ about_xml_path: Path | None = None,
) -> tuple[bool, ListedMod]:
"""Create a ListedMod from a directory path, parsing About.xml if present."""
if path.is_dir():
- about_xml_path: Path | None
- if case_insensitive_about_xml:
+ if about_xml_path is None:
about_xml_path = find_about_xml(path)
- else:
- candidate = path / "About" / "About.xml"
- about_xml_path = candidate if candidate.exists() else None
if about_xml_path is not None:
success, about_mod = _create_about_mod_from_xml(
diff --git a/src/pxmodrim/core/providers/core.py b/src/pxmodrim/core/providers/core.py
index 2192ab9..1c80a77 100644
--- a/src/pxmodrim/core/providers/core.py
+++ b/src/pxmodrim/core/providers/core.py
@@ -37,9 +37,11 @@ def _scan(t: Timer | None) -> dict[str, ListedMod]:
if data_dir.exists():
with (t or Timer())("scan_dir"):
dirs = scan_mod_directory(data_dir)
- for d in dirs:
+ for d, a in dirs.items():
with (t or Timer())("parse_xml"):
- _, mod = create_listed_mod_from_path(d, target_version)
+ _, mod = create_listed_mod_from_path(
+ d, target_version, about_xml_path=a
+ )
logger.debug(
"CoreModProvider found: {} (uuid: {})", mod.name, mod.uuid
)
diff --git a/src/pxmodrim/core/providers/local.py b/src/pxmodrim/core/providers/local.py
index 09e38f9..6cfce76 100644
--- a/src/pxmodrim/core/providers/local.py
+++ b/src/pxmodrim/core/providers/local.py
@@ -38,17 +38,19 @@ def _scan(t: Timer | None) -> dict[str, ListedMod]:
with tm("scan_dir"):
dirs = scan_mod_directory(self._path)
- def _keep(d: Path) -> ListedMod | None:
+ def _keep(d: Path, about: Path) -> ListedMod | None:
if (d / "About/PublishedFileId.txt").exists():
return None
- _, mod = create_listed_mod_from_path(d, target_version)
+ _, mod = create_listed_mod_from_path(
+ d, target_version, about_xml_path=about
+ )
mod.provider_id = self.provider_id
return mod
pool = self._pool
if pool is not None:
with tm("process_mods"):
- futures = {pool.submit(_keep, d): d for d in dirs}
+ futures = {pool.submit(_keep, d, a): d for d, a in dirs.items()}
result: dict[str, ListedMod] = {}
for f in as_completed(futures):
mod = f.result()
@@ -57,8 +59,8 @@ def _keep(d: Path) -> ListedMod | None:
else:
with tm("process_mods"):
result = {}
- for d in dirs:
- mod = _keep(d)
+ for d, a in dirs.items():
+ mod = _keep(d, a)
if mod is not None:
result[mod.uuid] = mod
return result
@@ -94,17 +96,19 @@ def _scan(t: Timer | None) -> dict[str, ListedMod]:
with tm("scan_dir"):
dirs = scan_mod_directory(self._path)
- def _keep(d: Path) -> ListedMod | None:
+ def _keep(d: Path, about: Path) -> ListedMod | None:
if not (d / "About/PublishedFileId.txt").exists():
return None
- _, mod = create_listed_mod_from_path(d, target_version)
+ _, mod = create_listed_mod_from_path(
+ d, target_version, about_xml_path=about
+ )
mod.provider_id = self.provider_id
return mod
pool = self._pool
if pool is not None:
with tm("process_mods"):
- futures = {pool.submit(_keep, d): d for d in dirs}
+ futures = {pool.submit(_keep, d, a): d for d, a in dirs.items()}
result: dict[str, ListedMod] = {}
for f in as_completed(futures):
mod = f.result()
@@ -113,8 +117,8 @@ def _keep(d: Path) -> ListedMod | None:
else:
with tm("process_mods"):
result = {}
- for d in dirs:
- mod = _keep(d)
+ for d, a in dirs.items():
+ mod = _keep(d, a)
if mod is not None:
result[mod.uuid] = mod
return result
diff --git a/src/pxmodrim/core/services/mod_discovery.py b/src/pxmodrim/core/services/mod_discovery.py
index 2300574..bdc189f 100644
--- a/src/pxmodrim/core/services/mod_discovery.py
+++ b/src/pxmodrim/core/services/mod_discovery.py
@@ -1,5 +1,6 @@
from __future__ import annotations
+import os
from pathlib import Path
from loguru import logger
@@ -9,18 +10,21 @@
from pxmodrim.core.utils import find_about_xml
-def scan_mod_directory(mods_path: Path) -> list[Path]:
- """Scan a directory for mod subdirectories, returning those containing About.xml."""
+def scan_mod_directory(mods_path: Path) -> dict[Path, Path]:
+ """Scan a directory for mod subdirectories, returning {mod_path: about_xml_path}."""
if not mods_path.exists() or not mods_path.is_dir():
logger.warning(f"Mod directory not found: {mods_path}")
- return []
-
- results: list[Path] = []
- for entry in mods_path.iterdir():
- if not entry.is_dir():
- continue
- if find_about_xml(entry):
- results.append(entry)
+ return {}
+
+ results: dict[Path, Path] = {}
+ with os.scandir(mods_path) as it:
+ for entry in it:
+ if not entry.is_dir():
+ continue
+ p = Path(entry.path)
+ about = find_about_xml(p)
+ if about:
+ results[p] = about
return results
diff --git a/src/pxmodrim/core/utils.py b/src/pxmodrim/core/utils.py
index 704136b..b18f713 100644
--- a/src/pxmodrim/core/utils.py
+++ b/src/pxmodrim/core/utils.py
@@ -7,6 +7,9 @@ def find_about_xml(mod_path: Path) -> Path | None:
"""Find About.xml in a mod directory (case-insensitive)."""
if not mod_path.is_dir():
return None
+ candidate = mod_path / "About" / "About.xml"
+ if candidate.is_file():
+ return candidate
for entry in mod_path.iterdir():
if entry.name.lower() == "about" and entry.is_dir():
for child in entry.iterdir():
diff --git a/src/pxmodrim/core/xml.py b/src/pxmodrim/core/xml.py
index 6d092ef..dfffae8 100644
--- a/src/pxmodrim/core/xml.py
+++ b/src/pxmodrim/core/xml.py
@@ -7,6 +7,15 @@
from loguru import logger
+def _text(elem: ET._Element, tag: str) -> str | None:
+ """Return stripped text of the first child matching *tag*, or None."""
+ found = elem.find(tag)
+ if found is None or found.text is None:
+ return None
+ text = found.text.strip()
+ return text or None
+
+
def etree_to_dict(t: Any) -> dict[str, Any]:
"""Recursively convert an lxml Element tree into a plain nested dictionary."""
d: dict[str, Any] = {str(t.tag): {}}
diff --git a/src/pxmodrim/ui/panels/about_panel.py b/src/pxmodrim/ui/panels/about_panel.py
index 9379ab1..bc697ab 100644
--- a/src/pxmodrim/ui/panels/about_panel.py
+++ b/src/pxmodrim/ui/panels/about_panel.py
@@ -139,8 +139,6 @@ def _create_credits_tab(self) -> QWidget:
("loguru", "MIT"),
("lxml", "BSD-3-Clause"),
("msgspec", "BSD-3-Clause"),
- ("natsort", "MIT"),
- ("networkx", "BSD-3-Clause"),
("pyside6", "LGPL-3.0 / GPL-2.0 / GPL-3.0"),
("qasync", "BSD-2-Clause"),
("toposort", "Apache-2.0"),
diff --git a/src/pxmodrim/ui/window/main_window.py b/src/pxmodrim/ui/window/main_window.py
index dbf45e9..f72d22b 100644
--- a/src/pxmodrim/ui/window/main_window.py
+++ b/src/pxmodrim/ui/window/main_window.py
@@ -343,9 +343,14 @@ async def _auto_sort(self) -> None:
elapsed = time.monotonic() - t0
self.mod_list.model.reorder(ordered_uuids)
self._ctx.diagnostics_service.reorder(ordered_uuids)
- logger.info("auto-sort: {} mods sorted in {:.1f}s", len(ordered_uuids), elapsed)
+ elapsed_ms = elapsed * 1000
+ logger.info(
+ "auto-sort: {} mods sorted in {:.0f}ms",
+ len(ordered_uuids),
+ elapsed_ms,
+ )
self._toast_manager.success(
- f"Sorted {len(ordered_uuids)} mods in {elapsed:.1f}s", 5000
+ f"Sorted {len(ordered_uuids)} mods in {elapsed_ms:.0f}ms", 5000
)
@asyncSlot()
diff --git a/tests/benchmarks/bench_discovery.py b/tests/benchmarks/bench_discovery.py
new file mode 100644
index 0000000..95ac483
--- /dev/null
+++ b/tests/benchmarks/bench_discovery.py
@@ -0,0 +1,99 @@
+from __future__ import annotations
+
+import random
+import string
+import sys
+import tempfile
+import time
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
+
+from loguru import logger
+
+logger.remove()
+
+from pxmodrim.core.models.metadata.parsing import ( # noqa: E402
+ create_listed_mod_from_path,
+)
+from pxmodrim.core.services.mod_discovery import scan_mod_directory # noqa: E402
+
+ABOUT_XML = """\
+
+
+ {name}
+ {author}
+ {author}.{name}
+ https://steamcommunity.com/sharedfiles/filedetails/?id={sid}
+
+ 1.4
+ 1.5
+
+
+ {dep}
+
+ A benchmark mod #{i}
+ 1.0.0
+"""
+
+
+def make_mod_dir(root: Path, i: int, dep_pid: str) -> Path:
+ author = random.choice(string.ascii_lowercase[:5])
+ name = f"benchmark_mod_{i:04d}"
+
+ about = ABOUT_XML.format(
+ name=name, author=author, sid=i, dep=dep_pid, i=i
+ )
+
+ mod_dir = root / name
+ about_dir = mod_dir / "About"
+ about_dir.mkdir(parents=True, exist_ok=True)
+ (about_dir / "About.xml").write_text(about)
+ (about_dir / "PublishedFileId.txt").write_text(str(i))
+ return mod_dir
+
+
+def make_mods(root: Path, count: int) -> None:
+ """Create count mod directories with dependency chains."""
+ prev_pid = "ludeon.rimworld"
+ for i in range(count):
+ make_mod_dir(root, i, prev_pid)
+ author = random.choice(string.ascii_lowercase[:5])
+ prev_pid = f"{author}.benchmark_mod_{i:04d}"
+
+
+def bench(count: int) -> None:
+ with tempfile.TemporaryDirectory() as tmp:
+ root = Path(tmp) / "Mods"
+ root.mkdir()
+ make_mods(root, count)
+
+ t0 = time.perf_counter_ns()
+ scanned = scan_mod_directory(root)
+ t1 = time.perf_counter_ns()
+
+ mods = {}
+ t2 = time.perf_counter_ns()
+ for d, about in scanned.items():
+ _, mod = create_listed_mod_from_path(
+ d, "1.5", about_xml_path=about
+ )
+ mods[mod.uuid] = mod
+ t3 = time.perf_counter_ns()
+
+ scan_ms = (t1 - t0) / 1e6
+ parse_ms = (t3 - t2) / 1e6
+ total_ms = (t3 - t0) / 1e6
+ print(
+ f"{count:>5} mods: scan_dir={scan_ms:>6.1f}ms"
+ f" parse_xml={parse_ms:>6.1f}ms total={total_ms:>6.1f}ms"
+ )
+ print(
+ f" scan avg: {scan_ms / count * 1000:.1f} us/mod"
+ f" parse avg: {parse_ms / count * 1000:.1f} us/mod"
+ )
+
+
+if __name__ == "__main__":
+ for count in (50, 100, 200, 500, 1000, 2000):
+ bench(count)
diff --git a/tests/benchmarks/bench_sort.py b/tests/benchmarks/bench_sort.py
new file mode 100644
index 0000000..6d1d785
--- /dev/null
+++ b/tests/benchmarks/bench_sort.py
@@ -0,0 +1,134 @@
+from __future__ import annotations
+
+import random
+import sys
+import time
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
+
+from loguru import logger
+
+logger.remove()
+
+from pxmodrim.core.checker.graph import ConstraintGraph # noqa: E402
+from pxmodrim.core.checker.sort import topological_sort # noqa: E402
+from pxmodrim.core.models.metadata.structures import ( # noqa: E402
+ AboutXmlMod,
+ BaseRules,
+ CaseInsensitiveSet,
+ CaseInsensitiveStr,
+ DependencyMod,
+)
+from pxmodrim.core.sort.config import SortSettings, TierConfig # noqa: E402
+from pxmodrim.core.sort.models import CommunityRule # noqa: E402
+
+PackageId = CaseInsensitiveStr
+
+
+def _tier_config(pids: list[PackageId]) -> TierConfig:
+ return TierConfig(
+ tier_0=tuple(pids[:3]) if len(pids) >= 3 else tuple(pids),
+ tier_1=tuple(pids[3:6]) if len(pids) >= 6 else (),
+ tier_3=(),
+ )
+
+
+def make_mods(
+ count: int, edges_per_mod: int
+) -> tuple[
+ dict[PackageId, AboutXmlMod],
+ dict[PackageId, CommunityRule],
+]:
+ rng = random.Random(42)
+ pids = [PackageId(f"bench.mod.{i:04d}") for i in range(count)]
+ community_rules: dict[PackageId, CommunityRule] = {}
+ mods: dict[PackageId, AboutXmlMod] = {}
+
+ for i, pid in enumerate(pids):
+ deps: dict[PackageId, DependencyMod] = {}
+ load_after = CaseInsensitiveSet()
+
+ num_edges = min(edges_per_mod, i)
+ if num_edges:
+ targets = rng.sample(range(i), k=num_edges)
+ dep_count = max(1, num_edges // 2)
+ for idx in targets[:dep_count]:
+ deps[pids[idx]] = DependencyMod(
+ name=str(pids[idx]),
+ package_id=pids[idx],
+ )
+ for idx in targets[dep_count:]:
+ load_after.add(pids[idx])
+
+ mods[pid] = AboutXmlMod(
+ name=f"Bench Mod {i:04d}",
+ package_id=pid,
+ provider_id="bench",
+ valid=True,
+ about_rules=BaseRules(dependencies=deps, load_after=load_after),
+ )
+
+ if rng.random() < 0.2:
+ cr_la: set[PackageId] = set()
+ if i > 0 and rng.random() < 0.5:
+ extra = rng.randint(1, min(2, i))
+ for idx in rng.sample(range(i), k=extra):
+ cr_la.add(pids[idx])
+
+ community_rules[pid] = CommunityRule(
+ package_id=pid,
+ load_after=cr_la,
+ load_before=set(),
+ load_first=rng.random() < 0.1,
+ load_last=rng.random() < 0.1,
+ incompatible_with=set(),
+ )
+
+ return mods, community_rules
+
+
+def bench(count: int, edges_per_mod: int) -> None:
+ mods, community_rules = make_mods(count, edges_per_mod)
+ pids = list(mods.keys())
+ settings = SortSettings(
+ use_community_rules=True,
+ use_alternative_package_ids=False,
+ tier_config=_tier_config(pids),
+ )
+
+ graph = ConstraintGraph()
+
+ t0 = time.perf_counter_ns()
+ graph.build(mods, pids, settings, community_rules)
+ t1 = time.perf_counter_ns()
+
+ t2 = time.perf_counter_ns()
+ result = topological_sort(mods, graph, settings, community_rules)
+ t3 = time.perf_counter_ns()
+
+ build_ms = (t1 - t0) / 1e6
+ sort_ms = (t3 - t2) / 1e6
+ total_ms = (t3 - t0) / 1e6
+
+ assert len(result) == count
+
+ print(
+ f"{count:>5} mods ({edges_per_mod} e/m): "
+ f"graph={build_ms:>6.1f}ms "
+ f"sort={sort_ms:>6.1f}ms "
+ f"total={total_ms:>6.1f}ms"
+ )
+ print(
+ f" avg per mod: "
+ f"graph={build_ms/count*1000:>5.1f}us "
+ f"sort={sort_ms/count*1000:>5.1f}us "
+ f"total={total_ms/count*1000:>5.1f}us"
+ )
+
+
+if __name__ == "__main__":
+ print("=== bench_sort ===")
+ for count in (200, 500, 1000, 2000):
+ bench(count, edges_per_mod=5)
+ bench(2000, edges_per_mod=10)
diff --git a/uv.lock b/uv.lock
index d01869e..113f2ef 100644
--- a/uv.lock
+++ b/uv.lock
@@ -171,24 +171,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/5e/2e/a8f9eca8fd00e097d7a9e99ba8a4685db994494448e3d4f0b7f6e9a3c0f7/msgspec-0.21.1-cp312-cp312-win_arm64.whl", hash = "sha256:628aaa35c74950a8c59da330d7e98917e1c7188f983745782027748ee4ca573e", size = 175345, upload-time = "2026-04-12T21:44:16.431Z" },
]
-[[package]]
-name = "natsort"
-version = "8.4.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/e2/a9/a0c57aee75f77794adaf35322f8b6404cbd0f89ad45c87197a937764b7d0/natsort-8.4.0.tar.gz", hash = "sha256:45312c4a0e5507593da193dedd04abb1469253b601ecaf63445ad80f0a1ea581", size = 76575, upload-time = "2023-06-20T04:17:19.925Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/ef/82/7a9d0550484a62c6da82858ee9419f3dd1ccc9aa1c26a1e43da3ecd20b0d/natsort-8.4.0-py3-none-any.whl", hash = "sha256:4732914fb471f56b5cce04d7bae6f164a592c7712e1c85f9ef585e197299521c", size = 38268, upload-time = "2023-06-20T04:17:17.522Z" },
-]
-
-[[package]]
-name = "networkx"
-version = "3.6.1"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/6a/51/63fe664f3908c97be9d2e4f1158eb633317598cfa6e1fc14af5383f17512/networkx-3.6.1.tar.gz", hash = "sha256:26b7c357accc0c8cde558ad486283728b65b6a95d85ee1cd66bafab4c8168509", size = 2517025, upload-time = "2025-12-08T17:02:39.908Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl", hash = "sha256:d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762", size = 2068504, upload-time = "2025-12-08T17:02:38.159Z" },
-]
-
[[package]]
name = "nodeenv"
version = "1.10.0"
@@ -232,8 +214,6 @@ dependencies = [
{ name = "loguru", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "lxml", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "msgspec", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
- { name = "natsort", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
- { name = "networkx", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "pyside6", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "qasync", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "toposort", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -259,9 +239,7 @@ requires-dist = [
{ name = "loguru", specifier = ">=0.7.3" },
{ name = "lxml", specifier = ">=6.1.1" },
{ name = "msgspec", specifier = ">=0.21.1" },
- { name = "natsort", specifier = ">=8.4.0" },
- { name = "networkx", specifier = ">=3.6.1" },
- { name = "pyside6", specifier = ">=6.11.1,<6.12" },
+ { name = "pyside6", specifier = ">=6.11.1" },
{ name = "qasync", specifier = ">=0.28.0" },
{ name = "toposort", specifier = ">=1.10" },
{ name = "ttimer", specifier = ">=0.0.2" },