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
|
||||
|
||||
|
||||
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(
|
||||
steam_ids: list[str], *, mode: Literal["add", "refresh"]
|
||||
) -> 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.errors == 1
|
||||
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