From ebe3ad08d0c5ddcff9b7d0ed5aaf3b12e04923ac Mon Sep 17 00:00:00 2001 From: filoxenace Date: Wed, 18 Feb 2026 11:09:04 -0500 Subject: [PATCH] test: add pytest suite, fixtures, and CI workflow --- .github/workflows/test.yml | 16 ++++ README.md | 59 +++++++++++++ pyproject.toml | 9 ++ src/rbx_upload/client.py | 2 +- tests/fixtures/shirt.xml | 13 +++ tests/test_client.py | 66 +++++++++++++++ uv.lock | 166 +++++++++++++++++++++++++++++++++++++ 7 files changed, 330 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/test.yml create mode 100644 README.md create mode 100644 tests/fixtures/shirt.xml create mode 100644 tests/test_client.py create mode 100644 uv.lock diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..c1a0424 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,16 @@ +name: Tests + +on: + push: + branches: [master] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v5 + - run: uv run --group dev pytest -v + env: + ROBLOSECURITY: ${{ secrets.ROBLOSECURITY }} diff --git a/README.md b/README.md new file mode 100644 index 0000000..6d34cb6 --- /dev/null +++ b/README.md @@ -0,0 +1,59 @@ +# rbx-upload + +Async Python client for (re)uploading and managing Roblox assets. + +## Install + +```bash +uv add rbx-upload +``` + +## Usage + +```python +from rbx_upload import RobloxClient, RbxAssetType + +async with RobloxClient( + roblosecurity="...", + publisher_user_id=12345, +) as client: + + # Fetch asset info + asset = await client.asset_from_id(127203169647575) + + # Fetch raw PNG bytes for a clothing asset + image = await client.fetch_clothing_image(asset) + + # Upload a clothing image + result = await client.upload_clothing_image( + image=image, + name="My Shirt", + description="", + asset_type=RbxAssetType.SHIRT, + group_id=67890, + ) + + # Put an asset on sale + await client.onsale_asset( + asset_id=result["asset_id"], + name="My Shirt", + description="", + group_id=67890, + price=5, + ) +``` + +## API + +### `RobloxClient(roblosecurity, publisher_user_id, proxy=None)` + +All methods are async + +| Method | Description | +|---|---| +| `asset_from_id(asset_id)` | Fetch asset info. Returns `ClothingAsset` for shirts/pants, `RbxAsset` otherwise. | +| `fetch_clothing_image(asset)` | Fetch raw PNG bytes for a clothing asset. | +| `upload_clothing_image(image, name, description, asset_type, group_id)` | Upload a clothing image. Returns `{"asset_id": int}`. | +| `onsale_asset(asset_id, name, description, group_id, price=5)` | Put an asset on sale. | + +`proxy` replaces `roblox.com` in all URLs diff --git a/pyproject.toml b/pyproject.toml index ccb59d5..c1c180a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,3 +14,12 @@ build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] packages = ["src/rbx_upload"] + +[dependency-groups] +dev = [ + "pytest>=8.0", + "pytest-asyncio>=0.24", +] + +[tool.pytest.ini_options] +asyncio_mode = "auto" diff --git a/src/rbx_upload/client.py b/src/rbx_upload/client.py index cc9d662..2190453 100644 --- a/src/rbx_upload/client.py +++ b/src/rbx_upload/client.py @@ -199,7 +199,7 @@ class RobloxClient: group_id: int, price: int = 5, ) -> dict: - """Put an asset on sale as a collectible.""" + """Put an asset on sale.""" csrf = await self._get_csrf_token() data = { "saleLocationConfiguration": {"saleLocationType": 1, "places": []}, diff --git a/tests/fixtures/shirt.xml b/tests/fixtures/shirt.xml new file mode 100644 index 0000000..18822c6 --- /dev/null +++ b/tests/fixtures/shirt.xml @@ -0,0 +1,13 @@ + + null + nil + + + + http://www.roblox.com/asset/?id=80789317092375 + + Shirt + true + + + \ No newline at end of file diff --git a/tests/test_client.py b/tests/test_client.py new file mode 100644 index 0000000..e23849e --- /dev/null +++ b/tests/test_client.py @@ -0,0 +1,66 @@ +import os +import xml.etree.ElementTree as ET +from pathlib import Path + +import pytest + +from rbx_upload import RbxAssetType, RobloxClient +from rbx_upload.models import ClothingAsset, RbxAsset + +SHIRT_ASSET_ID = 127203169647575 +SHIRT_TEMPLATE_ID = 80789317092375 +FIXTURES = Path(__file__).parent / "fixtures" + + +@pytest.fixture +def client(): + roblosecurity = os.environ["ROBLOSECURITY"] + return RobloxClient(roblosecurity=roblosecurity, publisher_user_id=0) + + +# --------------------------------------------------------------------------- +# _get_shirt_template_id_from_xml (pure, no HTTP) +# --------------------------------------------------------------------------- + + +def test_get_shirt_template_id_from_xml(): + root = ET.parse(FIXTURES / "shirt.xml").getroot() + assert RobloxClient._get_shirt_template_id_from_xml(root) == SHIRT_TEMPLATE_ID + + +def test_get_shirt_template_id_missing_url_tag(): + root = ET.fromstring("data") + with pytest.raises(ValueError, match=" tag"): + RobloxClient._get_shirt_template_id_from_xml(root) + + +def test_get_shirt_template_id_empty_url_tag(): + root = ET.fromstring("") + with pytest.raises(ValueError, match="did not contain any text"): + RobloxClient._get_shirt_template_id_from_xml(root) + + +# --------------------------------------------------------------------------- +# asset_from_id (live HTTP) +# --------------------------------------------------------------------------- + + +async def test_asset_from_id_returns_clothing_asset(client): + async with client: + asset = await client.asset_from_id(SHIRT_ASSET_ID) + assert isinstance(asset, ClothingAsset) + assert asset.asset_id == SHIRT_ASSET_ID + assert asset.asset_type == RbxAssetType.SHIRT + + +# --------------------------------------------------------------------------- +# fetch_clothing_image (live HTTP) +# --------------------------------------------------------------------------- + + +async def test_fetch_clothing_image_returns_png(client): + async with client: + shirt = await client.asset_from_id(SHIRT_ASSET_ID) + image = await client.fetch_clothing_image(shirt) + assert isinstance(image, bytes) + assert image[:4] == b"\x89PNG" diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..61da231 --- /dev/null +++ b/uv.lock @@ -0,0 +1,166 @@ +version = 1 +revision = 3 +requires-python = ">=3.13" + +[[package]] +name = "anyio" +version = "4.12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, +] + +[[package]] +name = "certifi" +version = "2026.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120", size = 154268, upload-time = "2026-01-04T02:42:41.825Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "packaging" +version = "26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, +] + +[[package]] +name = "rbx-upload" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "httpx" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "pytest-asyncio" }, +] + +[package.metadata] +requires-dist = [{ name = "httpx", specifier = ">=0.25.0" }] + +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = ">=8.0" }, + { name = "pytest-asyncio", specifier = ">=0.24" }, +]