feat(workshop): batched expand_collections() helper
Adds expand_collections(ids) to steam_workshop: one GetCollectionDetails POST covers a mixed batch of item and collection IDs, returning a flat deduplicated list of item IDs in input order. Foundation for the upcoming items-vs-collection autodetect in the workshop add_items handler. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
f1b0cbb5f1
commit
6a04594c19
2 changed files with 177 additions and 0 deletions
|
|
@ -140,6 +140,64 @@ def resolve_collection(collection_id: str) -> list[str]:
|
||||||
return children
|
return children
|
||||||
|
|
||||||
|
|
||||||
|
def expand_collections(ids: list[str]) -> list[str]:
|
||||||
|
"""Resolve a mix of item and collection IDs into a flat list of item IDs.
|
||||||
|
|
||||||
|
Performs one batched POST to GetCollectionDetails. For each input ID:
|
||||||
|
- If Steam returns result==1 with a children array, the ID is a
|
||||||
|
collection — replace it with its non-nested child item IDs in order.
|
||||||
|
- If Steam returns result==9 (k_EResultFileNotFound), the ID is not a
|
||||||
|
collection — pass it through unchanged.
|
||||||
|
|
||||||
|
Result preserves input order; collection children are inserted at the
|
||||||
|
position the collection ID held. Duplicates (across pass-throughs and
|
||||||
|
expanded children) are removed, keeping first occurrence.
|
||||||
|
"""
|
||||||
|
if not ids:
|
||||||
|
return []
|
||||||
|
for sid in ids:
|
||||||
|
if not _NUMERIC_ID_RE.fullmatch(sid):
|
||||||
|
raise ValueError(f"steam id must be digits only: {sid!r}")
|
||||||
|
|
||||||
|
payload: dict[str, str | int] = {"collectioncount": len(ids)}
|
||||||
|
for index, sid in enumerate(ids):
|
||||||
|
payload[f"publishedfileids[{index}]"] = sid
|
||||||
|
|
||||||
|
response = _session().post(
|
||||||
|
GET_COLLECTION_DETAILS_URL,
|
||||||
|
data=payload,
|
||||||
|
timeout=REQUEST_TIMEOUT_SECONDS,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
body = response.json()
|
||||||
|
|
||||||
|
by_id: dict[str, dict] = {
|
||||||
|
str(entry.get("publishedfileid", "")): entry
|
||||||
|
for entry in body.get("response", {}).get("collectiondetails", [])
|
||||||
|
}
|
||||||
|
|
||||||
|
expanded: list[str] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
|
||||||
|
def _add(sid: str) -> None:
|
||||||
|
if sid and sid not in seen:
|
||||||
|
seen.add(sid)
|
||||||
|
expanded.append(sid)
|
||||||
|
|
||||||
|
for sid in ids:
|
||||||
|
entry = by_id.get(sid)
|
||||||
|
if entry and entry.get("result") == 1 and "children" in entry:
|
||||||
|
for child in entry["children"]:
|
||||||
|
if child.get("filetype", 0) != 0:
|
||||||
|
continue
|
||||||
|
child_id = child.get("publishedfileid")
|
||||||
|
if child_id is not None:
|
||||||
|
_add(str(child_id))
|
||||||
|
else:
|
||||||
|
_add(sid)
|
||||||
|
return expanded
|
||||||
|
|
||||||
|
|
||||||
def fetch_metadata_batch(
|
def fetch_metadata_batch(
|
||||||
steam_ids: list[str], *, mode: Literal["add", "refresh"]
|
steam_ids: list[str], *, mode: Literal["add", "refresh"]
|
||||||
) -> list[WorkshopMetadata]:
|
) -> list[WorkshopMetadata]:
|
||||||
|
|
|
||||||
|
|
@ -310,3 +310,122 @@ def test_refresh_all_uses_thread_pool_and_collects_errors(tmp_path: Path) -> Non
|
||||||
assert report.downloaded == 2
|
assert report.downloaded == 2
|
||||||
assert report.errors == 1
|
assert report.errors == 1
|
||||||
assert "2" in report.per_item_errors
|
assert "2" in report.per_item_errors
|
||||||
|
|
||||||
|
|
||||||
|
def test_expand_collections_passes_through_items() -> None:
|
||||||
|
fake_response = MagicMock(status_code=200)
|
||||||
|
fake_response.raise_for_status = MagicMock()
|
||||||
|
fake_response.json.return_value = {
|
||||||
|
"response": {
|
||||||
|
"collectiondetails": [
|
||||||
|
{"publishedfileid": "1001", "result": 9},
|
||||||
|
{"publishedfileid": "1002", "result": 9},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
with patch.object(steam_workshop, "_session", return_value=MagicMock(post=MagicMock(return_value=fake_response))):
|
||||||
|
result = steam_workshop.expand_collections(["1001", "1002"])
|
||||||
|
assert result == ["1001", "1002"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_expand_collections_replaces_collection_with_children() -> None:
|
||||||
|
fake_response = MagicMock(status_code=200)
|
||||||
|
fake_response.raise_for_status = MagicMock()
|
||||||
|
fake_response.json.return_value = {
|
||||||
|
"response": {
|
||||||
|
"collectiondetails": [
|
||||||
|
{
|
||||||
|
"publishedfileid": "555",
|
||||||
|
"result": 1,
|
||||||
|
"children": [
|
||||||
|
{"publishedfileid": "1001", "filetype": 0},
|
||||||
|
{"publishedfileid": "1002", "filetype": 0},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
with patch.object(steam_workshop, "_session", return_value=MagicMock(post=MagicMock(return_value=fake_response))):
|
||||||
|
result = steam_workshop.expand_collections(["555"])
|
||||||
|
assert result == ["1001", "1002"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_expand_collections_mixed_items_and_collections() -> None:
|
||||||
|
fake_response = MagicMock(status_code=200)
|
||||||
|
fake_response.raise_for_status = MagicMock()
|
||||||
|
fake_response.json.return_value = {
|
||||||
|
"response": {
|
||||||
|
"collectiondetails": [
|
||||||
|
{"publishedfileid": "1001", "result": 9},
|
||||||
|
{
|
||||||
|
"publishedfileid": "555",
|
||||||
|
"result": 1,
|
||||||
|
"children": [
|
||||||
|
{"publishedfileid": "2001", "filetype": 0},
|
||||||
|
{"publishedfileid": "2002", "filetype": 0},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{"publishedfileid": "1003", "result": 9},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
with patch.object(steam_workshop, "_session", return_value=MagicMock(post=MagicMock(return_value=fake_response))):
|
||||||
|
result = steam_workshop.expand_collections(["1001", "555", "1003"])
|
||||||
|
# Input order preserved; collection's children inserted where the collection was.
|
||||||
|
assert result == ["1001", "2001", "2002", "1003"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_expand_collections_skips_nested_collections() -> None:
|
||||||
|
fake_response = MagicMock(status_code=200)
|
||||||
|
fake_response.raise_for_status = MagicMock()
|
||||||
|
fake_response.json.return_value = {
|
||||||
|
"response": {
|
||||||
|
"collectiondetails": [
|
||||||
|
{
|
||||||
|
"publishedfileid": "555",
|
||||||
|
"result": 1,
|
||||||
|
"children": [
|
||||||
|
{"publishedfileid": "1001", "filetype": 0},
|
||||||
|
{"publishedfileid": "9999", "filetype": 1},
|
||||||
|
{"publishedfileid": "1002", "filetype": 0},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
with patch.object(steam_workshop, "_session", return_value=MagicMock(post=MagicMock(return_value=fake_response))):
|
||||||
|
result = steam_workshop.expand_collections(["555"])
|
||||||
|
assert result == ["1001", "1002"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_expand_collections_deduplicates() -> None:
|
||||||
|
fake_response = MagicMock(status_code=200)
|
||||||
|
fake_response.raise_for_status = MagicMock()
|
||||||
|
fake_response.json.return_value = {
|
||||||
|
"response": {
|
||||||
|
"collectiondetails": [
|
||||||
|
{"publishedfileid": "1001", "result": 9},
|
||||||
|
{
|
||||||
|
"publishedfileid": "555",
|
||||||
|
"result": 1,
|
||||||
|
"children": [
|
||||||
|
{"publishedfileid": "1001", "filetype": 0},
|
||||||
|
{"publishedfileid": "1002", "filetype": 0},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
with patch.object(steam_workshop, "_session", return_value=MagicMock(post=MagicMock(return_value=fake_response))):
|
||||||
|
result = steam_workshop.expand_collections(["1001", "555"])
|
||||||
|
assert result == ["1001", "1002"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_expand_collections_empty_input_returns_empty() -> None:
|
||||||
|
result = steam_workshop.expand_collections([])
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_expand_collections_rejects_non_numeric_ids() -> None:
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
steam_workshop.expand_collections(["1001", "not-a-number"])
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue