├── src ├── lumaai │ ├── py.typed │ ├── _version.py │ ├── lib │ │ └── .keep │ ├── types │ │ ├── generations │ │ │ ├── concept_list_response.py │ │ │ ├── __init__.py │ │ │ ├── video_modify_params.py │ │ │ ├── image_reframe_params.py │ │ │ ├── video_reframe_params.py │ │ │ ├── image_create_params.py │ │ │ └── video_create_params.py │ │ ├── credit_get_response.py │ │ ├── ping_check_response.py │ │ ├── generation_list_params.py │ │ ├── generation_audio_params.py │ │ ├── generation_upscale_params.py │ │ ├── __init__.py │ │ ├── generation_list_response.py │ │ └── generation_create_params.py │ ├── _utils │ │ ├── _streams.py │ │ ├── _resources_proxy.py │ │ ├── _logs.py │ │ ├── _compat.py │ │ ├── _reflection.py │ │ ├── _proxy.py │ │ ├── __init__.py │ │ ├── _sync.py │ │ ├── _datetime_parse.py │ │ └── _typing.py │ ├── _constants.py │ ├── _resource.py │ ├── resources │ │ ├── __init__.py │ │ ├── generations │ │ │ ├── __init__.py │ │ │ └── concepts.py │ │ ├── ping.py │ │ └── credits.py │ ├── __init__.py │ ├── _exceptions.py │ ├── _files.py │ ├── _qs.py │ ├── _compat.py │ ├── _types.py │ └── _streaming.py └── luma_ai │ └── lib │ └── .keep ├── .python-version ├── Brewfile ├── tests ├── sample_file.txt ├── __init__.py ├── api_resources │ ├── __init__.py │ ├── generations │ │ ├── __init__.py │ │ ├── test_concepts.py │ │ └── test_image.py │ ├── test_ping.py │ └── test_credits.py ├── test_utils │ ├── test_proxy.py │ ├── test_typing.py │ └── test_datetime_parse.py ├── test_files.py ├── test_deepcopy.py ├── test_extract_files.py ├── conftest.py ├── test_qs.py ├── test_required_args.py ├── utils.py ├── test_streaming.py └── test_response.py ├── .release-please-manifest.json ├── .vscode └── settings.json ├── bin ├── publish-pypi └── check-release-environment ├── scripts ├── format ├── lint ├── bootstrap ├── utils │ ├── upload-artifact.sh │ └── ruffen-docs.py ├── mock └── test ├── .gitignore ├── examples └── .keep ├── .stats.yml ├── noxfile.py ├── .devcontainer ├── Dockerfile └── devcontainer.json ├── .github └── workflows │ ├── release-doctor.yml │ ├── publish-pypi.yml │ └── ci.yml ├── SECURITY.md ├── release-please-config.json ├── requirements.lock ├── requirements-dev.lock ├── api.md ├── CONTRIBUTING.md └── pyproject.toml /src/lumaai/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- 1 | 3.9.18 2 | -------------------------------------------------------------------------------- /Brewfile: -------------------------------------------------------------------------------- 1 | brew "rye" 2 | 3 | -------------------------------------------------------------------------------- /tests/sample_file.txt: -------------------------------------------------------------------------------- 1 | Hello, world! 2 | -------------------------------------------------------------------------------- /.release-please-manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | ".": "1.18.1" 3 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.analysis.importFormat": "relative", 3 | } 4 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. 2 | -------------------------------------------------------------------------------- /tests/api_resources/__init__.py: -------------------------------------------------------------------------------- 1 | # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. 2 | -------------------------------------------------------------------------------- /tests/api_resources/generations/__init__.py: -------------------------------------------------------------------------------- 1 | # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. 2 | -------------------------------------------------------------------------------- /bin/publish-pypi: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -eux 4 | mkdir -p dist 5 | rye build --clean 6 | rye publish --yes --token=$PYPI_TOKEN 7 | -------------------------------------------------------------------------------- /scripts/format: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | cd "$(dirname "$0")/.." 6 | 7 | echo "==> Running formatters" 8 | rye run format 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .prism.log 2 | _dev 3 | 4 | __pycache__ 5 | .mypy_cache 6 | 7 | dist 8 | 9 | .venv 10 | .idea 11 | 12 | .env 13 | .envrc 14 | codegen.log 15 | Brewfile.lock.json 16 | -------------------------------------------------------------------------------- /src/lumaai/_version.py: -------------------------------------------------------------------------------- 1 | # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. 2 | 3 | __title__ = "lumaai" 4 | __version__ = "1.18.1" # x-release-please-version 5 | -------------------------------------------------------------------------------- /scripts/lint: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | cd "$(dirname "$0")/.." 6 | 7 | echo "==> Running lints" 8 | rye run lint 9 | 10 | echo "==> Making sure it imports" 11 | rye run python -c 'import lumaai' 12 | -------------------------------------------------------------------------------- /src/luma_ai/lib/.keep: -------------------------------------------------------------------------------- 1 | File generated from our OpenAPI spec by Stainless. 2 | 3 | This directory can be used to store custom files to expand the SDK. 4 | It is ignored by Stainless code generation and its content (other than this keep file) won't be touched. -------------------------------------------------------------------------------- /src/lumaai/lib/.keep: -------------------------------------------------------------------------------- 1 | File generated from our OpenAPI spec by Stainless. 2 | 3 | This directory can be used to store custom files to expand the SDK. 4 | It is ignored by Stainless code generation and its content (other than this keep file) won't be touched. -------------------------------------------------------------------------------- /examples/.keep: -------------------------------------------------------------------------------- 1 | File generated from our OpenAPI spec by Stainless. 2 | 3 | This directory can be used to store example files demonstrating usage of this SDK. 4 | It is ignored by Stainless code generation and its content (other than this keep file) won't be touched. -------------------------------------------------------------------------------- /src/lumaai/types/generations/concept_list_response.py: -------------------------------------------------------------------------------- 1 | # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. 2 | 3 | from typing import List 4 | from typing_extensions import TypeAlias 5 | 6 | __all__ = ["ConceptListResponse"] 7 | 8 | ConceptListResponse: TypeAlias = List[str] 9 | -------------------------------------------------------------------------------- /.stats.yml: -------------------------------------------------------------------------------- 1 | configured_endpoints: 13 2 | openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/luma-ai-karanganesan%2Fluma_ai-965c8e97a488f5378849ba7564b44a272c141329700a6af65b24a4ea408880de.yml 3 | openapi_spec_hash: 7ba050249c3c434b4a4632c41c8de405 4 | config_hash: 8017bd140eecc6eedb1d788d7b0edb3b 5 | -------------------------------------------------------------------------------- /noxfile.py: -------------------------------------------------------------------------------- 1 | import nox 2 | 3 | 4 | @nox.session(reuse_venv=True, name="test-pydantic-v1") 5 | def test_pydantic_v1(session: nox.Session) -> None: 6 | session.install("-r", "requirements-dev.lock") 7 | session.install("pydantic<2") 8 | 9 | session.run("pytest", "--showlocals", "--ignore=tests/functional", *session.posargs) 10 | -------------------------------------------------------------------------------- /src/lumaai/types/credit_get_response.py: -------------------------------------------------------------------------------- 1 | # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. 2 | 3 | from .._models import BaseModel 4 | 5 | __all__ = ["CreditGetResponse"] 6 | 7 | 8 | class CreditGetResponse(BaseModel): 9 | credit_balance: float 10 | """Available credits balance in USD cents""" 11 | -------------------------------------------------------------------------------- /src/lumaai/types/ping_check_response.py: -------------------------------------------------------------------------------- 1 | # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. 2 | 3 | from typing import Optional 4 | 5 | from .._models import BaseModel 6 | 7 | __all__ = ["PingCheckResponse"] 8 | 9 | 10 | class PingCheckResponse(BaseModel): 11 | message: Optional[str] = None 12 | """The message""" 13 | -------------------------------------------------------------------------------- /src/lumaai/_utils/_streams.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | from typing_extensions import Iterator, AsyncIterator 3 | 4 | 5 | def consume_sync_iterator(iterator: Iterator[Any]) -> None: 6 | for _ in iterator: 7 | ... 8 | 9 | 10 | async def consume_async_iterator(iterator: AsyncIterator[Any]) -> None: 11 | async for _ in iterator: 12 | ... 13 | -------------------------------------------------------------------------------- /src/lumaai/types/generation_list_params.py: -------------------------------------------------------------------------------- 1 | # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. 2 | 3 | from __future__ import annotations 4 | 5 | from typing_extensions import TypedDict 6 | 7 | __all__ = ["GenerationListParams"] 8 | 9 | 10 | class GenerationListParams(TypedDict, total=False): 11 | limit: int 12 | 13 | offset: int 14 | -------------------------------------------------------------------------------- /.devcontainer/Dockerfile: -------------------------------------------------------------------------------- 1 | ARG VARIANT="3.9" 2 | FROM mcr.microsoft.com/vscode/devcontainers/python:0-${VARIANT} 3 | 4 | USER vscode 5 | 6 | RUN curl -sSf https://rye.astral.sh/get | RYE_VERSION="0.44.0" RYE_INSTALL_OPTION="--yes" bash 7 | ENV PATH=/home/vscode/.rye/shims:$PATH 8 | 9 | RUN echo "[[ -d .venv ]] && source .venv/bin/activate || export PATH=\$PATH" >> /home/vscode/.bashrc 10 | -------------------------------------------------------------------------------- /src/lumaai/_constants.py: -------------------------------------------------------------------------------- 1 | # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. 2 | 3 | import httpx 4 | 5 | RAW_RESPONSE_HEADER = "X-Stainless-Raw-Response" 6 | OVERRIDE_CAST_TO_HEADER = "____stainless_override_cast_to" 7 | 8 | # default timeout is 1 minute 9 | DEFAULT_TIMEOUT = httpx.Timeout(timeout=60, connect=5.0) 10 | DEFAULT_MAX_RETRIES = 2 11 | DEFAULT_CONNECTION_LIMITS = httpx.Limits(max_connections=100, max_keepalive_connections=20) 12 | 13 | INITIAL_RETRY_DELAY = 0.5 14 | MAX_RETRY_DELAY = 8.0 15 | -------------------------------------------------------------------------------- /bin/check-release-environment: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | errors=() 4 | 5 | if [ -z "${PYPI_TOKEN}" ]; then 6 | errors+=("The PYPI_TOKEN secret has not been set. Please set it in either this repository's secrets or your organization secrets.") 7 | fi 8 | 9 | lenErrors=${#errors[@]} 10 | 11 | if [[ lenErrors -gt 0 ]]; then 12 | echo -e "Found the following errors in the release environment:\n" 13 | 14 | for error in "${errors[@]}"; do 15 | echo -e "- $error\n" 16 | done 17 | 18 | exit 1 19 | fi 20 | 21 | echo "The environment is ready to push releases!" 22 | -------------------------------------------------------------------------------- /src/lumaai/types/generation_audio_params.py: -------------------------------------------------------------------------------- 1 | # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. 2 | 3 | from __future__ import annotations 4 | 5 | from typing_extensions import Literal, TypedDict 6 | 7 | __all__ = ["GenerationAudioParams"] 8 | 9 | 10 | class GenerationAudioParams(TypedDict, total=False): 11 | callback_url: str 12 | """The callback URL for the audio""" 13 | 14 | generation_type: Literal["add_audio"] 15 | 16 | negative_prompt: str 17 | """The negative prompt of the audio""" 18 | 19 | prompt: str 20 | """The prompt of the audio""" 21 | -------------------------------------------------------------------------------- /src/lumaai/types/generation_upscale_params.py: -------------------------------------------------------------------------------- 1 | # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. 2 | 3 | from __future__ import annotations 4 | 5 | from typing import Union 6 | from typing_extensions import Literal, TypedDict 7 | 8 | __all__ = ["GenerationUpscaleParams"] 9 | 10 | 11 | class GenerationUpscaleParams(TypedDict, total=False): 12 | callback_url: str 13 | """The callback URL for the upscale""" 14 | 15 | generation_type: Literal["upscale_video"] 16 | 17 | resolution: Union[Literal["540p", "720p", "1080p", "4k"], str] 18 | """The resolution of the upscale""" 19 | -------------------------------------------------------------------------------- /src/lumaai/types/generations/__init__.py: -------------------------------------------------------------------------------- 1 | # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. 2 | 3 | from __future__ import annotations 4 | 5 | from .image_create_params import ImageCreateParams as ImageCreateParams 6 | from .video_create_params import VideoCreateParams as VideoCreateParams 7 | from .video_modify_params import VideoModifyParams as VideoModifyParams 8 | from .image_reframe_params import ImageReframeParams as ImageReframeParams 9 | from .video_reframe_params import VideoReframeParams as VideoReframeParams 10 | from .concept_list_response import ConceptListResponse as ConceptListResponse 11 | -------------------------------------------------------------------------------- /scripts/bootstrap: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | cd "$(dirname "$0")/.." 6 | 7 | if [ -f "Brewfile" ] && [ "$(uname -s)" = "Darwin" ] && [ "$SKIP_BREW" != "1" ] && [ -t 0 ]; then 8 | brew bundle check >/dev/null 2>&1 || { 9 | echo -n "==> Install Homebrew dependencies? (y/N): " 10 | read -r response 11 | case "$response" in 12 | [yY][eE][sS]|[yY]) 13 | brew bundle 14 | ;; 15 | *) 16 | ;; 17 | esac 18 | echo 19 | } 20 | fi 21 | 22 | echo "==> Installing Python dependencies…" 23 | 24 | # experimental uv support makes installations significantly faster 25 | rye config --set-bool behavior.use-uv=true 26 | 27 | rye sync --all-features 28 | -------------------------------------------------------------------------------- /src/lumaai/_utils/_resources_proxy.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Any 4 | from typing_extensions import override 5 | 6 | from ._proxy import LazyProxy 7 | 8 | 9 | class ResourcesProxy(LazyProxy[Any]): 10 | """A proxy for the `lumaai.resources` module. 11 | 12 | This is used so that we can lazily import `lumaai.resources` only when 13 | needed *and* so that users can just import `lumaai` and reference `lumaai.resources` 14 | """ 15 | 16 | @override 17 | def __load__(self) -> Any: 18 | import importlib 19 | 20 | mod = importlib.import_module("lumaai.resources") 21 | return mod 22 | 23 | 24 | resources = ResourcesProxy().__as_proxied__() 25 | -------------------------------------------------------------------------------- /.github/workflows/release-doctor.yml: -------------------------------------------------------------------------------- 1 | name: Release Doctor 2 | on: 3 | pull_request: 4 | branches: 5 | - main 6 | workflow_dispatch: 7 | 8 | jobs: 9 | release_doctor: 10 | name: release doctor 11 | runs-on: ubuntu-latest 12 | if: github.repository == 'lumalabs/lumaai-python' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch' || startsWith(github.head_ref, 'release-please') || github.head_ref == 'next') 13 | 14 | steps: 15 | - uses: actions/checkout@v4 16 | 17 | - name: Check release environment 18 | run: | 19 | bash ./bin/check-release-environment 20 | env: 21 | PYPI_TOKEN: ${{ secrets.LUMAAI_PYPI_TOKEN || secrets.PYPI_TOKEN }} 22 | -------------------------------------------------------------------------------- /src/lumaai/types/__init__.py: -------------------------------------------------------------------------------- 1 | # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. 2 | 3 | from __future__ import annotations 4 | 5 | from .generation import Generation as Generation 6 | from .credit_get_response import CreditGetResponse as CreditGetResponse 7 | from .ping_check_response import PingCheckResponse as PingCheckResponse 8 | from .generation_list_params import GenerationListParams as GenerationListParams 9 | from .generation_audio_params import GenerationAudioParams as GenerationAudioParams 10 | from .generation_create_params import GenerationCreateParams as GenerationCreateParams 11 | from .generation_list_response import GenerationListResponse as GenerationListResponse 12 | from .generation_upscale_params import GenerationUpscaleParams as GenerationUpscaleParams 13 | -------------------------------------------------------------------------------- /src/lumaai/types/generation_list_response.py: -------------------------------------------------------------------------------- 1 | # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. 2 | 3 | from typing import List, Optional 4 | 5 | from .._models import BaseModel 6 | from .generation import Generation 7 | 8 | __all__ = ["GenerationListResponse"] 9 | 10 | 11 | class GenerationListResponse(BaseModel): 12 | generations: List[Generation] 13 | """The generations requested""" 14 | 15 | count: Optional[int] = None 16 | """The number of generations returned""" 17 | 18 | has_more: Optional[bool] = None 19 | """Whether there are more generations""" 20 | 21 | limit: Optional[int] = None 22 | """The limit of the generations requested""" 23 | 24 | offset: Optional[int] = None 25 | """The offset of the generations requested""" 26 | -------------------------------------------------------------------------------- /src/lumaai/_utils/_logs.py: -------------------------------------------------------------------------------- 1 | import os 2 | import logging 3 | 4 | logger: logging.Logger = logging.getLogger("lumaai") 5 | httpx_logger: logging.Logger = logging.getLogger("httpx") 6 | 7 | 8 | def _basic_config() -> None: 9 | # e.g. [2023-10-05 14:12:26 - lumaai._base_client:818 - DEBUG] HTTP Request: POST http://127.0.0.1:4010/foo/bar "200 OK" 10 | logging.basicConfig( 11 | format="[%(asctime)s - %(name)s:%(lineno)d - %(levelname)s] %(message)s", 12 | datefmt="%Y-%m-%d %H:%M:%S", 13 | ) 14 | 15 | 16 | def setup_logging() -> None: 17 | env = os.environ.get("LUMAAI_LOG") 18 | if env == "debug": 19 | _basic_config() 20 | logger.setLevel(logging.DEBUG) 21 | httpx_logger.setLevel(logging.DEBUG) 22 | elif env == "info": 23 | _basic_config() 24 | logger.setLevel(logging.INFO) 25 | httpx_logger.setLevel(logging.INFO) 26 | -------------------------------------------------------------------------------- /scripts/utils/upload-artifact.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -exuo pipefail 3 | 4 | FILENAME=$(basename dist/*.whl) 5 | 6 | RESPONSE=$(curl -X POST "$URL?filename=$FILENAME" \ 7 | -H "Authorization: Bearer $AUTH" \ 8 | -H "Content-Type: application/json") 9 | 10 | SIGNED_URL=$(echo "$RESPONSE" | jq -r '.url') 11 | 12 | if [[ "$SIGNED_URL" == "null" ]]; then 13 | echo -e "\033[31mFailed to get signed URL.\033[0m" 14 | exit 1 15 | fi 16 | 17 | UPLOAD_RESPONSE=$(curl -v -X PUT \ 18 | -H "Content-Type: binary/octet-stream" \ 19 | --data-binary "@dist/$FILENAME" "$SIGNED_URL" 2>&1) 20 | 21 | if echo "$UPLOAD_RESPONSE" | grep -q "HTTP/[0-9.]* 200"; then 22 | echo -e "\033[32mUploaded build to Stainless storage.\033[0m" 23 | echo -e "\033[32mInstallation: pip install 'https://pkg.stainless.com/s/luma_ai-python/$SHA/$FILENAME'\033[0m" 24 | else 25 | echo -e "\033[31mFailed to upload artifact.\033[0m" 26 | exit 1 27 | fi 28 | -------------------------------------------------------------------------------- /.github/workflows/publish-pypi.yml: -------------------------------------------------------------------------------- 1 | # This workflow is triggered when a GitHub release is created. 2 | # It can also be run manually to re-publish to PyPI in case it failed for some reason. 3 | # You can run this workflow by navigating to https://www.github.com/lumalabs/lumaai-python/actions/workflows/publish-pypi.yml 4 | name: Publish PyPI 5 | on: 6 | workflow_dispatch: 7 | 8 | release: 9 | types: [published] 10 | 11 | jobs: 12 | publish: 13 | name: publish 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - uses: actions/checkout@v4 18 | 19 | - name: Install Rye 20 | run: | 21 | curl -sSf https://rye.astral.sh/get | bash 22 | echo "$HOME/.rye/shims" >> $GITHUB_PATH 23 | env: 24 | RYE_VERSION: '0.44.0' 25 | RYE_INSTALL_OPTION: '--yes' 26 | 27 | - name: Publish to PyPI 28 | run: | 29 | bash ./bin/publish-pypi 30 | env: 31 | PYPI_TOKEN: ${{ secrets.LUMAAI_PYPI_TOKEN || secrets.PYPI_TOKEN }} 32 | -------------------------------------------------------------------------------- /scripts/mock: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | cd "$(dirname "$0")/.." 6 | 7 | if [[ -n "$1" && "$1" != '--'* ]]; then 8 | URL="$1" 9 | shift 10 | else 11 | URL="$(grep 'openapi_spec_url' .stats.yml | cut -d' ' -f2)" 12 | fi 13 | 14 | # Check if the URL is empty 15 | if [ -z "$URL" ]; then 16 | echo "Error: No OpenAPI spec path/url provided or found in .stats.yml" 17 | exit 1 18 | fi 19 | 20 | echo "==> Starting mock server with URL ${URL}" 21 | 22 | # Run prism mock on the given spec 23 | if [ "$1" == "--daemon" ]; then 24 | npm exec --package=@stainless-api/prism-cli@5.15.0 -- prism mock "$URL" &> .prism.log & 25 | 26 | # Wait for server to come online 27 | echo -n "Waiting for server" 28 | while ! grep -q "✖ fatal\|Prism is listening" ".prism.log" ; do 29 | echo -n "." 30 | sleep 0.1 31 | done 32 | 33 | if grep -q "✖ fatal" ".prism.log"; then 34 | cat .prism.log 35 | exit 1 36 | fi 37 | 38 | echo 39 | else 40 | npm exec --package=@stainless-api/prism-cli@5.15.0 -- prism mock "$URL" 41 | fi 42 | -------------------------------------------------------------------------------- /tests/test_utils/test_proxy.py: -------------------------------------------------------------------------------- 1 | import operator 2 | from typing import Any 3 | from typing_extensions import override 4 | 5 | from lumaai._utils import LazyProxy 6 | 7 | 8 | class RecursiveLazyProxy(LazyProxy[Any]): 9 | @override 10 | def __load__(self) -> Any: 11 | return self 12 | 13 | def __call__(self, *_args: Any, **_kwds: Any) -> Any: 14 | raise RuntimeError("This should never be called!") 15 | 16 | 17 | def test_recursive_proxy() -> None: 18 | proxy = RecursiveLazyProxy() 19 | assert repr(proxy) == "RecursiveLazyProxy" 20 | assert str(proxy) == "RecursiveLazyProxy" 21 | assert dir(proxy) == [] 22 | assert type(proxy).__name__ == "RecursiveLazyProxy" 23 | assert type(operator.attrgetter("name.foo.bar.baz")(proxy)).__name__ == "RecursiveLazyProxy" 24 | 25 | 26 | def test_isinstance_does_not_error() -> None: 27 | class AlwaysErrorProxy(LazyProxy[Any]): 28 | @override 29 | def __load__(self) -> Any: 30 | raise RuntimeError("Mocking missing dependency") 31 | 32 | proxy = AlwaysErrorProxy() 33 | assert not isinstance(proxy, dict) 34 | assert isinstance(proxy, LazyProxy) 35 | -------------------------------------------------------------------------------- /src/lumaai/_resource.py: -------------------------------------------------------------------------------- 1 | # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. 2 | 3 | from __future__ import annotations 4 | 5 | import time 6 | from typing import TYPE_CHECKING 7 | 8 | import anyio 9 | 10 | if TYPE_CHECKING: 11 | from ._client import LumaAI, AsyncLumaAI 12 | 13 | 14 | class SyncAPIResource: 15 | _client: LumaAI 16 | 17 | def __init__(self, client: LumaAI) -> None: 18 | self._client = client 19 | self._get = client.get 20 | self._post = client.post 21 | self._patch = client.patch 22 | self._put = client.put 23 | self._delete = client.delete 24 | self._get_api_list = client.get_api_list 25 | 26 | def _sleep(self, seconds: float) -> None: 27 | time.sleep(seconds) 28 | 29 | 30 | class AsyncAPIResource: 31 | _client: AsyncLumaAI 32 | 33 | def __init__(self, client: AsyncLumaAI) -> None: 34 | self._client = client 35 | self._get = client.get 36 | self._post = client.post 37 | self._patch = client.patch 38 | self._put = client.put 39 | self._delete = client.delete 40 | self._get_api_list = client.get_api_list 41 | 42 | async def _sleep(self, seconds: float) -> None: 43 | await anyio.sleep(seconds) 44 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Reporting Security Issues 4 | 5 | This SDK is generated by [Stainless Software Inc](http://stainless.com). Stainless takes security seriously, and encourages you to report any security vulnerability promptly so that appropriate action can be taken. 6 | 7 | To report a security issue, please contact the Stainless team at security@stainless.com. 8 | 9 | ## Responsible Disclosure 10 | 11 | We appreciate the efforts of security researchers and individuals who help us maintain the security of 12 | SDKs we generate. If you believe you have found a security vulnerability, please adhere to responsible 13 | disclosure practices by allowing us a reasonable amount of time to investigate and address the issue 14 | before making any information public. 15 | 16 | ## Reporting Non-SDK Related Security Issues 17 | 18 | If you encounter security issues that are not directly related to SDKs but pertain to the services 19 | or products provided by LumaAI, please follow the respective company's security reporting guidelines. 20 | 21 | ### LumaAI Terms and Policies 22 | 23 | Please contact support+api@lumalabs.ai for any questions or concerns regarding the security of our services. 24 | 25 | --- 26 | 27 | Thank you for helping us keep the SDKs and systems they interact with secure. 28 | -------------------------------------------------------------------------------- /src/lumaai/_utils/_compat.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import sys 4 | import typing_extensions 5 | from typing import Any, Type, Union, Literal, Optional 6 | from datetime import date, datetime 7 | from typing_extensions import get_args as _get_args, get_origin as _get_origin 8 | 9 | from .._types import StrBytesIntFloat 10 | from ._datetime_parse import parse_date as _parse_date, parse_datetime as _parse_datetime 11 | 12 | _LITERAL_TYPES = {Literal, typing_extensions.Literal} 13 | 14 | 15 | def get_args(tp: type[Any]) -> tuple[Any, ...]: 16 | return _get_args(tp) 17 | 18 | 19 | def get_origin(tp: type[Any]) -> type[Any] | None: 20 | return _get_origin(tp) 21 | 22 | 23 | def is_union(tp: Optional[Type[Any]]) -> bool: 24 | if sys.version_info < (3, 10): 25 | return tp is Union # type: ignore[comparison-overlap] 26 | else: 27 | import types 28 | 29 | return tp is Union or tp is types.UnionType 30 | 31 | 32 | def is_typeddict(tp: Type[Any]) -> bool: 33 | return typing_extensions.is_typeddict(tp) 34 | 35 | 36 | def is_literal_type(tp: Type[Any]) -> bool: 37 | return get_origin(tp) in _LITERAL_TYPES 38 | 39 | 40 | def parse_date(value: Union[date, StrBytesIntFloat]) -> date: 41 | return _parse_date(value) 42 | 43 | 44 | def parse_datetime(value: Union[datetime, StrBytesIntFloat]) -> datetime: 45 | return _parse_datetime(value) 46 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | // For format details, see https://aka.ms/devcontainer.json. For config options, see the 2 | // README at: https://github.com/devcontainers/templates/tree/main/src/debian 3 | { 4 | "name": "Debian", 5 | "build": { 6 | "dockerfile": "Dockerfile", 7 | "context": ".." 8 | }, 9 | 10 | "postStartCommand": "rye sync --all-features", 11 | 12 | "customizations": { 13 | "vscode": { 14 | "extensions": [ 15 | "ms-python.python" 16 | ], 17 | "settings": { 18 | "terminal.integrated.shell.linux": "/bin/bash", 19 | "python.pythonPath": ".venv/bin/python", 20 | "python.defaultInterpreterPath": ".venv/bin/python", 21 | "python.typeChecking": "basic", 22 | "terminal.integrated.env.linux": { 23 | "PATH": "/home/vscode/.rye/shims:${env:PATH}" 24 | } 25 | } 26 | } 27 | }, 28 | "features": { 29 | "ghcr.io/devcontainers/features/node:1": {} 30 | } 31 | 32 | // Features to add to the dev container. More info: https://containers.dev/features. 33 | // "features": {}, 34 | 35 | // Use 'forwardPorts' to make a list of ports inside the container available locally. 36 | // "forwardPorts": [], 37 | 38 | // Configure tool-specific properties. 39 | // "customizations": {}, 40 | 41 | // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. 42 | // "remoteUser": "root" 43 | } 44 | -------------------------------------------------------------------------------- /src/lumaai/_utils/_reflection.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import inspect 4 | from typing import Any, Callable 5 | 6 | 7 | def function_has_argument(func: Callable[..., Any], arg_name: str) -> bool: 8 | """Returns whether or not the given function has a specific parameter""" 9 | sig = inspect.signature(func) 10 | return arg_name in sig.parameters 11 | 12 | 13 | def assert_signatures_in_sync( 14 | source_func: Callable[..., Any], 15 | check_func: Callable[..., Any], 16 | *, 17 | exclude_params: set[str] = set(), 18 | ) -> None: 19 | """Ensure that the signature of the second function matches the first.""" 20 | 21 | check_sig = inspect.signature(check_func) 22 | source_sig = inspect.signature(source_func) 23 | 24 | errors: list[str] = [] 25 | 26 | for name, source_param in source_sig.parameters.items(): 27 | if name in exclude_params: 28 | continue 29 | 30 | custom_param = check_sig.parameters.get(name) 31 | if not custom_param: 32 | errors.append(f"the `{name}` param is missing") 33 | continue 34 | 35 | if custom_param.annotation != source_param.annotation: 36 | errors.append( 37 | f"types for the `{name}` param are do not match; source={repr(source_param.annotation)} checking={repr(custom_param.annotation)}" 38 | ) 39 | continue 40 | 41 | if errors: 42 | raise AssertionError(f"{len(errors)} errors encountered when comparing signatures:\n\n" + "\n\n".join(errors)) 43 | -------------------------------------------------------------------------------- /src/lumaai/types/generations/video_modify_params.py: -------------------------------------------------------------------------------- 1 | # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. 2 | 3 | from __future__ import annotations 4 | 5 | from typing_extensions import Literal, Required, TypedDict 6 | 7 | __all__ = ["VideoModifyParams", "Media", "FirstFrame"] 8 | 9 | 10 | class VideoModifyParams(TypedDict, total=False): 11 | generation_type: Required[Literal["modify_video"]] 12 | 13 | media: Required[Media] 14 | """The image entity object""" 15 | 16 | mode: Required[ 17 | Literal[ 18 | "adhere_1", 19 | "adhere_2", 20 | "adhere_3", 21 | "flex_1", 22 | "flex_2", 23 | "flex_3", 24 | "reimagine_1", 25 | "reimagine_2", 26 | "reimagine_3", 27 | ] 28 | ] 29 | """The mode of the modify video""" 30 | 31 | model: Required[Literal["ray-2", "ray-flash-2"]] 32 | """The model used for the modify video""" 33 | 34 | callback_url: str 35 | """ 36 | The callback URL of the generation, a POST request with Generation object will 37 | be sent to the callback URL when the generation is dreaming, completed, or 38 | failed 39 | """ 40 | 41 | first_frame: FirstFrame 42 | """The image entity object""" 43 | 44 | prompt: str 45 | """The prompt of the generation""" 46 | 47 | 48 | class Media(TypedDict, total=False): 49 | url: Required[str] 50 | """The URL of the media""" 51 | 52 | 53 | class FirstFrame(TypedDict, total=False): 54 | url: Required[str] 55 | """The URL of the media""" 56 | -------------------------------------------------------------------------------- /release-please-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "packages": { 3 | ".": {} 4 | }, 5 | "$schema": "https://raw.githubusercontent.com/stainless-api/release-please/main/schemas/config.json", 6 | "include-v-in-tag": true, 7 | "include-component-in-tag": false, 8 | "versioning": "prerelease", 9 | "prerelease": true, 10 | "bump-minor-pre-major": true, 11 | "bump-patch-for-minor-pre-major": false, 12 | "pull-request-header": "Automated Release PR", 13 | "pull-request-title-pattern": "release: ${version}", 14 | "changelog-sections": [ 15 | { 16 | "type": "feat", 17 | "section": "Features" 18 | }, 19 | { 20 | "type": "fix", 21 | "section": "Bug Fixes" 22 | }, 23 | { 24 | "type": "perf", 25 | "section": "Performance Improvements" 26 | }, 27 | { 28 | "type": "revert", 29 | "section": "Reverts" 30 | }, 31 | { 32 | "type": "chore", 33 | "section": "Chores" 34 | }, 35 | { 36 | "type": "docs", 37 | "section": "Documentation" 38 | }, 39 | { 40 | "type": "style", 41 | "section": "Styles" 42 | }, 43 | { 44 | "type": "refactor", 45 | "section": "Refactors" 46 | }, 47 | { 48 | "type": "test", 49 | "section": "Tests", 50 | "hidden": true 51 | }, 52 | { 53 | "type": "build", 54 | "section": "Build System" 55 | }, 56 | { 57 | "type": "ci", 58 | "section": "Continuous Integration", 59 | "hidden": true 60 | } 61 | ], 62 | "release-type": "python", 63 | "extra-files": [ 64 | "src/lumaai/_version.py" 65 | ] 66 | } -------------------------------------------------------------------------------- /src/lumaai/resources/__init__.py: -------------------------------------------------------------------------------- 1 | # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. 2 | 3 | from .ping import ( 4 | PingResource, 5 | AsyncPingResource, 6 | PingResourceWithRawResponse, 7 | AsyncPingResourceWithRawResponse, 8 | PingResourceWithStreamingResponse, 9 | AsyncPingResourceWithStreamingResponse, 10 | ) 11 | from .credits import ( 12 | CreditsResource, 13 | AsyncCreditsResource, 14 | CreditsResourceWithRawResponse, 15 | AsyncCreditsResourceWithRawResponse, 16 | CreditsResourceWithStreamingResponse, 17 | AsyncCreditsResourceWithStreamingResponse, 18 | ) 19 | from .generations import ( 20 | GenerationsResource, 21 | AsyncGenerationsResource, 22 | GenerationsResourceWithRawResponse, 23 | AsyncGenerationsResourceWithRawResponse, 24 | GenerationsResourceWithStreamingResponse, 25 | AsyncGenerationsResourceWithStreamingResponse, 26 | ) 27 | 28 | __all__ = [ 29 | "GenerationsResource", 30 | "AsyncGenerationsResource", 31 | "GenerationsResourceWithRawResponse", 32 | "AsyncGenerationsResourceWithRawResponse", 33 | "GenerationsResourceWithStreamingResponse", 34 | "AsyncGenerationsResourceWithStreamingResponse", 35 | "PingResource", 36 | "AsyncPingResource", 37 | "PingResourceWithRawResponse", 38 | "AsyncPingResourceWithRawResponse", 39 | "PingResourceWithStreamingResponse", 40 | "AsyncPingResourceWithStreamingResponse", 41 | "CreditsResource", 42 | "AsyncCreditsResource", 43 | "CreditsResourceWithRawResponse", 44 | "AsyncCreditsResourceWithRawResponse", 45 | "CreditsResourceWithStreamingResponse", 46 | "AsyncCreditsResourceWithStreamingResponse", 47 | ] 48 | -------------------------------------------------------------------------------- /requirements.lock: -------------------------------------------------------------------------------- 1 | # generated by rye 2 | # use `rye lock` or `rye sync` to update this lockfile 3 | # 4 | # last locked with the following flags: 5 | # pre: false 6 | # features: [] 7 | # all-features: true 8 | # with-sources: false 9 | # generate-hashes: false 10 | # universal: false 11 | 12 | -e file:. 13 | aiohappyeyeballs==2.6.1 14 | # via aiohttp 15 | aiohttp==3.12.8 16 | # via httpx-aiohttp 17 | # via lumaai 18 | aiosignal==1.3.2 19 | # via aiohttp 20 | annotated-types==0.6.0 21 | # via pydantic 22 | anyio==4.4.0 23 | # via httpx 24 | # via lumaai 25 | async-timeout==5.0.1 26 | # via aiohttp 27 | attrs==25.3.0 28 | # via aiohttp 29 | certifi==2023.7.22 30 | # via httpcore 31 | # via httpx 32 | distro==1.8.0 33 | # via lumaai 34 | exceptiongroup==1.2.2 35 | # via anyio 36 | frozenlist==1.6.2 37 | # via aiohttp 38 | # via aiosignal 39 | h11==0.16.0 40 | # via httpcore 41 | httpcore==1.0.9 42 | # via httpx 43 | httpx==0.28.1 44 | # via httpx-aiohttp 45 | # via lumaai 46 | httpx-aiohttp==0.1.9 47 | # via lumaai 48 | idna==3.4 49 | # via anyio 50 | # via httpx 51 | # via yarl 52 | multidict==6.4.4 53 | # via aiohttp 54 | # via yarl 55 | propcache==0.3.1 56 | # via aiohttp 57 | # via yarl 58 | pydantic==2.11.9 59 | # via lumaai 60 | pydantic-core==2.33.2 61 | # via pydantic 62 | sniffio==1.3.0 63 | # via anyio 64 | # via lumaai 65 | typing-extensions==4.12.2 66 | # via anyio 67 | # via lumaai 68 | # via multidict 69 | # via pydantic 70 | # via pydantic-core 71 | # via typing-inspection 72 | typing-inspection==0.4.1 73 | # via pydantic 74 | yarl==1.20.0 75 | # via aiohttp 76 | -------------------------------------------------------------------------------- /scripts/test: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | cd "$(dirname "$0")/.." 6 | 7 | RED='\033[0;31m' 8 | GREEN='\033[0;32m' 9 | YELLOW='\033[0;33m' 10 | NC='\033[0m' # No Color 11 | 12 | function prism_is_running() { 13 | curl --silent "http://localhost:4010" >/dev/null 2>&1 14 | } 15 | 16 | kill_server_on_port() { 17 | pids=$(lsof -t -i tcp:"$1" || echo "") 18 | if [ "$pids" != "" ]; then 19 | kill "$pids" 20 | echo "Stopped $pids." 21 | fi 22 | } 23 | 24 | function is_overriding_api_base_url() { 25 | [ -n "$TEST_API_BASE_URL" ] 26 | } 27 | 28 | if ! is_overriding_api_base_url && ! prism_is_running ; then 29 | # When we exit this script, make sure to kill the background mock server process 30 | trap 'kill_server_on_port 4010' EXIT 31 | 32 | # Start the dev server 33 | ./scripts/mock --daemon 34 | fi 35 | 36 | if is_overriding_api_base_url ; then 37 | echo -e "${GREEN}✔ Running tests against ${TEST_API_BASE_URL}${NC}" 38 | echo 39 | elif ! prism_is_running ; then 40 | echo -e "${RED}ERROR:${NC} The test suite will not run without a mock Prism server" 41 | echo -e "running against your OpenAPI spec." 42 | echo 43 | echo -e "To run the server, pass in the path or url of your OpenAPI" 44 | echo -e "spec to the prism command:" 45 | echo 46 | echo -e " \$ ${YELLOW}npm exec --package=@stainless-api/prism-cli@5.15.0 -- prism mock path/to/your.openapi.yml${NC}" 47 | echo 48 | 49 | exit 1 50 | else 51 | echo -e "${GREEN}✔ Mock prism server is running with your OpenAPI spec${NC}" 52 | echo 53 | fi 54 | 55 | export DEFER_PYDANTIC_BUILD=false 56 | 57 | echo "==> Running tests" 58 | rye run pytest "$@" 59 | 60 | echo "==> Running Pydantic v1 tests" 61 | rye run nox -s test-pydantic-v1 -- "$@" 62 | -------------------------------------------------------------------------------- /tests/test_files.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import anyio 4 | import pytest 5 | from dirty_equals import IsDict, IsList, IsBytes, IsTuple 6 | 7 | from lumaai._files import to_httpx_files, async_to_httpx_files 8 | 9 | readme_path = Path(__file__).parent.parent.joinpath("README.md") 10 | 11 | 12 | def test_pathlib_includes_file_name() -> None: 13 | result = to_httpx_files({"file": readme_path}) 14 | print(result) 15 | assert result == IsDict({"file": IsTuple("README.md", IsBytes())}) 16 | 17 | 18 | def test_tuple_input() -> None: 19 | result = to_httpx_files([("file", readme_path)]) 20 | print(result) 21 | assert result == IsList(IsTuple("file", IsTuple("README.md", IsBytes()))) 22 | 23 | 24 | @pytest.mark.asyncio 25 | async def test_async_pathlib_includes_file_name() -> None: 26 | result = await async_to_httpx_files({"file": readme_path}) 27 | print(result) 28 | assert result == IsDict({"file": IsTuple("README.md", IsBytes())}) 29 | 30 | 31 | @pytest.mark.asyncio 32 | async def test_async_supports_anyio_path() -> None: 33 | result = await async_to_httpx_files({"file": anyio.Path(readme_path)}) 34 | print(result) 35 | assert result == IsDict({"file": IsTuple("README.md", IsBytes())}) 36 | 37 | 38 | @pytest.mark.asyncio 39 | async def test_async_tuple_input() -> None: 40 | result = await async_to_httpx_files([("file", readme_path)]) 41 | print(result) 42 | assert result == IsList(IsTuple("file", IsTuple("README.md", IsBytes()))) 43 | 44 | 45 | def test_string_not_allowed() -> None: 46 | with pytest.raises(TypeError, match="Expected file types input to be a FileContent type or to be a tuple"): 47 | to_httpx_files( 48 | { 49 | "file": "foo", # type: ignore 50 | } 51 | ) 52 | -------------------------------------------------------------------------------- /tests/test_deepcopy.py: -------------------------------------------------------------------------------- 1 | from lumaai._utils import deepcopy_minimal 2 | 3 | 4 | def assert_different_identities(obj1: object, obj2: object) -> None: 5 | assert obj1 == obj2 6 | assert id(obj1) != id(obj2) 7 | 8 | 9 | def test_simple_dict() -> None: 10 | obj1 = {"foo": "bar"} 11 | obj2 = deepcopy_minimal(obj1) 12 | assert_different_identities(obj1, obj2) 13 | 14 | 15 | def test_nested_dict() -> None: 16 | obj1 = {"foo": {"bar": True}} 17 | obj2 = deepcopy_minimal(obj1) 18 | assert_different_identities(obj1, obj2) 19 | assert_different_identities(obj1["foo"], obj2["foo"]) 20 | 21 | 22 | def test_complex_nested_dict() -> None: 23 | obj1 = {"foo": {"bar": [{"hello": "world"}]}} 24 | obj2 = deepcopy_minimal(obj1) 25 | assert_different_identities(obj1, obj2) 26 | assert_different_identities(obj1["foo"], obj2["foo"]) 27 | assert_different_identities(obj1["foo"]["bar"], obj2["foo"]["bar"]) 28 | assert_different_identities(obj1["foo"]["bar"][0], obj2["foo"]["bar"][0]) 29 | 30 | 31 | def test_simple_list() -> None: 32 | obj1 = ["a", "b", "c"] 33 | obj2 = deepcopy_minimal(obj1) 34 | assert_different_identities(obj1, obj2) 35 | 36 | 37 | def test_nested_list() -> None: 38 | obj1 = ["a", [1, 2, 3]] 39 | obj2 = deepcopy_minimal(obj1) 40 | assert_different_identities(obj1, obj2) 41 | assert_different_identities(obj1[1], obj2[1]) 42 | 43 | 44 | class MyObject: ... 45 | 46 | 47 | def test_ignores_other_types() -> None: 48 | # custom classes 49 | my_obj = MyObject() 50 | obj1 = {"foo": my_obj} 51 | obj2 = deepcopy_minimal(obj1) 52 | assert_different_identities(obj1, obj2) 53 | assert obj1["foo"] is my_obj 54 | 55 | # tuples 56 | obj3 = ("a", "b") 57 | obj4 = deepcopy_minimal(obj3) 58 | assert obj3 is obj4 59 | -------------------------------------------------------------------------------- /src/lumaai/types/generations/image_reframe_params.py: -------------------------------------------------------------------------------- 1 | # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. 2 | 3 | from __future__ import annotations 4 | 5 | from typing_extensions import Literal, Required, TypedDict 6 | 7 | __all__ = ["ImageReframeParams", "Media"] 8 | 9 | 10 | class ImageReframeParams(TypedDict, total=False): 11 | aspect_ratio: Required[Literal["1:1", "16:9", "9:16", "4:3", "3:4", "21:9", "9:21"]] 12 | """The aspect ratio of the generation""" 13 | 14 | generation_type: Required[Literal["reframe_image"]] 15 | 16 | media: Required[Media] 17 | """The image entity object""" 18 | 19 | model: Required[Literal["photon-1", "photon-flash-1"]] 20 | """The model used for the reframe image""" 21 | 22 | callback_url: str 23 | """ 24 | The callback URL of the generation, a POST request with Generation object will 25 | be sent to the callback URL when the generation is dreaming, completed, or 26 | failed 27 | """ 28 | 29 | format: Literal["jpg", "png"] 30 | """The format of the image""" 31 | 32 | grid_position_x: int 33 | """The x position of the image in the grid""" 34 | 35 | grid_position_y: int 36 | """The y position of the image in the grid""" 37 | 38 | prompt: str 39 | """The prompt of the generation""" 40 | 41 | resized_height: int 42 | """Resized height of source image""" 43 | 44 | resized_width: int 45 | """Resized width of source image""" 46 | 47 | x_end: int 48 | """The x end of the crop bounds""" 49 | 50 | x_start: int 51 | """The x start of the crop bounds""" 52 | 53 | y_end: int 54 | """The y end of the crop bounds""" 55 | 56 | y_start: int 57 | """The y start of the crop bounds""" 58 | 59 | 60 | class Media(TypedDict, total=False): 61 | url: Required[str] 62 | """The URL of the media""" 63 | -------------------------------------------------------------------------------- /src/lumaai/types/generations/video_reframe_params.py: -------------------------------------------------------------------------------- 1 | # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. 2 | 3 | from __future__ import annotations 4 | 5 | from typing_extensions import Literal, Required, TypedDict 6 | 7 | __all__ = ["VideoReframeParams", "Media", "FirstFrame"] 8 | 9 | 10 | class VideoReframeParams(TypedDict, total=False): 11 | aspect_ratio: Required[Literal["1:1", "16:9", "9:16", "4:3", "3:4", "21:9", "9:21"]] 12 | """The aspect ratio of the generation""" 13 | 14 | generation_type: Required[Literal["reframe_video"]] 15 | 16 | media: Required[Media] 17 | """The image entity object""" 18 | 19 | model: Required[Literal["ray-2", "ray-flash-2"]] 20 | """The model used for the reframe video""" 21 | 22 | callback_url: str 23 | """ 24 | The callback URL of the generation, a POST request with Generation object will 25 | be sent to the callback URL when the generation is dreaming, completed, or 26 | failed 27 | """ 28 | 29 | first_frame: FirstFrame 30 | """The image entity object""" 31 | 32 | grid_position_x: int 33 | """The x position of the image in the grid""" 34 | 35 | grid_position_y: int 36 | """The y position of the image in the grid""" 37 | 38 | prompt: str 39 | """The prompt of the generation""" 40 | 41 | resized_height: int 42 | """Resized height of source video""" 43 | 44 | resized_width: int 45 | """Resized width of source video""" 46 | 47 | x_end: int 48 | """The x end of the crop bounds""" 49 | 50 | x_start: int 51 | """The x start of the crop bounds""" 52 | 53 | y_end: int 54 | """The y end of the crop bounds""" 55 | 56 | y_start: int 57 | """The y start of the crop bounds""" 58 | 59 | 60 | class Media(TypedDict, total=False): 61 | url: Required[str] 62 | """The URL of the media""" 63 | 64 | 65 | class FirstFrame(TypedDict, total=False): 66 | url: Required[str] 67 | """The URL of the media""" 68 | -------------------------------------------------------------------------------- /tests/test_extract_files.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Sequence 4 | 5 | import pytest 6 | 7 | from lumaai._types import FileTypes 8 | from lumaai._utils import extract_files 9 | 10 | 11 | def test_removes_files_from_input() -> None: 12 | query = {"foo": "bar"} 13 | assert extract_files(query, paths=[]) == [] 14 | assert query == {"foo": "bar"} 15 | 16 | query2 = {"foo": b"Bar", "hello": "world"} 17 | assert extract_files(query2, paths=[["foo"]]) == [("foo", b"Bar")] 18 | assert query2 == {"hello": "world"} 19 | 20 | query3 = {"foo": {"foo": {"bar": b"Bar"}}, "hello": "world"} 21 | assert extract_files(query3, paths=[["foo", "foo", "bar"]]) == [("foo[foo][bar]", b"Bar")] 22 | assert query3 == {"foo": {"foo": {}}, "hello": "world"} 23 | 24 | query4 = {"foo": {"bar": b"Bar", "baz": "foo"}, "hello": "world"} 25 | assert extract_files(query4, paths=[["foo", "bar"]]) == [("foo[bar]", b"Bar")] 26 | assert query4 == {"hello": "world", "foo": {"baz": "foo"}} 27 | 28 | 29 | def test_multiple_files() -> None: 30 | query = {"documents": [{"file": b"My first file"}, {"file": b"My second file"}]} 31 | assert extract_files(query, paths=[["documents", "", "file"]]) == [ 32 | ("documents[][file]", b"My first file"), 33 | ("documents[][file]", b"My second file"), 34 | ] 35 | assert query == {"documents": [{}, {}]} 36 | 37 | 38 | @pytest.mark.parametrize( 39 | "query,paths,expected", 40 | [ 41 | [ 42 | {"foo": {"bar": "baz"}}, 43 | [["foo", "", "bar"]], 44 | [], 45 | ], 46 | [ 47 | {"foo": ["bar", "baz"]}, 48 | [["foo", "bar"]], 49 | [], 50 | ], 51 | [ 52 | {"foo": {"bar": "baz"}}, 53 | [["foo", "foo"]], 54 | [], 55 | ], 56 | ], 57 | ids=["dict expecting array", "array expecting dict", "unknown keys"], 58 | ) 59 | def test_ignores_incorrect_paths( 60 | query: dict[str, object], 61 | paths: Sequence[Sequence[str]], 62 | expected: list[tuple[str, FileTypes]], 63 | ) -> None: 64 | assert extract_files(query, paths=paths) == expected 65 | -------------------------------------------------------------------------------- /src/lumaai/resources/generations/__init__.py: -------------------------------------------------------------------------------- 1 | # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. 2 | 3 | from .image import ( 4 | ImageResource, 5 | AsyncImageResource, 6 | ImageResourceWithRawResponse, 7 | AsyncImageResourceWithRawResponse, 8 | ImageResourceWithStreamingResponse, 9 | AsyncImageResourceWithStreamingResponse, 10 | ) 11 | from .video import ( 12 | VideoResource, 13 | AsyncVideoResource, 14 | VideoResourceWithRawResponse, 15 | AsyncVideoResourceWithRawResponse, 16 | VideoResourceWithStreamingResponse, 17 | AsyncVideoResourceWithStreamingResponse, 18 | ) 19 | from .concepts import ( 20 | ConceptsResource, 21 | AsyncConceptsResource, 22 | ConceptsResourceWithRawResponse, 23 | AsyncConceptsResourceWithRawResponse, 24 | ConceptsResourceWithStreamingResponse, 25 | AsyncConceptsResourceWithStreamingResponse, 26 | ) 27 | from .generations import ( 28 | GenerationsResource, 29 | AsyncGenerationsResource, 30 | GenerationsResourceWithRawResponse, 31 | AsyncGenerationsResourceWithRawResponse, 32 | GenerationsResourceWithStreamingResponse, 33 | AsyncGenerationsResourceWithStreamingResponse, 34 | ) 35 | 36 | __all__ = [ 37 | "ConceptsResource", 38 | "AsyncConceptsResource", 39 | "ConceptsResourceWithRawResponse", 40 | "AsyncConceptsResourceWithRawResponse", 41 | "ConceptsResourceWithStreamingResponse", 42 | "AsyncConceptsResourceWithStreamingResponse", 43 | "ImageResource", 44 | "AsyncImageResource", 45 | "ImageResourceWithRawResponse", 46 | "AsyncImageResourceWithRawResponse", 47 | "ImageResourceWithStreamingResponse", 48 | "AsyncImageResourceWithStreamingResponse", 49 | "VideoResource", 50 | "AsyncVideoResource", 51 | "VideoResourceWithRawResponse", 52 | "AsyncVideoResourceWithRawResponse", 53 | "VideoResourceWithStreamingResponse", 54 | "AsyncVideoResourceWithStreamingResponse", 55 | "GenerationsResource", 56 | "AsyncGenerationsResource", 57 | "GenerationsResourceWithRawResponse", 58 | "AsyncGenerationsResourceWithRawResponse", 59 | "GenerationsResourceWithStreamingResponse", 60 | "AsyncGenerationsResourceWithStreamingResponse", 61 | ] 62 | -------------------------------------------------------------------------------- /src/lumaai/_utils/_proxy.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from abc import ABC, abstractmethod 4 | from typing import Generic, TypeVar, Iterable, cast 5 | from typing_extensions import override 6 | 7 | T = TypeVar("T") 8 | 9 | 10 | class LazyProxy(Generic[T], ABC): 11 | """Implements data methods to pretend that an instance is another instance. 12 | 13 | This includes forwarding attribute access and other methods. 14 | """ 15 | 16 | # Note: we have to special case proxies that themselves return proxies 17 | # to support using a proxy as a catch-all for any random access, e.g. `proxy.foo.bar.baz` 18 | 19 | def __getattr__(self, attr: str) -> object: 20 | proxied = self.__get_proxied__() 21 | if isinstance(proxied, LazyProxy): 22 | return proxied # pyright: ignore 23 | return getattr(proxied, attr) 24 | 25 | @override 26 | def __repr__(self) -> str: 27 | proxied = self.__get_proxied__() 28 | if isinstance(proxied, LazyProxy): 29 | return proxied.__class__.__name__ 30 | return repr(self.__get_proxied__()) 31 | 32 | @override 33 | def __str__(self) -> str: 34 | proxied = self.__get_proxied__() 35 | if isinstance(proxied, LazyProxy): 36 | return proxied.__class__.__name__ 37 | return str(proxied) 38 | 39 | @override 40 | def __dir__(self) -> Iterable[str]: 41 | proxied = self.__get_proxied__() 42 | if isinstance(proxied, LazyProxy): 43 | return [] 44 | return proxied.__dir__() 45 | 46 | @property # type: ignore 47 | @override 48 | def __class__(self) -> type: # pyright: ignore 49 | try: 50 | proxied = self.__get_proxied__() 51 | except Exception: 52 | return type(self) 53 | if issubclass(type(proxied), LazyProxy): 54 | return type(proxied) 55 | return proxied.__class__ 56 | 57 | def __get_proxied__(self) -> T: 58 | return self.__load__() 59 | 60 | def __as_proxied__(self) -> T: 61 | """Helper method that returns the current proxy, typed as the loaded object""" 62 | return cast(T, self) 63 | 64 | @abstractmethod 65 | def __load__(self) -> T: ... 66 | -------------------------------------------------------------------------------- /src/lumaai/types/generations/image_create_params.py: -------------------------------------------------------------------------------- 1 | # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. 2 | 3 | from __future__ import annotations 4 | 5 | from typing import Iterable 6 | from typing_extensions import Literal, Required, TypedDict 7 | 8 | from ..._types import SequenceNotStr 9 | 10 | __all__ = ["ImageCreateParams", "CharacterRef", "CharacterRefIdentity0", "ImageRef", "ModifyImageRef", "StyleRef"] 11 | 12 | 13 | class ImageCreateParams(TypedDict, total=False): 14 | model: Required[Literal["photon-1", "photon-flash-1"]] 15 | """The model used for the generation""" 16 | 17 | aspect_ratio: Literal["1:1", "16:9", "9:16", "4:3", "3:4", "21:9", "9:21"] 18 | """The aspect ratio of the generation""" 19 | 20 | callback_url: str 21 | """The callback URL for the generation""" 22 | 23 | character_ref: CharacterRef 24 | 25 | format: Literal["jpg", "png"] 26 | """The format of the image""" 27 | 28 | generation_type: Literal["image"] 29 | 30 | image_ref: Iterable[ImageRef] 31 | 32 | modify_image_ref: ModifyImageRef 33 | """The modify image reference object""" 34 | 35 | prompt: str 36 | """The prompt of the generation""" 37 | 38 | style_ref: Iterable[StyleRef] 39 | 40 | sync: bool 41 | """Create image in synchronous mode and return complated image""" 42 | 43 | sync_timeout: float 44 | """The timeout for the synchronous image generation""" 45 | 46 | 47 | class CharacterRefIdentity0(TypedDict, total=False): 48 | images: SequenceNotStr[str] 49 | """The URLs of the image identity""" 50 | 51 | 52 | class CharacterRef(TypedDict, total=False): 53 | identity0: CharacterRefIdentity0 54 | """The image identity object""" 55 | 56 | 57 | class ImageRef(TypedDict, total=False): 58 | url: str 59 | """The URL of the image reference""" 60 | 61 | weight: float 62 | """The weight of the image reference""" 63 | 64 | 65 | class ModifyImageRef(TypedDict, total=False): 66 | url: str 67 | """The URL of the image reference""" 68 | 69 | weight: float 70 | """The weight of the modify image reference""" 71 | 72 | 73 | class StyleRef(TypedDict, total=False): 74 | url: str 75 | """The URL of the image reference""" 76 | 77 | weight: float 78 | """The weight of the image reference""" 79 | -------------------------------------------------------------------------------- /src/lumaai/_utils/__init__.py: -------------------------------------------------------------------------------- 1 | from ._sync import asyncify as asyncify 2 | from ._proxy import LazyProxy as LazyProxy 3 | from ._utils import ( 4 | flatten as flatten, 5 | is_dict as is_dict, 6 | is_list as is_list, 7 | is_given as is_given, 8 | is_tuple as is_tuple, 9 | json_safe as json_safe, 10 | lru_cache as lru_cache, 11 | is_mapping as is_mapping, 12 | is_tuple_t as is_tuple_t, 13 | is_iterable as is_iterable, 14 | is_sequence as is_sequence, 15 | coerce_float as coerce_float, 16 | is_mapping_t as is_mapping_t, 17 | removeprefix as removeprefix, 18 | removesuffix as removesuffix, 19 | extract_files as extract_files, 20 | is_sequence_t as is_sequence_t, 21 | required_args as required_args, 22 | coerce_boolean as coerce_boolean, 23 | coerce_integer as coerce_integer, 24 | file_from_path as file_from_path, 25 | strip_not_given as strip_not_given, 26 | deepcopy_minimal as deepcopy_minimal, 27 | get_async_library as get_async_library, 28 | maybe_coerce_float as maybe_coerce_float, 29 | get_required_header as get_required_header, 30 | maybe_coerce_boolean as maybe_coerce_boolean, 31 | maybe_coerce_integer as maybe_coerce_integer, 32 | ) 33 | from ._compat import ( 34 | get_args as get_args, 35 | is_union as is_union, 36 | get_origin as get_origin, 37 | is_typeddict as is_typeddict, 38 | is_literal_type as is_literal_type, 39 | ) 40 | from ._typing import ( 41 | is_list_type as is_list_type, 42 | is_union_type as is_union_type, 43 | extract_type_arg as extract_type_arg, 44 | is_iterable_type as is_iterable_type, 45 | is_required_type as is_required_type, 46 | is_sequence_type as is_sequence_type, 47 | is_annotated_type as is_annotated_type, 48 | is_type_alias_type as is_type_alias_type, 49 | strip_annotated_type as strip_annotated_type, 50 | extract_type_var_from_base as extract_type_var_from_base, 51 | ) 52 | from ._streams import consume_sync_iterator as consume_sync_iterator, consume_async_iterator as consume_async_iterator 53 | from ._transform import ( 54 | PropertyInfo as PropertyInfo, 55 | transform as transform, 56 | async_transform as async_transform, 57 | maybe_transform as maybe_transform, 58 | async_maybe_transform as async_maybe_transform, 59 | ) 60 | from ._reflection import ( 61 | function_has_argument as function_has_argument, 62 | assert_signatures_in_sync as assert_signatures_in_sync, 63 | ) 64 | from ._datetime_parse import parse_date as parse_date, parse_datetime as parse_datetime 65 | -------------------------------------------------------------------------------- /tests/test_utils/test_typing.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Generic, TypeVar, cast 4 | 5 | from lumaai._utils import extract_type_var_from_base 6 | 7 | _T = TypeVar("_T") 8 | _T2 = TypeVar("_T2") 9 | _T3 = TypeVar("_T3") 10 | 11 | 12 | class BaseGeneric(Generic[_T]): ... 13 | 14 | 15 | class SubclassGeneric(BaseGeneric[_T]): ... 16 | 17 | 18 | class BaseGenericMultipleTypeArgs(Generic[_T, _T2, _T3]): ... 19 | 20 | 21 | class SubclassGenericMultipleTypeArgs(BaseGenericMultipleTypeArgs[_T, _T2, _T3]): ... 22 | 23 | 24 | class SubclassDifferentOrderGenericMultipleTypeArgs(BaseGenericMultipleTypeArgs[_T2, _T, _T3]): ... 25 | 26 | 27 | def test_extract_type_var() -> None: 28 | assert ( 29 | extract_type_var_from_base( 30 | BaseGeneric[int], 31 | index=0, 32 | generic_bases=cast("tuple[type, ...]", (BaseGeneric,)), 33 | ) 34 | == int 35 | ) 36 | 37 | 38 | def test_extract_type_var_generic_subclass() -> None: 39 | assert ( 40 | extract_type_var_from_base( 41 | SubclassGeneric[int], 42 | index=0, 43 | generic_bases=cast("tuple[type, ...]", (BaseGeneric,)), 44 | ) 45 | == int 46 | ) 47 | 48 | 49 | def test_extract_type_var_multiple() -> None: 50 | typ = BaseGenericMultipleTypeArgs[int, str, None] 51 | 52 | generic_bases = cast("tuple[type, ...]", (BaseGenericMultipleTypeArgs,)) 53 | assert extract_type_var_from_base(typ, index=0, generic_bases=generic_bases) == int 54 | assert extract_type_var_from_base(typ, index=1, generic_bases=generic_bases) == str 55 | assert extract_type_var_from_base(typ, index=2, generic_bases=generic_bases) == type(None) 56 | 57 | 58 | def test_extract_type_var_generic_subclass_multiple() -> None: 59 | typ = SubclassGenericMultipleTypeArgs[int, str, None] 60 | 61 | generic_bases = cast("tuple[type, ...]", (BaseGenericMultipleTypeArgs,)) 62 | assert extract_type_var_from_base(typ, index=0, generic_bases=generic_bases) == int 63 | assert extract_type_var_from_base(typ, index=1, generic_bases=generic_bases) == str 64 | assert extract_type_var_from_base(typ, index=2, generic_bases=generic_bases) == type(None) 65 | 66 | 67 | def test_extract_type_var_generic_subclass_different_ordering_multiple() -> None: 68 | typ = SubclassDifferentOrderGenericMultipleTypeArgs[int, str, None] 69 | 70 | generic_bases = cast("tuple[type, ...]", (BaseGenericMultipleTypeArgs,)) 71 | assert extract_type_var_from_base(typ, index=0, generic_bases=generic_bases) == int 72 | assert extract_type_var_from_base(typ, index=1, generic_bases=generic_bases) == str 73 | assert extract_type_var_from_base(typ, index=2, generic_bases=generic_bases) == type(None) 74 | -------------------------------------------------------------------------------- /src/lumaai/__init__.py: -------------------------------------------------------------------------------- 1 | # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. 2 | 3 | import typing as _t 4 | 5 | from . import types 6 | from ._types import NOT_GIVEN, Omit, NoneType, NotGiven, Transport, ProxiesTypes, omit, not_given 7 | from ._utils import file_from_path 8 | from ._client import Client, LumaAI, Stream, Timeout, Transport, AsyncClient, AsyncLumaAI, AsyncStream, RequestOptions 9 | from ._models import BaseModel 10 | from ._version import __title__, __version__ 11 | from ._response import APIResponse as APIResponse, AsyncAPIResponse as AsyncAPIResponse 12 | from ._constants import DEFAULT_TIMEOUT, DEFAULT_MAX_RETRIES, DEFAULT_CONNECTION_LIMITS 13 | from ._exceptions import ( 14 | APIError, 15 | LumaAIError, 16 | ConflictError, 17 | NotFoundError, 18 | APIStatusError, 19 | RateLimitError, 20 | APITimeoutError, 21 | BadRequestError, 22 | APIConnectionError, 23 | AuthenticationError, 24 | InternalServerError, 25 | PermissionDeniedError, 26 | UnprocessableEntityError, 27 | APIResponseValidationError, 28 | ) 29 | from ._base_client import DefaultHttpxClient, DefaultAioHttpClient, DefaultAsyncHttpxClient 30 | from ._utils._logs import setup_logging as _setup_logging 31 | 32 | __all__ = [ 33 | "types", 34 | "__version__", 35 | "__title__", 36 | "NoneType", 37 | "Transport", 38 | "ProxiesTypes", 39 | "NotGiven", 40 | "NOT_GIVEN", 41 | "not_given", 42 | "Omit", 43 | "omit", 44 | "LumaAIError", 45 | "APIError", 46 | "APIStatusError", 47 | "APITimeoutError", 48 | "APIConnectionError", 49 | "APIResponseValidationError", 50 | "BadRequestError", 51 | "AuthenticationError", 52 | "PermissionDeniedError", 53 | "NotFoundError", 54 | "ConflictError", 55 | "UnprocessableEntityError", 56 | "RateLimitError", 57 | "InternalServerError", 58 | "Timeout", 59 | "RequestOptions", 60 | "Client", 61 | "AsyncClient", 62 | "Stream", 63 | "AsyncStream", 64 | "LumaAI", 65 | "AsyncLumaAI", 66 | "file_from_path", 67 | "BaseModel", 68 | "DEFAULT_TIMEOUT", 69 | "DEFAULT_MAX_RETRIES", 70 | "DEFAULT_CONNECTION_LIMITS", 71 | "DefaultHttpxClient", 72 | "DefaultAsyncHttpxClient", 73 | "DefaultAioHttpClient", 74 | ] 75 | 76 | if not _t.TYPE_CHECKING: 77 | from ._utils._resources_proxy import resources as resources 78 | 79 | _setup_logging() 80 | 81 | # Update the __module__ attribute for exported symbols so that 82 | # error messages point to this module instead of the module 83 | # it was originally defined in, e.g. 84 | # lumaai._exceptions.NotFoundError -> lumaai.NotFoundError 85 | __locals = locals() 86 | for __name in __all__: 87 | if not __name.startswith("__"): 88 | try: 89 | __locals[__name].__module__ = "lumaai" 90 | except (TypeError, AttributeError): 91 | # Some of our exported symbols are builtins which we can't set attributes for. 92 | pass 93 | -------------------------------------------------------------------------------- /tests/api_resources/test_ping.py: -------------------------------------------------------------------------------- 1 | # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. 2 | 3 | from __future__ import annotations 4 | 5 | import os 6 | from typing import Any, cast 7 | 8 | import pytest 9 | 10 | from lumaai import LumaAI, AsyncLumaAI 11 | from tests.utils import assert_matches_type 12 | from lumaai.types import PingCheckResponse 13 | 14 | base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") 15 | 16 | 17 | class TestPing: 18 | parametrize = pytest.mark.parametrize("client", [False, True], indirect=True, ids=["loose", "strict"]) 19 | 20 | @parametrize 21 | def test_method_check(self, client: LumaAI) -> None: 22 | ping = client.ping.check() 23 | assert_matches_type(PingCheckResponse, ping, path=["response"]) 24 | 25 | @parametrize 26 | def test_raw_response_check(self, client: LumaAI) -> None: 27 | response = client.ping.with_raw_response.check() 28 | 29 | assert response.is_closed is True 30 | assert response.http_request.headers.get("X-Stainless-Lang") == "python" 31 | ping = response.parse() 32 | assert_matches_type(PingCheckResponse, ping, path=["response"]) 33 | 34 | @parametrize 35 | def test_streaming_response_check(self, client: LumaAI) -> None: 36 | with client.ping.with_streaming_response.check() as response: 37 | assert not response.is_closed 38 | assert response.http_request.headers.get("X-Stainless-Lang") == "python" 39 | 40 | ping = response.parse() 41 | assert_matches_type(PingCheckResponse, ping, path=["response"]) 42 | 43 | assert cast(Any, response.is_closed) is True 44 | 45 | 46 | class TestAsyncPing: 47 | parametrize = pytest.mark.parametrize( 48 | "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] 49 | ) 50 | 51 | @parametrize 52 | async def test_method_check(self, async_client: AsyncLumaAI) -> None: 53 | ping = await async_client.ping.check() 54 | assert_matches_type(PingCheckResponse, ping, path=["response"]) 55 | 56 | @parametrize 57 | async def test_raw_response_check(self, async_client: AsyncLumaAI) -> None: 58 | response = await async_client.ping.with_raw_response.check() 59 | 60 | assert response.is_closed is True 61 | assert response.http_request.headers.get("X-Stainless-Lang") == "python" 62 | ping = await response.parse() 63 | assert_matches_type(PingCheckResponse, ping, path=["response"]) 64 | 65 | @parametrize 66 | async def test_streaming_response_check(self, async_client: AsyncLumaAI) -> None: 67 | async with async_client.ping.with_streaming_response.check() as response: 68 | assert not response.is_closed 69 | assert response.http_request.headers.get("X-Stainless-Lang") == "python" 70 | 71 | ping = await response.parse() 72 | assert_matches_type(PingCheckResponse, ping, path=["response"]) 73 | 74 | assert cast(Any, response.is_closed) is True 75 | -------------------------------------------------------------------------------- /tests/api_resources/test_credits.py: -------------------------------------------------------------------------------- 1 | # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. 2 | 3 | from __future__ import annotations 4 | 5 | import os 6 | from typing import Any, cast 7 | 8 | import pytest 9 | 10 | from lumaai import LumaAI, AsyncLumaAI 11 | from tests.utils import assert_matches_type 12 | from lumaai.types import CreditGetResponse 13 | 14 | base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") 15 | 16 | 17 | class TestCredits: 18 | parametrize = pytest.mark.parametrize("client", [False, True], indirect=True, ids=["loose", "strict"]) 19 | 20 | @parametrize 21 | def test_method_get(self, client: LumaAI) -> None: 22 | credit = client.credits.get() 23 | assert_matches_type(CreditGetResponse, credit, path=["response"]) 24 | 25 | @parametrize 26 | def test_raw_response_get(self, client: LumaAI) -> None: 27 | response = client.credits.with_raw_response.get() 28 | 29 | assert response.is_closed is True 30 | assert response.http_request.headers.get("X-Stainless-Lang") == "python" 31 | credit = response.parse() 32 | assert_matches_type(CreditGetResponse, credit, path=["response"]) 33 | 34 | @parametrize 35 | def test_streaming_response_get(self, client: LumaAI) -> None: 36 | with client.credits.with_streaming_response.get() as response: 37 | assert not response.is_closed 38 | assert response.http_request.headers.get("X-Stainless-Lang") == "python" 39 | 40 | credit = response.parse() 41 | assert_matches_type(CreditGetResponse, credit, path=["response"]) 42 | 43 | assert cast(Any, response.is_closed) is True 44 | 45 | 46 | class TestAsyncCredits: 47 | parametrize = pytest.mark.parametrize( 48 | "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] 49 | ) 50 | 51 | @parametrize 52 | async def test_method_get(self, async_client: AsyncLumaAI) -> None: 53 | credit = await async_client.credits.get() 54 | assert_matches_type(CreditGetResponse, credit, path=["response"]) 55 | 56 | @parametrize 57 | async def test_raw_response_get(self, async_client: AsyncLumaAI) -> None: 58 | response = await async_client.credits.with_raw_response.get() 59 | 60 | assert response.is_closed is True 61 | assert response.http_request.headers.get("X-Stainless-Lang") == "python" 62 | credit = await response.parse() 63 | assert_matches_type(CreditGetResponse, credit, path=["response"]) 64 | 65 | @parametrize 66 | async def test_streaming_response_get(self, async_client: AsyncLumaAI) -> None: 67 | async with async_client.credits.with_streaming_response.get() as response: 68 | assert not response.is_closed 69 | assert response.http_request.headers.get("X-Stainless-Lang") == "python" 70 | 71 | credit = await response.parse() 72 | assert_matches_type(CreditGetResponse, credit, path=["response"]) 73 | 74 | assert cast(Any, response.is_closed) is True 75 | -------------------------------------------------------------------------------- /src/lumaai/types/generations/video_create_params.py: -------------------------------------------------------------------------------- 1 | # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. 2 | 3 | from __future__ import annotations 4 | 5 | from typing import Union, Iterable 6 | from typing_extensions import Literal, Required, TypeAlias, TypedDict 7 | 8 | __all__ = [ 9 | "VideoCreateParams", 10 | "Concept", 11 | "Keyframes", 12 | "KeyframesFrame0", 13 | "KeyframesFrame0GenerationReference", 14 | "KeyframesFrame0ImageReference", 15 | "KeyframesFrame1", 16 | "KeyframesFrame1GenerationReference", 17 | "KeyframesFrame1ImageReference", 18 | ] 19 | 20 | 21 | class VideoCreateParams(TypedDict, total=False): 22 | model: Required[Literal["ray-1-6", "ray-2", "ray-flash-2"]] 23 | """The model used for the generation""" 24 | 25 | aspect_ratio: Literal["1:1", "16:9", "9:16", "4:3", "3:4", "21:9", "9:21"] 26 | """The aspect ratio of the generation""" 27 | 28 | callback_url: str 29 | """ 30 | The callback URL of the generation, a POST request with Generation object will 31 | be sent to the callback URL when the generation is dreaming, completed, or 32 | failed 33 | """ 34 | 35 | concepts: Iterable[Concept] 36 | """The concepts of the generation""" 37 | 38 | duration: Union[Literal["5s", "9s"], str] 39 | """The duration of the generation""" 40 | 41 | generation_type: Literal["video"] 42 | 43 | keyframes: Keyframes 44 | """The keyframes of the generation""" 45 | 46 | loop: bool 47 | """Whether to loop the video""" 48 | 49 | prompt: str 50 | """The prompt of the generation""" 51 | 52 | resolution: Union[Literal["540p", "720p", "1080p", "4k"], str] 53 | """The resolution of the generation""" 54 | 55 | 56 | class Concept(TypedDict, total=False): 57 | key: Required[str] 58 | """The key of the concept""" 59 | 60 | 61 | class KeyframesFrame0GenerationReference(TypedDict, total=False): 62 | id: Required[str] 63 | """The ID of the generation""" 64 | 65 | type: Required[Literal["generation"]] 66 | 67 | 68 | class KeyframesFrame0ImageReference(TypedDict, total=False): 69 | type: Required[Literal["image"]] 70 | 71 | url: Required[str] 72 | """The URL of the image""" 73 | 74 | 75 | KeyframesFrame0: TypeAlias = Union[KeyframesFrame0GenerationReference, KeyframesFrame0ImageReference] 76 | 77 | 78 | class KeyframesFrame1GenerationReference(TypedDict, total=False): 79 | id: Required[str] 80 | """The ID of the generation""" 81 | 82 | type: Required[Literal["generation"]] 83 | 84 | 85 | class KeyframesFrame1ImageReference(TypedDict, total=False): 86 | type: Required[Literal["image"]] 87 | 88 | url: Required[str] 89 | """The URL of the image""" 90 | 91 | 92 | KeyframesFrame1: TypeAlias = Union[KeyframesFrame1GenerationReference, KeyframesFrame1ImageReference] 93 | 94 | 95 | class Keyframes(TypedDict, total=False): 96 | frame0: KeyframesFrame0 97 | """The frame 0 of the generation""" 98 | 99 | frame1: KeyframesFrame1 100 | """The frame 1 of the generation""" 101 | -------------------------------------------------------------------------------- /src/lumaai/types/generation_create_params.py: -------------------------------------------------------------------------------- 1 | # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. 2 | 3 | from __future__ import annotations 4 | 5 | from typing import Union, Iterable 6 | from typing_extensions import Literal, Required, TypeAlias, TypedDict 7 | 8 | __all__ = [ 9 | "GenerationCreateParams", 10 | "Concept", 11 | "Keyframes", 12 | "KeyframesFrame0", 13 | "KeyframesFrame0GenerationReference", 14 | "KeyframesFrame0ImageReference", 15 | "KeyframesFrame1", 16 | "KeyframesFrame1GenerationReference", 17 | "KeyframesFrame1ImageReference", 18 | ] 19 | 20 | 21 | class GenerationCreateParams(TypedDict, total=False): 22 | model: Required[Literal["ray-1-6", "ray-2", "ray-flash-2"]] 23 | """The model used for the generation""" 24 | 25 | aspect_ratio: Literal["1:1", "16:9", "9:16", "4:3", "3:4", "21:9", "9:21"] 26 | """The aspect ratio of the generation""" 27 | 28 | callback_url: str 29 | """ 30 | The callback URL of the generation, a POST request with Generation object will 31 | be sent to the callback URL when the generation is dreaming, completed, or 32 | failed 33 | """ 34 | 35 | concepts: Iterable[Concept] 36 | """The concepts of the generation""" 37 | 38 | duration: Union[Literal["5s", "9s"], str] 39 | """The duration of the generation""" 40 | 41 | generation_type: Literal["video"] 42 | 43 | keyframes: Keyframes 44 | """The keyframes of the generation""" 45 | 46 | loop: bool 47 | """Whether to loop the video""" 48 | 49 | prompt: str 50 | """The prompt of the generation""" 51 | 52 | resolution: Union[Literal["540p", "720p", "1080p", "4k"], str] 53 | """The resolution of the generation""" 54 | 55 | 56 | class Concept(TypedDict, total=False): 57 | key: Required[str] 58 | """The key of the concept""" 59 | 60 | 61 | class KeyframesFrame0GenerationReference(TypedDict, total=False): 62 | id: Required[str] 63 | """The ID of the generation""" 64 | 65 | type: Required[Literal["generation"]] 66 | 67 | 68 | class KeyframesFrame0ImageReference(TypedDict, total=False): 69 | type: Required[Literal["image"]] 70 | 71 | url: Required[str] 72 | """The URL of the image""" 73 | 74 | 75 | KeyframesFrame0: TypeAlias = Union[KeyframesFrame0GenerationReference, KeyframesFrame0ImageReference] 76 | 77 | 78 | class KeyframesFrame1GenerationReference(TypedDict, total=False): 79 | id: Required[str] 80 | """The ID of the generation""" 81 | 82 | type: Required[Literal["generation"]] 83 | 84 | 85 | class KeyframesFrame1ImageReference(TypedDict, total=False): 86 | type: Required[Literal["image"]] 87 | 88 | url: Required[str] 89 | """The URL of the image""" 90 | 91 | 92 | KeyframesFrame1: TypeAlias = Union[KeyframesFrame1GenerationReference, KeyframesFrame1ImageReference] 93 | 94 | 95 | class Keyframes(TypedDict, total=False): 96 | frame0: KeyframesFrame0 97 | """The frame 0 of the generation""" 98 | 99 | frame1: KeyframesFrame1 100 | """The frame 1 of the generation""" 101 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | push: 4 | branches-ignore: 5 | - 'generated' 6 | - 'codegen/**' 7 | - 'integrated/**' 8 | - 'stl-preview-head/**' 9 | - 'stl-preview-base/**' 10 | pull_request: 11 | branches-ignore: 12 | - 'stl-preview-head/**' 13 | - 'stl-preview-base/**' 14 | 15 | jobs: 16 | lint: 17 | timeout-minutes: 10 18 | name: lint 19 | runs-on: ${{ github.repository == 'stainless-sdks/luma_ai-python' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }} 20 | if: github.event_name == 'push' || github.event.pull_request.head.repo.fork 21 | steps: 22 | - uses: actions/checkout@v4 23 | 24 | - name: Install Rye 25 | run: | 26 | curl -sSf https://rye.astral.sh/get | bash 27 | echo "$HOME/.rye/shims" >> $GITHUB_PATH 28 | env: 29 | RYE_VERSION: '0.44.0' 30 | RYE_INSTALL_OPTION: '--yes' 31 | 32 | - name: Install dependencies 33 | run: rye sync --all-features 34 | 35 | - name: Run lints 36 | run: ./scripts/lint 37 | 38 | build: 39 | if: github.event_name == 'push' || github.event.pull_request.head.repo.fork 40 | timeout-minutes: 10 41 | name: build 42 | permissions: 43 | contents: read 44 | id-token: write 45 | runs-on: ${{ github.repository == 'stainless-sdks/luma_ai-python' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }} 46 | steps: 47 | - uses: actions/checkout@v4 48 | 49 | - name: Install Rye 50 | run: | 51 | curl -sSf https://rye.astral.sh/get | bash 52 | echo "$HOME/.rye/shims" >> $GITHUB_PATH 53 | env: 54 | RYE_VERSION: '0.44.0' 55 | RYE_INSTALL_OPTION: '--yes' 56 | 57 | - name: Install dependencies 58 | run: rye sync --all-features 59 | 60 | - name: Run build 61 | run: rye build 62 | 63 | - name: Get GitHub OIDC Token 64 | if: github.repository == 'stainless-sdks/luma_ai-python' 65 | id: github-oidc 66 | uses: actions/github-script@v6 67 | with: 68 | script: core.setOutput('github_token', await core.getIDToken()); 69 | 70 | - name: Upload tarball 71 | if: github.repository == 'stainless-sdks/luma_ai-python' 72 | env: 73 | URL: https://pkg.stainless.com/s 74 | AUTH: ${{ steps.github-oidc.outputs.github_token }} 75 | SHA: ${{ github.sha }} 76 | run: ./scripts/utils/upload-artifact.sh 77 | 78 | test: 79 | timeout-minutes: 10 80 | name: test 81 | runs-on: ${{ github.repository == 'stainless-sdks/luma_ai-python' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }} 82 | if: github.event_name == 'push' || github.event.pull_request.head.repo.fork 83 | steps: 84 | - uses: actions/checkout@v4 85 | 86 | - name: Install Rye 87 | run: | 88 | curl -sSf https://rye.astral.sh/get | bash 89 | echo "$HOME/.rye/shims" >> $GITHUB_PATH 90 | env: 91 | RYE_VERSION: '0.44.0' 92 | RYE_INSTALL_OPTION: '--yes' 93 | 94 | - name: Bootstrap 95 | run: ./scripts/bootstrap 96 | 97 | - name: Run tests 98 | run: ./scripts/test 99 | -------------------------------------------------------------------------------- /src/lumaai/_utils/_sync.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import sys 4 | import asyncio 5 | import functools 6 | import contextvars 7 | from typing import Any, TypeVar, Callable, Awaitable 8 | from typing_extensions import ParamSpec 9 | 10 | import anyio 11 | import sniffio 12 | import anyio.to_thread 13 | 14 | T_Retval = TypeVar("T_Retval") 15 | T_ParamSpec = ParamSpec("T_ParamSpec") 16 | 17 | 18 | if sys.version_info >= (3, 9): 19 | _asyncio_to_thread = asyncio.to_thread 20 | else: 21 | # backport of https://docs.python.org/3/library/asyncio-task.html#asyncio.to_thread 22 | # for Python 3.8 support 23 | async def _asyncio_to_thread( 24 | func: Callable[T_ParamSpec, T_Retval], /, *args: T_ParamSpec.args, **kwargs: T_ParamSpec.kwargs 25 | ) -> Any: 26 | """Asynchronously run function *func* in a separate thread. 27 | 28 | Any *args and **kwargs supplied for this function are directly passed 29 | to *func*. Also, the current :class:`contextvars.Context` is propagated, 30 | allowing context variables from the main thread to be accessed in the 31 | separate thread. 32 | 33 | Returns a coroutine that can be awaited to get the eventual result of *func*. 34 | """ 35 | loop = asyncio.events.get_running_loop() 36 | ctx = contextvars.copy_context() 37 | func_call = functools.partial(ctx.run, func, *args, **kwargs) 38 | return await loop.run_in_executor(None, func_call) 39 | 40 | 41 | async def to_thread( 42 | func: Callable[T_ParamSpec, T_Retval], /, *args: T_ParamSpec.args, **kwargs: T_ParamSpec.kwargs 43 | ) -> T_Retval: 44 | if sniffio.current_async_library() == "asyncio": 45 | return await _asyncio_to_thread(func, *args, **kwargs) 46 | 47 | return await anyio.to_thread.run_sync( 48 | functools.partial(func, *args, **kwargs), 49 | ) 50 | 51 | 52 | # inspired by `asyncer`, https://github.com/tiangolo/asyncer 53 | def asyncify(function: Callable[T_ParamSpec, T_Retval]) -> Callable[T_ParamSpec, Awaitable[T_Retval]]: 54 | """ 55 | Take a blocking function and create an async one that receives the same 56 | positional and keyword arguments. For python version 3.9 and above, it uses 57 | asyncio.to_thread to run the function in a separate thread. For python version 58 | 3.8, it uses locally defined copy of the asyncio.to_thread function which was 59 | introduced in python 3.9. 60 | 61 | Usage: 62 | 63 | ```python 64 | def blocking_func(arg1, arg2, kwarg1=None): 65 | # blocking code 66 | return result 67 | 68 | 69 | result = asyncify(blocking_function)(arg1, arg2, kwarg1=value1) 70 | ``` 71 | 72 | ## Arguments 73 | 74 | `function`: a blocking regular callable (e.g. a function) 75 | 76 | ## Return 77 | 78 | An async function that takes the same positional and keyword arguments as the 79 | original one, that when called runs the same original function in a thread worker 80 | and returns the result. 81 | """ 82 | 83 | async def wrapper(*args: T_ParamSpec.args, **kwargs: T_ParamSpec.kwargs) -> T_Retval: 84 | return await to_thread(function, *args, **kwargs) 85 | 86 | return wrapper 87 | -------------------------------------------------------------------------------- /requirements-dev.lock: -------------------------------------------------------------------------------- 1 | # generated by rye 2 | # use `rye lock` or `rye sync` to update this lockfile 3 | # 4 | # last locked with the following flags: 5 | # pre: false 6 | # features: [] 7 | # all-features: true 8 | # with-sources: false 9 | # generate-hashes: false 10 | # universal: false 11 | 12 | -e file:. 13 | aiohappyeyeballs==2.6.1 14 | # via aiohttp 15 | aiohttp==3.12.8 16 | # via httpx-aiohttp 17 | # via lumaai 18 | aiosignal==1.3.2 19 | # via aiohttp 20 | annotated-types==0.6.0 21 | # via pydantic 22 | anyio==4.4.0 23 | # via httpx 24 | # via lumaai 25 | argcomplete==3.1.2 26 | # via nox 27 | async-timeout==5.0.1 28 | # via aiohttp 29 | attrs==25.3.0 30 | # via aiohttp 31 | certifi==2023.7.22 32 | # via httpcore 33 | # via httpx 34 | colorlog==6.7.0 35 | # via nox 36 | dirty-equals==0.6.0 37 | distlib==0.3.7 38 | # via virtualenv 39 | distro==1.8.0 40 | # via lumaai 41 | exceptiongroup==1.2.2 42 | # via anyio 43 | # via pytest 44 | execnet==2.1.1 45 | # via pytest-xdist 46 | filelock==3.12.4 47 | # via virtualenv 48 | frozenlist==1.6.2 49 | # via aiohttp 50 | # via aiosignal 51 | h11==0.16.0 52 | # via httpcore 53 | httpcore==1.0.9 54 | # via httpx 55 | httpx==0.28.1 56 | # via httpx-aiohttp 57 | # via lumaai 58 | # via respx 59 | httpx-aiohttp==0.1.9 60 | # via lumaai 61 | idna==3.4 62 | # via anyio 63 | # via httpx 64 | # via yarl 65 | importlib-metadata==7.0.0 66 | iniconfig==2.0.0 67 | # via pytest 68 | markdown-it-py==3.0.0 69 | # via rich 70 | mdurl==0.1.2 71 | # via markdown-it-py 72 | multidict==6.4.4 73 | # via aiohttp 74 | # via yarl 75 | mypy==1.14.1 76 | mypy-extensions==1.0.0 77 | # via mypy 78 | nodeenv==1.8.0 79 | # via pyright 80 | nox==2023.4.22 81 | packaging==23.2 82 | # via nox 83 | # via pytest 84 | platformdirs==3.11.0 85 | # via virtualenv 86 | pluggy==1.5.0 87 | # via pytest 88 | propcache==0.3.1 89 | # via aiohttp 90 | # via yarl 91 | pydantic==2.11.9 92 | # via lumaai 93 | pydantic-core==2.33.2 94 | # via pydantic 95 | pygments==2.18.0 96 | # via rich 97 | pyright==1.1.399 98 | pytest==8.3.3 99 | # via pytest-asyncio 100 | # via pytest-xdist 101 | pytest-asyncio==0.24.0 102 | pytest-xdist==3.7.0 103 | python-dateutil==2.8.2 104 | # via time-machine 105 | pytz==2023.3.post1 106 | # via dirty-equals 107 | respx==0.22.0 108 | rich==13.7.1 109 | ruff==0.9.4 110 | setuptools==68.2.2 111 | # via nodeenv 112 | six==1.16.0 113 | # via python-dateutil 114 | sniffio==1.3.0 115 | # via anyio 116 | # via lumaai 117 | time-machine==2.9.0 118 | tomli==2.0.2 119 | # via mypy 120 | # via pytest 121 | typing-extensions==4.12.2 122 | # via anyio 123 | # via lumaai 124 | # via multidict 125 | # via mypy 126 | # via pydantic 127 | # via pydantic-core 128 | # via pyright 129 | # via typing-inspection 130 | typing-inspection==0.4.1 131 | # via pydantic 132 | virtualenv==20.24.5 133 | # via nox 134 | yarl==1.20.0 135 | # via aiohttp 136 | zipp==3.17.0 137 | # via importlib-metadata 138 | -------------------------------------------------------------------------------- /tests/api_resources/generations/test_concepts.py: -------------------------------------------------------------------------------- 1 | # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. 2 | 3 | from __future__ import annotations 4 | 5 | import os 6 | from typing import Any, cast 7 | 8 | import pytest 9 | 10 | from lumaai import LumaAI, AsyncLumaAI 11 | from tests.utils import assert_matches_type 12 | from lumaai.types.generations import ConceptListResponse 13 | 14 | base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") 15 | 16 | 17 | class TestConcepts: 18 | parametrize = pytest.mark.parametrize("client", [False, True], indirect=True, ids=["loose", "strict"]) 19 | 20 | @parametrize 21 | def test_method_list(self, client: LumaAI) -> None: 22 | concept = client.generations.concepts.list() 23 | assert_matches_type(ConceptListResponse, concept, path=["response"]) 24 | 25 | @parametrize 26 | def test_raw_response_list(self, client: LumaAI) -> None: 27 | response = client.generations.concepts.with_raw_response.list() 28 | 29 | assert response.is_closed is True 30 | assert response.http_request.headers.get("X-Stainless-Lang") == "python" 31 | concept = response.parse() 32 | assert_matches_type(ConceptListResponse, concept, path=["response"]) 33 | 34 | @parametrize 35 | def test_streaming_response_list(self, client: LumaAI) -> None: 36 | with client.generations.concepts.with_streaming_response.list() as response: 37 | assert not response.is_closed 38 | assert response.http_request.headers.get("X-Stainless-Lang") == "python" 39 | 40 | concept = response.parse() 41 | assert_matches_type(ConceptListResponse, concept, path=["response"]) 42 | 43 | assert cast(Any, response.is_closed) is True 44 | 45 | 46 | class TestAsyncConcepts: 47 | parametrize = pytest.mark.parametrize( 48 | "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] 49 | ) 50 | 51 | @parametrize 52 | async def test_method_list(self, async_client: AsyncLumaAI) -> None: 53 | concept = await async_client.generations.concepts.list() 54 | assert_matches_type(ConceptListResponse, concept, path=["response"]) 55 | 56 | @parametrize 57 | async def test_raw_response_list(self, async_client: AsyncLumaAI) -> None: 58 | response = await async_client.generations.concepts.with_raw_response.list() 59 | 60 | assert response.is_closed is True 61 | assert response.http_request.headers.get("X-Stainless-Lang") == "python" 62 | concept = await response.parse() 63 | assert_matches_type(ConceptListResponse, concept, path=["response"]) 64 | 65 | @parametrize 66 | async def test_streaming_response_list(self, async_client: AsyncLumaAI) -> None: 67 | async with async_client.generations.concepts.with_streaming_response.list() as response: 68 | assert not response.is_closed 69 | assert response.http_request.headers.get("X-Stainless-Lang") == "python" 70 | 71 | concept = await response.parse() 72 | assert_matches_type(ConceptListResponse, concept, path=["response"]) 73 | 74 | assert cast(Any, response.is_closed) is True 75 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. 2 | 3 | from __future__ import annotations 4 | 5 | import os 6 | import logging 7 | from typing import TYPE_CHECKING, Iterator, AsyncIterator 8 | 9 | import httpx 10 | import pytest 11 | from pytest_asyncio import is_async_test 12 | 13 | from lumaai import LumaAI, AsyncLumaAI, DefaultAioHttpClient 14 | from lumaai._utils import is_dict 15 | 16 | if TYPE_CHECKING: 17 | from _pytest.fixtures import FixtureRequest # pyright: ignore[reportPrivateImportUsage] 18 | 19 | pytest.register_assert_rewrite("tests.utils") 20 | 21 | logging.getLogger("lumaai").setLevel(logging.DEBUG) 22 | 23 | 24 | # automatically add `pytest.mark.asyncio()` to all of our async tests 25 | # so we don't have to add that boilerplate everywhere 26 | def pytest_collection_modifyitems(items: list[pytest.Function]) -> None: 27 | pytest_asyncio_tests = (item for item in items if is_async_test(item)) 28 | session_scope_marker = pytest.mark.asyncio(loop_scope="session") 29 | for async_test in pytest_asyncio_tests: 30 | async_test.add_marker(session_scope_marker, append=False) 31 | 32 | # We skip tests that use both the aiohttp client and respx_mock as respx_mock 33 | # doesn't support custom transports. 34 | for item in items: 35 | if "async_client" not in item.fixturenames or "respx_mock" not in item.fixturenames: 36 | continue 37 | 38 | if not hasattr(item, "callspec"): 39 | continue 40 | 41 | async_client_param = item.callspec.params.get("async_client") 42 | if is_dict(async_client_param) and async_client_param.get("http_client") == "aiohttp": 43 | item.add_marker(pytest.mark.skip(reason="aiohttp client is not compatible with respx_mock")) 44 | 45 | 46 | base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") 47 | 48 | auth_token = "My Auth Token" 49 | 50 | 51 | @pytest.fixture(scope="session") 52 | def client(request: FixtureRequest) -> Iterator[LumaAI]: 53 | strict = getattr(request, "param", True) 54 | if not isinstance(strict, bool): 55 | raise TypeError(f"Unexpected fixture parameter type {type(strict)}, expected {bool}") 56 | 57 | with LumaAI(base_url=base_url, auth_token=auth_token, _strict_response_validation=strict) as client: 58 | yield client 59 | 60 | 61 | @pytest.fixture(scope="session") 62 | async def async_client(request: FixtureRequest) -> AsyncIterator[AsyncLumaAI]: 63 | param = getattr(request, "param", True) 64 | 65 | # defaults 66 | strict = True 67 | http_client: None | httpx.AsyncClient = None 68 | 69 | if isinstance(param, bool): 70 | strict = param 71 | elif is_dict(param): 72 | strict = param.get("strict", True) 73 | assert isinstance(strict, bool) 74 | 75 | http_client_type = param.get("http_client", "httpx") 76 | if http_client_type == "aiohttp": 77 | http_client = DefaultAioHttpClient() 78 | else: 79 | raise TypeError(f"Unexpected fixture parameter type {type(param)}, expected bool or dict") 80 | 81 | async with AsyncLumaAI( 82 | base_url=base_url, auth_token=auth_token, _strict_response_validation=strict, http_client=http_client 83 | ) as client: 84 | yield client 85 | -------------------------------------------------------------------------------- /tests/test_qs.py: -------------------------------------------------------------------------------- 1 | from typing import Any, cast 2 | from functools import partial 3 | from urllib.parse import unquote 4 | 5 | import pytest 6 | 7 | from lumaai._qs import Querystring, stringify 8 | 9 | 10 | def test_empty() -> None: 11 | assert stringify({}) == "" 12 | assert stringify({"a": {}}) == "" 13 | assert stringify({"a": {"b": {"c": {}}}}) == "" 14 | 15 | 16 | def test_basic() -> None: 17 | assert stringify({"a": 1}) == "a=1" 18 | assert stringify({"a": "b"}) == "a=b" 19 | assert stringify({"a": True}) == "a=true" 20 | assert stringify({"a": False}) == "a=false" 21 | assert stringify({"a": 1.23456}) == "a=1.23456" 22 | assert stringify({"a": None}) == "" 23 | 24 | 25 | @pytest.mark.parametrize("method", ["class", "function"]) 26 | def test_nested_dotted(method: str) -> None: 27 | if method == "class": 28 | serialise = Querystring(nested_format="dots").stringify 29 | else: 30 | serialise = partial(stringify, nested_format="dots") 31 | 32 | assert unquote(serialise({"a": {"b": "c"}})) == "a.b=c" 33 | assert unquote(serialise({"a": {"b": "c", "d": "e", "f": "g"}})) == "a.b=c&a.d=e&a.f=g" 34 | assert unquote(serialise({"a": {"b": {"c": {"d": "e"}}}})) == "a.b.c.d=e" 35 | assert unquote(serialise({"a": {"b": True}})) == "a.b=true" 36 | 37 | 38 | def test_nested_brackets() -> None: 39 | assert unquote(stringify({"a": {"b": "c"}})) == "a[b]=c" 40 | assert unquote(stringify({"a": {"b": "c", "d": "e", "f": "g"}})) == "a[b]=c&a[d]=e&a[f]=g" 41 | assert unquote(stringify({"a": {"b": {"c": {"d": "e"}}}})) == "a[b][c][d]=e" 42 | assert unquote(stringify({"a": {"b": True}})) == "a[b]=true" 43 | 44 | 45 | @pytest.mark.parametrize("method", ["class", "function"]) 46 | def test_array_comma(method: str) -> None: 47 | if method == "class": 48 | serialise = Querystring(array_format="comma").stringify 49 | else: 50 | serialise = partial(stringify, array_format="comma") 51 | 52 | assert unquote(serialise({"in": ["foo", "bar"]})) == "in=foo,bar" 53 | assert unquote(serialise({"a": {"b": [True, False]}})) == "a[b]=true,false" 54 | assert unquote(serialise({"a": {"b": [True, False, None, True]}})) == "a[b]=true,false,true" 55 | 56 | 57 | def test_array_repeat() -> None: 58 | assert unquote(stringify({"in": ["foo", "bar"]})) == "in=foo&in=bar" 59 | assert unquote(stringify({"a": {"b": [True, False]}})) == "a[b]=true&a[b]=false" 60 | assert unquote(stringify({"a": {"b": [True, False, None, True]}})) == "a[b]=true&a[b]=false&a[b]=true" 61 | assert unquote(stringify({"in": ["foo", {"b": {"c": ["d", "e"]}}]})) == "in=foo&in[b][c]=d&in[b][c]=e" 62 | 63 | 64 | @pytest.mark.parametrize("method", ["class", "function"]) 65 | def test_array_brackets(method: str) -> None: 66 | if method == "class": 67 | serialise = Querystring(array_format="brackets").stringify 68 | else: 69 | serialise = partial(stringify, array_format="brackets") 70 | 71 | assert unquote(serialise({"in": ["foo", "bar"]})) == "in[]=foo&in[]=bar" 72 | assert unquote(serialise({"a": {"b": [True, False]}})) == "a[b][]=true&a[b][]=false" 73 | assert unquote(serialise({"a": {"b": [True, False, None, True]}})) == "a[b][]=true&a[b][]=false&a[b][]=true" 74 | 75 | 76 | def test_unknown_array_format() -> None: 77 | with pytest.raises(NotImplementedError, match="Unknown array_format value: foo, choose from comma, repeat"): 78 | stringify({"a": ["foo", "bar"]}, array_format=cast(Any, "foo")) 79 | -------------------------------------------------------------------------------- /tests/test_required_args.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import pytest 4 | 5 | from lumaai._utils import required_args 6 | 7 | 8 | def test_too_many_positional_params() -> None: 9 | @required_args(["a"]) 10 | def foo(a: str | None = None) -> str | None: 11 | return a 12 | 13 | with pytest.raises(TypeError, match=r"foo\(\) takes 1 argument\(s\) but 2 were given"): 14 | foo("a", "b") # type: ignore 15 | 16 | 17 | def test_positional_param() -> None: 18 | @required_args(["a"]) 19 | def foo(a: str | None = None) -> str | None: 20 | return a 21 | 22 | assert foo("a") == "a" 23 | assert foo(None) is None 24 | assert foo(a="b") == "b" 25 | 26 | with pytest.raises(TypeError, match="Missing required argument: 'a'"): 27 | foo() 28 | 29 | 30 | def test_keyword_only_param() -> None: 31 | @required_args(["a"]) 32 | def foo(*, a: str | None = None) -> str | None: 33 | return a 34 | 35 | assert foo(a="a") == "a" 36 | assert foo(a=None) is None 37 | assert foo(a="b") == "b" 38 | 39 | with pytest.raises(TypeError, match="Missing required argument: 'a'"): 40 | foo() 41 | 42 | 43 | def test_multiple_params() -> None: 44 | @required_args(["a", "b", "c"]) 45 | def foo(a: str = "", *, b: str = "", c: str = "") -> str | None: 46 | return f"{a} {b} {c}" 47 | 48 | assert foo(a="a", b="b", c="c") == "a b c" 49 | 50 | error_message = r"Missing required arguments.*" 51 | 52 | with pytest.raises(TypeError, match=error_message): 53 | foo() 54 | 55 | with pytest.raises(TypeError, match=error_message): 56 | foo(a="a") 57 | 58 | with pytest.raises(TypeError, match=error_message): 59 | foo(b="b") 60 | 61 | with pytest.raises(TypeError, match=error_message): 62 | foo(c="c") 63 | 64 | with pytest.raises(TypeError, match=r"Missing required argument: 'a'"): 65 | foo(b="a", c="c") 66 | 67 | with pytest.raises(TypeError, match=r"Missing required argument: 'b'"): 68 | foo("a", c="c") 69 | 70 | 71 | def test_multiple_variants() -> None: 72 | @required_args(["a"], ["b"]) 73 | def foo(*, a: str | None = None, b: str | None = None) -> str | None: 74 | return a if a is not None else b 75 | 76 | assert foo(a="foo") == "foo" 77 | assert foo(b="bar") == "bar" 78 | assert foo(a=None) is None 79 | assert foo(b=None) is None 80 | 81 | # TODO: this error message could probably be improved 82 | with pytest.raises( 83 | TypeError, 84 | match=r"Missing required arguments; Expected either \('a'\) or \('b'\) arguments to be given", 85 | ): 86 | foo() 87 | 88 | 89 | def test_multiple_params_multiple_variants() -> None: 90 | @required_args(["a", "b"], ["c"]) 91 | def foo(*, a: str | None = None, b: str | None = None, c: str | None = None) -> str | None: 92 | if a is not None: 93 | return a 94 | if b is not None: 95 | return b 96 | return c 97 | 98 | error_message = r"Missing required arguments; Expected either \('a' and 'b'\) or \('c'\) arguments to be given" 99 | 100 | with pytest.raises(TypeError, match=error_message): 101 | foo(a="foo") 102 | 103 | with pytest.raises(TypeError, match=error_message): 104 | foo(b="bar") 105 | 106 | with pytest.raises(TypeError, match=error_message): 107 | foo() 108 | 109 | assert foo(a=None, b="bar") == "bar" 110 | assert foo(c=None) is None 111 | assert foo(c="foo") == "foo" 112 | -------------------------------------------------------------------------------- /src/lumaai/_exceptions.py: -------------------------------------------------------------------------------- 1 | # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. 2 | 3 | from __future__ import annotations 4 | 5 | from typing_extensions import Literal 6 | 7 | import httpx 8 | 9 | __all__ = [ 10 | "BadRequestError", 11 | "AuthenticationError", 12 | "PermissionDeniedError", 13 | "NotFoundError", 14 | "ConflictError", 15 | "UnprocessableEntityError", 16 | "RateLimitError", 17 | "InternalServerError", 18 | ] 19 | 20 | 21 | class LumaAIError(Exception): 22 | pass 23 | 24 | 25 | class APIError(LumaAIError): 26 | message: str 27 | request: httpx.Request 28 | 29 | body: object | None 30 | """The API response body. 31 | 32 | If the API responded with a valid JSON structure then this property will be the 33 | decoded result. 34 | 35 | If it isn't a valid JSON structure then this will be the raw response. 36 | 37 | If there was no response associated with this error then it will be `None`. 38 | """ 39 | 40 | def __init__(self, message: str, request: httpx.Request, *, body: object | None) -> None: # noqa: ARG002 41 | super().__init__(message) 42 | self.request = request 43 | self.message = message 44 | self.body = body 45 | 46 | 47 | class APIResponseValidationError(APIError): 48 | response: httpx.Response 49 | status_code: int 50 | 51 | def __init__(self, response: httpx.Response, body: object | None, *, message: str | None = None) -> None: 52 | super().__init__(message or "Data returned by API invalid for expected schema.", response.request, body=body) 53 | self.response = response 54 | self.status_code = response.status_code 55 | 56 | 57 | class APIStatusError(APIError): 58 | """Raised when an API response has a status code of 4xx or 5xx.""" 59 | 60 | response: httpx.Response 61 | status_code: int 62 | 63 | def __init__(self, message: str, *, response: httpx.Response, body: object | None) -> None: 64 | super().__init__(message, response.request, body=body) 65 | self.response = response 66 | self.status_code = response.status_code 67 | 68 | 69 | class APIConnectionError(APIError): 70 | def __init__(self, *, message: str = "Connection error.", request: httpx.Request) -> None: 71 | super().__init__(message, request, body=None) 72 | 73 | 74 | class APITimeoutError(APIConnectionError): 75 | def __init__(self, request: httpx.Request) -> None: 76 | super().__init__(message="Request timed out.", request=request) 77 | 78 | 79 | class BadRequestError(APIStatusError): 80 | status_code: Literal[400] = 400 # pyright: ignore[reportIncompatibleVariableOverride] 81 | 82 | 83 | class AuthenticationError(APIStatusError): 84 | status_code: Literal[401] = 401 # pyright: ignore[reportIncompatibleVariableOverride] 85 | 86 | 87 | class PermissionDeniedError(APIStatusError): 88 | status_code: Literal[403] = 403 # pyright: ignore[reportIncompatibleVariableOverride] 89 | 90 | 91 | class NotFoundError(APIStatusError): 92 | status_code: Literal[404] = 404 # pyright: ignore[reportIncompatibleVariableOverride] 93 | 94 | 95 | class ConflictError(APIStatusError): 96 | status_code: Literal[409] = 409 # pyright: ignore[reportIncompatibleVariableOverride] 97 | 98 | 99 | class UnprocessableEntityError(APIStatusError): 100 | status_code: Literal[422] = 422 # pyright: ignore[reportIncompatibleVariableOverride] 101 | 102 | 103 | class RateLimitError(APIStatusError): 104 | status_code: Literal[429] = 429 # pyright: ignore[reportIncompatibleVariableOverride] 105 | 106 | 107 | class InternalServerError(APIStatusError): 108 | pass 109 | -------------------------------------------------------------------------------- /api.md: -------------------------------------------------------------------------------- 1 | # Generations 2 | 3 | Types: 4 | 5 | ```python 6 | from lumaai.types import Generation, GenerationListResponse 7 | ``` 8 | 9 | Methods: 10 | 11 | - client.generations.create(\*\*params) -> Generation 12 | - client.generations.list(\*\*params) -> GenerationListResponse 13 | - client.generations.delete(id) -> None 14 | - client.generations.audio(id, \*\*params) -> Generation 15 | - client.generations.get(id) -> Generation 16 | - client.generations.upscale(id, \*\*params) -> Generation 17 | 18 | ## Concepts 19 | 20 | Types: 21 | 22 | ```python 23 | from lumaai.types.generations import ConceptListResponse 24 | ``` 25 | 26 | Methods: 27 | 28 | - client.generations.concepts.list() -> ConceptListResponse 29 | 30 | ## Image 31 | 32 | Methods: 33 | 34 | - client.generations.image.create(\*\*params) -> Generation 35 | - client.generations.image.reframe(\*\*params) -> Generation 36 | 37 | ## Video 38 | 39 | Methods: 40 | 41 | - client.generations.video.create(\*\*params) -> Generation 42 | - client.generations.video.modify(\*\*params) -> Generation 43 | - client.generations.video.reframe(\*\*params) -> Generation 44 | 45 | # Ping 46 | 47 | Types: 48 | 49 | ```python 50 | from lumaai.types import PingCheckResponse 51 | ``` 52 | 53 | Methods: 54 | 55 | - client.ping.check() -> PingCheckResponse 56 | 57 | # Credits 58 | 59 | Types: 60 | 61 | ```python 62 | from lumaai.types import CreditGetResponse 63 | ``` 64 | 65 | Methods: 66 | 67 | - client.credits.get() -> CreditGetResponse 68 | -------------------------------------------------------------------------------- /src/lumaai/_files.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import io 4 | import os 5 | import pathlib 6 | from typing import overload 7 | from typing_extensions import TypeGuard 8 | 9 | import anyio 10 | 11 | from ._types import ( 12 | FileTypes, 13 | FileContent, 14 | RequestFiles, 15 | HttpxFileTypes, 16 | Base64FileInput, 17 | HttpxFileContent, 18 | HttpxRequestFiles, 19 | ) 20 | from ._utils import is_tuple_t, is_mapping_t, is_sequence_t 21 | 22 | 23 | def is_base64_file_input(obj: object) -> TypeGuard[Base64FileInput]: 24 | return isinstance(obj, io.IOBase) or isinstance(obj, os.PathLike) 25 | 26 | 27 | def is_file_content(obj: object) -> TypeGuard[FileContent]: 28 | return ( 29 | isinstance(obj, bytes) or isinstance(obj, tuple) or isinstance(obj, io.IOBase) or isinstance(obj, os.PathLike) 30 | ) 31 | 32 | 33 | def assert_is_file_content(obj: object, *, key: str | None = None) -> None: 34 | if not is_file_content(obj): 35 | prefix = f"Expected entry at `{key}`" if key is not None else f"Expected file input `{obj!r}`" 36 | raise RuntimeError( 37 | f"{prefix} to be bytes, an io.IOBase instance, PathLike or a tuple but received {type(obj)} instead." 38 | ) from None 39 | 40 | 41 | @overload 42 | def to_httpx_files(files: None) -> None: ... 43 | 44 | 45 | @overload 46 | def to_httpx_files(files: RequestFiles) -> HttpxRequestFiles: ... 47 | 48 | 49 | def to_httpx_files(files: RequestFiles | None) -> HttpxRequestFiles | None: 50 | if files is None: 51 | return None 52 | 53 | if is_mapping_t(files): 54 | files = {key: _transform_file(file) for key, file in files.items()} 55 | elif is_sequence_t(files): 56 | files = [(key, _transform_file(file)) for key, file in files] 57 | else: 58 | raise TypeError(f"Unexpected file type input {type(files)}, expected mapping or sequence") 59 | 60 | return files 61 | 62 | 63 | def _transform_file(file: FileTypes) -> HttpxFileTypes: 64 | if is_file_content(file): 65 | if isinstance(file, os.PathLike): 66 | path = pathlib.Path(file) 67 | return (path.name, path.read_bytes()) 68 | 69 | return file 70 | 71 | if is_tuple_t(file): 72 | return (file[0], read_file_content(file[1]), *file[2:]) 73 | 74 | raise TypeError(f"Expected file types input to be a FileContent type or to be a tuple") 75 | 76 | 77 | def read_file_content(file: FileContent) -> HttpxFileContent: 78 | if isinstance(file, os.PathLike): 79 | return pathlib.Path(file).read_bytes() 80 | return file 81 | 82 | 83 | @overload 84 | async def async_to_httpx_files(files: None) -> None: ... 85 | 86 | 87 | @overload 88 | async def async_to_httpx_files(files: RequestFiles) -> HttpxRequestFiles: ... 89 | 90 | 91 | async def async_to_httpx_files(files: RequestFiles | None) -> HttpxRequestFiles | None: 92 | if files is None: 93 | return None 94 | 95 | if is_mapping_t(files): 96 | files = {key: await _async_transform_file(file) for key, file in files.items()} 97 | elif is_sequence_t(files): 98 | files = [(key, await _async_transform_file(file)) for key, file in files] 99 | else: 100 | raise TypeError("Unexpected file type input {type(files)}, expected mapping or sequence") 101 | 102 | return files 103 | 104 | 105 | async def _async_transform_file(file: FileTypes) -> HttpxFileTypes: 106 | if is_file_content(file): 107 | if isinstance(file, os.PathLike): 108 | path = anyio.Path(file) 109 | return (path.name, await path.read_bytes()) 110 | 111 | return file 112 | 113 | if is_tuple_t(file): 114 | return (file[0], await async_read_file_content(file[1]), *file[2:]) 115 | 116 | raise TypeError(f"Expected file types input to be a FileContent type or to be a tuple") 117 | 118 | 119 | async def async_read_file_content(file: FileContent) -> HttpxFileContent: 120 | if isinstance(file, os.PathLike): 121 | return await anyio.Path(file).read_bytes() 122 | 123 | return file 124 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Setting up the environment 2 | 3 | ### With Rye 4 | 5 | We use [Rye](https://rye.astral.sh/) to manage dependencies because it will automatically provision a Python environment with the expected Python version. To set it up, run: 6 | 7 | ```sh 8 | $ ./scripts/bootstrap 9 | ``` 10 | 11 | Or [install Rye manually](https://rye.astral.sh/guide/installation/) and run: 12 | 13 | ```sh 14 | $ rye sync --all-features 15 | ``` 16 | 17 | You can then run scripts using `rye run python script.py` or by activating the virtual environment: 18 | 19 | ```sh 20 | # Activate the virtual environment - https://docs.python.org/3/library/venv.html#how-venvs-work 21 | $ source .venv/bin/activate 22 | 23 | # now you can omit the `rye run` prefix 24 | $ python script.py 25 | ``` 26 | 27 | ### Without Rye 28 | 29 | Alternatively if you don't want to install `Rye`, you can stick with the standard `pip` setup by ensuring you have the Python version specified in `.python-version`, create a virtual environment however you desire and then install dependencies using this command: 30 | 31 | ```sh 32 | $ pip install -r requirements-dev.lock 33 | ``` 34 | 35 | ## Modifying/Adding code 36 | 37 | Most of the SDK is generated code. Modifications to code will be persisted between generations, but may 38 | result in merge conflicts between manual patches and changes from the generator. The generator will never 39 | modify the contents of the `src/lumaai/lib/` and `examples/` directories. 40 | 41 | ## Adding and running examples 42 | 43 | All files in the `examples/` directory are not modified by the generator and can be freely edited or added to. 44 | 45 | ```py 46 | # add an example to examples/.py 47 | 48 | #!/usr/bin/env -S rye run python 49 | … 50 | ``` 51 | 52 | ```sh 53 | $ chmod +x examples/.py 54 | # run the example against your api 55 | $ ./examples/.py 56 | ``` 57 | 58 | ## Using the repository from source 59 | 60 | If you’d like to use the repository from source, you can either install from git or link to a cloned repository: 61 | 62 | To install via git: 63 | 64 | ```sh 65 | $ pip install git+ssh://git@github.com/lumalabs/lumaai-python.git 66 | ``` 67 | 68 | Alternatively, you can build from source and install the wheel file: 69 | 70 | Building this package will create two files in the `dist/` directory, a `.tar.gz` containing the source files and a `.whl` that can be used to install the package efficiently. 71 | 72 | To create a distributable version of the library, all you have to do is run this command: 73 | 74 | ```sh 75 | $ rye build 76 | # or 77 | $ python -m build 78 | ``` 79 | 80 | Then to install: 81 | 82 | ```sh 83 | $ pip install ./path-to-wheel-file.whl 84 | ``` 85 | 86 | ## Running tests 87 | 88 | Most tests require you to [set up a mock server](https://github.com/stoplightio/prism) against the OpenAPI spec to run the tests. 89 | 90 | ```sh 91 | # you will need npm installed 92 | $ npx prism mock path/to/your/openapi.yml 93 | ``` 94 | 95 | ```sh 96 | $ ./scripts/test 97 | ``` 98 | 99 | ## Linting and formatting 100 | 101 | This repository uses [ruff](https://github.com/astral-sh/ruff) and 102 | [black](https://github.com/psf/black) to format the code in the repository. 103 | 104 | To lint: 105 | 106 | ```sh 107 | $ ./scripts/lint 108 | ``` 109 | 110 | To format and fix all ruff issues automatically: 111 | 112 | ```sh 113 | $ ./scripts/format 114 | ``` 115 | 116 | ## Publishing and releases 117 | 118 | Changes made to this repository via the automated release PR pipeline should publish to PyPI automatically. If 119 | the changes aren't made through the automated pipeline, you may want to make releases manually. 120 | 121 | ### Publish with a GitHub workflow 122 | 123 | You can release to package managers by using [the `Publish PyPI` GitHub action](https://www.github.com/lumalabs/lumaai-python/actions/workflows/publish-pypi.yml). This requires a setup organization or repository secret to be set up. 124 | 125 | ### Publish manually 126 | 127 | If you need to manually release a package, you can run the `bin/publish-pypi` script with a `PYPI_TOKEN` set on 128 | the environment. 129 | -------------------------------------------------------------------------------- /src/lumaai/_utils/_datetime_parse.py: -------------------------------------------------------------------------------- 1 | """ 2 | This file contains code from https://github.com/pydantic/pydantic/blob/main/pydantic/v1/datetime_parse.py 3 | without the Pydantic v1 specific errors. 4 | """ 5 | 6 | from __future__ import annotations 7 | 8 | import re 9 | from typing import Dict, Union, Optional 10 | from datetime import date, datetime, timezone, timedelta 11 | 12 | from .._types import StrBytesIntFloat 13 | 14 | date_expr = r"(?P\d{4})-(?P\d{1,2})-(?P\d{1,2})" 15 | time_expr = ( 16 | r"(?P\d{1,2}):(?P\d{1,2})" 17 | r"(?::(?P\d{1,2})(?:\.(?P\d{1,6})\d{0,6})?)?" 18 | r"(?PZ|[+-]\d{2}(?::?\d{2})?)?$" 19 | ) 20 | 21 | date_re = re.compile(f"{date_expr}$") 22 | datetime_re = re.compile(f"{date_expr}[T ]{time_expr}") 23 | 24 | 25 | EPOCH = datetime(1970, 1, 1) 26 | # if greater than this, the number is in ms, if less than or equal it's in seconds 27 | # (in seconds this is 11th October 2603, in ms it's 20th August 1970) 28 | MS_WATERSHED = int(2e10) 29 | # slightly more than datetime.max in ns - (datetime.max - EPOCH).total_seconds() * 1e9 30 | MAX_NUMBER = int(3e20) 31 | 32 | 33 | def _get_numeric(value: StrBytesIntFloat, native_expected_type: str) -> Union[None, int, float]: 34 | if isinstance(value, (int, float)): 35 | return value 36 | try: 37 | return float(value) 38 | except ValueError: 39 | return None 40 | except TypeError: 41 | raise TypeError(f"invalid type; expected {native_expected_type}, string, bytes, int or float") from None 42 | 43 | 44 | def _from_unix_seconds(seconds: Union[int, float]) -> datetime: 45 | if seconds > MAX_NUMBER: 46 | return datetime.max 47 | elif seconds < -MAX_NUMBER: 48 | return datetime.min 49 | 50 | while abs(seconds) > MS_WATERSHED: 51 | seconds /= 1000 52 | dt = EPOCH + timedelta(seconds=seconds) 53 | return dt.replace(tzinfo=timezone.utc) 54 | 55 | 56 | def _parse_timezone(value: Optional[str]) -> Union[None, int, timezone]: 57 | if value == "Z": 58 | return timezone.utc 59 | elif value is not None: 60 | offset_mins = int(value[-2:]) if len(value) > 3 else 0 61 | offset = 60 * int(value[1:3]) + offset_mins 62 | if value[0] == "-": 63 | offset = -offset 64 | return timezone(timedelta(minutes=offset)) 65 | else: 66 | return None 67 | 68 | 69 | def parse_datetime(value: Union[datetime, StrBytesIntFloat]) -> datetime: 70 | """ 71 | Parse a datetime/int/float/string and return a datetime.datetime. 72 | 73 | This function supports time zone offsets. When the input contains one, 74 | the output uses a timezone with a fixed offset from UTC. 75 | 76 | Raise ValueError if the input is well formatted but not a valid datetime. 77 | Raise ValueError if the input isn't well formatted. 78 | """ 79 | if isinstance(value, datetime): 80 | return value 81 | 82 | number = _get_numeric(value, "datetime") 83 | if number is not None: 84 | return _from_unix_seconds(number) 85 | 86 | if isinstance(value, bytes): 87 | value = value.decode() 88 | 89 | assert not isinstance(value, (float, int)) 90 | 91 | match = datetime_re.match(value) 92 | if match is None: 93 | raise ValueError("invalid datetime format") 94 | 95 | kw = match.groupdict() 96 | if kw["microsecond"]: 97 | kw["microsecond"] = kw["microsecond"].ljust(6, "0") 98 | 99 | tzinfo = _parse_timezone(kw.pop("tzinfo")) 100 | kw_: Dict[str, Union[None, int, timezone]] = {k: int(v) for k, v in kw.items() if v is not None} 101 | kw_["tzinfo"] = tzinfo 102 | 103 | return datetime(**kw_) # type: ignore 104 | 105 | 106 | def parse_date(value: Union[date, StrBytesIntFloat]) -> date: 107 | """ 108 | Parse a date/int/float/string and return a datetime.date. 109 | 110 | Raise ValueError if the input is well formatted but not a valid date. 111 | Raise ValueError if the input isn't well formatted. 112 | """ 113 | if isinstance(value, date): 114 | if isinstance(value, datetime): 115 | return value.date() 116 | else: 117 | return value 118 | 119 | number = _get_numeric(value, "date") 120 | if number is not None: 121 | return _from_unix_seconds(number).date() 122 | 123 | if isinstance(value, bytes): 124 | value = value.decode() 125 | 126 | assert not isinstance(value, (float, int)) 127 | match = date_re.match(value) 128 | if match is None: 129 | raise ValueError("invalid date format") 130 | 131 | kw = {k: int(v) for k, v in match.groupdict().items()} 132 | 133 | try: 134 | return date(**kw) 135 | except ValueError: 136 | raise ValueError("invalid date format") from None 137 | -------------------------------------------------------------------------------- /src/lumaai/resources/ping.py: -------------------------------------------------------------------------------- 1 | # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. 2 | 3 | from __future__ import annotations 4 | 5 | import httpx 6 | 7 | from .._types import Body, Query, Headers, NotGiven, not_given 8 | from .._compat import cached_property 9 | from .._resource import SyncAPIResource, AsyncAPIResource 10 | from .._response import ( 11 | to_raw_response_wrapper, 12 | to_streamed_response_wrapper, 13 | async_to_raw_response_wrapper, 14 | async_to_streamed_response_wrapper, 15 | ) 16 | from .._base_client import make_request_options 17 | from ..types.ping_check_response import PingCheckResponse 18 | 19 | __all__ = ["PingResource", "AsyncPingResource"] 20 | 21 | 22 | class PingResource(SyncAPIResource): 23 | @cached_property 24 | def with_raw_response(self) -> PingResourceWithRawResponse: 25 | """ 26 | This property can be used as a prefix for any HTTP method call to return 27 | the raw response object instead of the parsed content. 28 | 29 | For more information, see https://www.github.com/lumalabs/lumaai-python#accessing-raw-response-data-eg-headers 30 | """ 31 | return PingResourceWithRawResponse(self) 32 | 33 | @cached_property 34 | def with_streaming_response(self) -> PingResourceWithStreamingResponse: 35 | """ 36 | An alternative to `.with_raw_response` that doesn't eagerly read the response body. 37 | 38 | For more information, see https://www.github.com/lumalabs/lumaai-python#with_streaming_response 39 | """ 40 | return PingResourceWithStreamingResponse(self) 41 | 42 | def check( 43 | self, 44 | *, 45 | # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. 46 | # The extra values given here take precedence over values defined on the client or passed to this method. 47 | extra_headers: Headers | None = None, 48 | extra_query: Query | None = None, 49 | extra_body: Body | None = None, 50 | timeout: float | httpx.Timeout | None | NotGiven = not_given, 51 | ) -> PingCheckResponse: 52 | """Check if the API is running""" 53 | return self._get( 54 | "/ping", 55 | options=make_request_options( 56 | extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout 57 | ), 58 | cast_to=PingCheckResponse, 59 | ) 60 | 61 | 62 | class AsyncPingResource(AsyncAPIResource): 63 | @cached_property 64 | def with_raw_response(self) -> AsyncPingResourceWithRawResponse: 65 | """ 66 | This property can be used as a prefix for any HTTP method call to return 67 | the raw response object instead of the parsed content. 68 | 69 | For more information, see https://www.github.com/lumalabs/lumaai-python#accessing-raw-response-data-eg-headers 70 | """ 71 | return AsyncPingResourceWithRawResponse(self) 72 | 73 | @cached_property 74 | def with_streaming_response(self) -> AsyncPingResourceWithStreamingResponse: 75 | """ 76 | An alternative to `.with_raw_response` that doesn't eagerly read the response body. 77 | 78 | For more information, see https://www.github.com/lumalabs/lumaai-python#with_streaming_response 79 | """ 80 | return AsyncPingResourceWithStreamingResponse(self) 81 | 82 | async def check( 83 | self, 84 | *, 85 | # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. 86 | # The extra values given here take precedence over values defined on the client or passed to this method. 87 | extra_headers: Headers | None = None, 88 | extra_query: Query | None = None, 89 | extra_body: Body | None = None, 90 | timeout: float | httpx.Timeout | None | NotGiven = not_given, 91 | ) -> PingCheckResponse: 92 | """Check if the API is running""" 93 | return await self._get( 94 | "/ping", 95 | options=make_request_options( 96 | extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout 97 | ), 98 | cast_to=PingCheckResponse, 99 | ) 100 | 101 | 102 | class PingResourceWithRawResponse: 103 | def __init__(self, ping: PingResource) -> None: 104 | self._ping = ping 105 | 106 | self.check = to_raw_response_wrapper( 107 | ping.check, 108 | ) 109 | 110 | 111 | class AsyncPingResourceWithRawResponse: 112 | def __init__(self, ping: AsyncPingResource) -> None: 113 | self._ping = ping 114 | 115 | self.check = async_to_raw_response_wrapper( 116 | ping.check, 117 | ) 118 | 119 | 120 | class PingResourceWithStreamingResponse: 121 | def __init__(self, ping: PingResource) -> None: 122 | self._ping = ping 123 | 124 | self.check = to_streamed_response_wrapper( 125 | ping.check, 126 | ) 127 | 128 | 129 | class AsyncPingResourceWithStreamingResponse: 130 | def __init__(self, ping: AsyncPingResource) -> None: 131 | self._ping = ping 132 | 133 | self.check = async_to_streamed_response_wrapper( 134 | ping.check, 135 | ) 136 | -------------------------------------------------------------------------------- /src/lumaai/_qs.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Any, List, Tuple, Union, Mapping, TypeVar 4 | from urllib.parse import parse_qs, urlencode 5 | from typing_extensions import Literal, get_args 6 | 7 | from ._types import NotGiven, not_given 8 | from ._utils import flatten 9 | 10 | _T = TypeVar("_T") 11 | 12 | 13 | ArrayFormat = Literal["comma", "repeat", "indices", "brackets"] 14 | NestedFormat = Literal["dots", "brackets"] 15 | 16 | PrimitiveData = Union[str, int, float, bool, None] 17 | # this should be Data = Union[PrimitiveData, "List[Data]", "Tuple[Data]", "Mapping[str, Data]"] 18 | # https://github.com/microsoft/pyright/issues/3555 19 | Data = Union[PrimitiveData, List[Any], Tuple[Any], "Mapping[str, Any]"] 20 | Params = Mapping[str, Data] 21 | 22 | 23 | class Querystring: 24 | array_format: ArrayFormat 25 | nested_format: NestedFormat 26 | 27 | def __init__( 28 | self, 29 | *, 30 | array_format: ArrayFormat = "repeat", 31 | nested_format: NestedFormat = "brackets", 32 | ) -> None: 33 | self.array_format = array_format 34 | self.nested_format = nested_format 35 | 36 | def parse(self, query: str) -> Mapping[str, object]: 37 | # Note: custom format syntax is not supported yet 38 | return parse_qs(query) 39 | 40 | def stringify( 41 | self, 42 | params: Params, 43 | *, 44 | array_format: ArrayFormat | NotGiven = not_given, 45 | nested_format: NestedFormat | NotGiven = not_given, 46 | ) -> str: 47 | return urlencode( 48 | self.stringify_items( 49 | params, 50 | array_format=array_format, 51 | nested_format=nested_format, 52 | ) 53 | ) 54 | 55 | def stringify_items( 56 | self, 57 | params: Params, 58 | *, 59 | array_format: ArrayFormat | NotGiven = not_given, 60 | nested_format: NestedFormat | NotGiven = not_given, 61 | ) -> list[tuple[str, str]]: 62 | opts = Options( 63 | qs=self, 64 | array_format=array_format, 65 | nested_format=nested_format, 66 | ) 67 | return flatten([self._stringify_item(key, value, opts) for key, value in params.items()]) 68 | 69 | def _stringify_item( 70 | self, 71 | key: str, 72 | value: Data, 73 | opts: Options, 74 | ) -> list[tuple[str, str]]: 75 | if isinstance(value, Mapping): 76 | items: list[tuple[str, str]] = [] 77 | nested_format = opts.nested_format 78 | for subkey, subvalue in value.items(): 79 | items.extend( 80 | self._stringify_item( 81 | # TODO: error if unknown format 82 | f"{key}.{subkey}" if nested_format == "dots" else f"{key}[{subkey}]", 83 | subvalue, 84 | opts, 85 | ) 86 | ) 87 | return items 88 | 89 | if isinstance(value, (list, tuple)): 90 | array_format = opts.array_format 91 | if array_format == "comma": 92 | return [ 93 | ( 94 | key, 95 | ",".join(self._primitive_value_to_str(item) for item in value if item is not None), 96 | ), 97 | ] 98 | elif array_format == "repeat": 99 | items = [] 100 | for item in value: 101 | items.extend(self._stringify_item(key, item, opts)) 102 | return items 103 | elif array_format == "indices": 104 | raise NotImplementedError("The array indices format is not supported yet") 105 | elif array_format == "brackets": 106 | items = [] 107 | key = key + "[]" 108 | for item in value: 109 | items.extend(self._stringify_item(key, item, opts)) 110 | return items 111 | else: 112 | raise NotImplementedError( 113 | f"Unknown array_format value: {array_format}, choose from {', '.join(get_args(ArrayFormat))}" 114 | ) 115 | 116 | serialised = self._primitive_value_to_str(value) 117 | if not serialised: 118 | return [] 119 | return [(key, serialised)] 120 | 121 | def _primitive_value_to_str(self, value: PrimitiveData) -> str: 122 | # copied from httpx 123 | if value is True: 124 | return "true" 125 | elif value is False: 126 | return "false" 127 | elif value is None: 128 | return "" 129 | return str(value) 130 | 131 | 132 | _qs = Querystring() 133 | parse = _qs.parse 134 | stringify = _qs.stringify 135 | stringify_items = _qs.stringify_items 136 | 137 | 138 | class Options: 139 | array_format: ArrayFormat 140 | nested_format: NestedFormat 141 | 142 | def __init__( 143 | self, 144 | qs: Querystring = _qs, 145 | *, 146 | array_format: ArrayFormat | NotGiven = not_given, 147 | nested_format: NestedFormat | NotGiven = not_given, 148 | ) -> None: 149 | self.array_format = qs.array_format if isinstance(array_format, NotGiven) else array_format 150 | self.nested_format = qs.nested_format if isinstance(nested_format, NotGiven) else nested_format 151 | -------------------------------------------------------------------------------- /src/lumaai/_utils/_typing.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import sys 4 | import typing 5 | import typing_extensions 6 | from typing import Any, TypeVar, Iterable, cast 7 | from collections import abc as _c_abc 8 | from typing_extensions import ( 9 | TypeIs, 10 | Required, 11 | Annotated, 12 | get_args, 13 | get_origin, 14 | ) 15 | 16 | from ._utils import lru_cache 17 | from .._types import InheritsGeneric 18 | from ._compat import is_union as _is_union 19 | 20 | 21 | def is_annotated_type(typ: type) -> bool: 22 | return get_origin(typ) == Annotated 23 | 24 | 25 | def is_list_type(typ: type) -> bool: 26 | return (get_origin(typ) or typ) == list 27 | 28 | 29 | def is_sequence_type(typ: type) -> bool: 30 | origin = get_origin(typ) or typ 31 | return origin == typing_extensions.Sequence or origin == typing.Sequence or origin == _c_abc.Sequence 32 | 33 | 34 | def is_iterable_type(typ: type) -> bool: 35 | """If the given type is `typing.Iterable[T]`""" 36 | origin = get_origin(typ) or typ 37 | return origin == Iterable or origin == _c_abc.Iterable 38 | 39 | 40 | def is_union_type(typ: type) -> bool: 41 | return _is_union(get_origin(typ)) 42 | 43 | 44 | def is_required_type(typ: type) -> bool: 45 | return get_origin(typ) == Required 46 | 47 | 48 | def is_typevar(typ: type) -> bool: 49 | # type ignore is required because type checkers 50 | # think this expression will always return False 51 | return type(typ) == TypeVar # type: ignore 52 | 53 | 54 | _TYPE_ALIAS_TYPES: tuple[type[typing_extensions.TypeAliasType], ...] = (typing_extensions.TypeAliasType,) 55 | if sys.version_info >= (3, 12): 56 | _TYPE_ALIAS_TYPES = (*_TYPE_ALIAS_TYPES, typing.TypeAliasType) 57 | 58 | 59 | def is_type_alias_type(tp: Any, /) -> TypeIs[typing_extensions.TypeAliasType]: 60 | """Return whether the provided argument is an instance of `TypeAliasType`. 61 | 62 | ```python 63 | type Int = int 64 | is_type_alias_type(Int) 65 | # > True 66 | Str = TypeAliasType("Str", str) 67 | is_type_alias_type(Str) 68 | # > True 69 | ``` 70 | """ 71 | return isinstance(tp, _TYPE_ALIAS_TYPES) 72 | 73 | 74 | # Extracts T from Annotated[T, ...] or from Required[Annotated[T, ...]] 75 | @lru_cache(maxsize=8096) 76 | def strip_annotated_type(typ: type) -> type: 77 | if is_required_type(typ) or is_annotated_type(typ): 78 | return strip_annotated_type(cast(type, get_args(typ)[0])) 79 | 80 | return typ 81 | 82 | 83 | def extract_type_arg(typ: type, index: int) -> type: 84 | args = get_args(typ) 85 | try: 86 | return cast(type, args[index]) 87 | except IndexError as err: 88 | raise RuntimeError(f"Expected type {typ} to have a type argument at index {index} but it did not") from err 89 | 90 | 91 | def extract_type_var_from_base( 92 | typ: type, 93 | *, 94 | generic_bases: tuple[type, ...], 95 | index: int, 96 | failure_message: str | None = None, 97 | ) -> type: 98 | """Given a type like `Foo[T]`, returns the generic type variable `T`. 99 | 100 | This also handles the case where a concrete subclass is given, e.g. 101 | ```py 102 | class MyResponse(Foo[bytes]): 103 | ... 104 | 105 | extract_type_var(MyResponse, bases=(Foo,), index=0) -> bytes 106 | ``` 107 | 108 | And where a generic subclass is given: 109 | ```py 110 | _T = TypeVar('_T') 111 | class MyResponse(Foo[_T]): 112 | ... 113 | 114 | extract_type_var(MyResponse[bytes], bases=(Foo,), index=0) -> bytes 115 | ``` 116 | """ 117 | cls = cast(object, get_origin(typ) or typ) 118 | if cls in generic_bases: # pyright: ignore[reportUnnecessaryContains] 119 | # we're given the class directly 120 | return extract_type_arg(typ, index) 121 | 122 | # if a subclass is given 123 | # --- 124 | # this is needed as __orig_bases__ is not present in the typeshed stubs 125 | # because it is intended to be for internal use only, however there does 126 | # not seem to be a way to resolve generic TypeVars for inherited subclasses 127 | # without using it. 128 | if isinstance(cls, InheritsGeneric): 129 | target_base_class: Any | None = None 130 | for base in cls.__orig_bases__: 131 | if base.__origin__ in generic_bases: 132 | target_base_class = base 133 | break 134 | 135 | if target_base_class is None: 136 | raise RuntimeError( 137 | "Could not find the generic base class;\n" 138 | "This should never happen;\n" 139 | f"Does {cls} inherit from one of {generic_bases} ?" 140 | ) 141 | 142 | extracted = extract_type_arg(target_base_class, index) 143 | if is_typevar(extracted): 144 | # If the extracted type argument is itself a type variable 145 | # then that means the subclass itself is generic, so we have 146 | # to resolve the type argument from the class itself, not 147 | # the base class. 148 | # 149 | # Note: if there is more than 1 type argument, the subclass could 150 | # change the ordering of the type arguments, this is not currently 151 | # supported. 152 | return extract_type_arg(typ, index) 153 | 154 | return extracted 155 | 156 | raise RuntimeError(failure_message or f"Could not resolve inner type variable at index {index} for {typ}") 157 | -------------------------------------------------------------------------------- /src/lumaai/resources/credits.py: -------------------------------------------------------------------------------- 1 | # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. 2 | 3 | from __future__ import annotations 4 | 5 | import httpx 6 | 7 | from .._types import Body, Query, Headers, NotGiven, not_given 8 | from .._compat import cached_property 9 | from .._resource import SyncAPIResource, AsyncAPIResource 10 | from .._response import ( 11 | to_raw_response_wrapper, 12 | to_streamed_response_wrapper, 13 | async_to_raw_response_wrapper, 14 | async_to_streamed_response_wrapper, 15 | ) 16 | from .._base_client import make_request_options 17 | from ..types.credit_get_response import CreditGetResponse 18 | 19 | __all__ = ["CreditsResource", "AsyncCreditsResource"] 20 | 21 | 22 | class CreditsResource(SyncAPIResource): 23 | @cached_property 24 | def with_raw_response(self) -> CreditsResourceWithRawResponse: 25 | """ 26 | This property can be used as a prefix for any HTTP method call to return 27 | the raw response object instead of the parsed content. 28 | 29 | For more information, see https://www.github.com/lumalabs/lumaai-python#accessing-raw-response-data-eg-headers 30 | """ 31 | return CreditsResourceWithRawResponse(self) 32 | 33 | @cached_property 34 | def with_streaming_response(self) -> CreditsResourceWithStreamingResponse: 35 | """ 36 | An alternative to `.with_raw_response` that doesn't eagerly read the response body. 37 | 38 | For more information, see https://www.github.com/lumalabs/lumaai-python#with_streaming_response 39 | """ 40 | return CreditsResourceWithStreamingResponse(self) 41 | 42 | def get( 43 | self, 44 | *, 45 | # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. 46 | # The extra values given here take precedence over values defined on the client or passed to this method. 47 | extra_headers: Headers | None = None, 48 | extra_query: Query | None = None, 49 | extra_body: Body | None = None, 50 | timeout: float | httpx.Timeout | None | NotGiven = not_given, 51 | ) -> CreditGetResponse: 52 | """Get the credits information for the api user""" 53 | return self._get( 54 | "/credits", 55 | options=make_request_options( 56 | extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout 57 | ), 58 | cast_to=CreditGetResponse, 59 | ) 60 | 61 | 62 | class AsyncCreditsResource(AsyncAPIResource): 63 | @cached_property 64 | def with_raw_response(self) -> AsyncCreditsResourceWithRawResponse: 65 | """ 66 | This property can be used as a prefix for any HTTP method call to return 67 | the raw response object instead of the parsed content. 68 | 69 | For more information, see https://www.github.com/lumalabs/lumaai-python#accessing-raw-response-data-eg-headers 70 | """ 71 | return AsyncCreditsResourceWithRawResponse(self) 72 | 73 | @cached_property 74 | def with_streaming_response(self) -> AsyncCreditsResourceWithStreamingResponse: 75 | """ 76 | An alternative to `.with_raw_response` that doesn't eagerly read the response body. 77 | 78 | For more information, see https://www.github.com/lumalabs/lumaai-python#with_streaming_response 79 | """ 80 | return AsyncCreditsResourceWithStreamingResponse(self) 81 | 82 | async def get( 83 | self, 84 | *, 85 | # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. 86 | # The extra values given here take precedence over values defined on the client or passed to this method. 87 | extra_headers: Headers | None = None, 88 | extra_query: Query | None = None, 89 | extra_body: Body | None = None, 90 | timeout: float | httpx.Timeout | None | NotGiven = not_given, 91 | ) -> CreditGetResponse: 92 | """Get the credits information for the api user""" 93 | return await self._get( 94 | "/credits", 95 | options=make_request_options( 96 | extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout 97 | ), 98 | cast_to=CreditGetResponse, 99 | ) 100 | 101 | 102 | class CreditsResourceWithRawResponse: 103 | def __init__(self, credits: CreditsResource) -> None: 104 | self._credits = credits 105 | 106 | self.get = to_raw_response_wrapper( 107 | credits.get, 108 | ) 109 | 110 | 111 | class AsyncCreditsResourceWithRawResponse: 112 | def __init__(self, credits: AsyncCreditsResource) -> None: 113 | self._credits = credits 114 | 115 | self.get = async_to_raw_response_wrapper( 116 | credits.get, 117 | ) 118 | 119 | 120 | class CreditsResourceWithStreamingResponse: 121 | def __init__(self, credits: CreditsResource) -> None: 122 | self._credits = credits 123 | 124 | self.get = to_streamed_response_wrapper( 125 | credits.get, 126 | ) 127 | 128 | 129 | class AsyncCreditsResourceWithStreamingResponse: 130 | def __init__(self, credits: AsyncCreditsResource) -> None: 131 | self._credits = credits 132 | 133 | self.get = async_to_streamed_response_wrapper( 134 | credits.get, 135 | ) 136 | -------------------------------------------------------------------------------- /src/lumaai/resources/generations/concepts.py: -------------------------------------------------------------------------------- 1 | # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. 2 | 3 | from __future__ import annotations 4 | 5 | import httpx 6 | 7 | from ..._types import Body, Query, Headers, NotGiven, not_given 8 | from ..._compat import cached_property 9 | from ..._resource import SyncAPIResource, AsyncAPIResource 10 | from ..._response import ( 11 | to_raw_response_wrapper, 12 | to_streamed_response_wrapper, 13 | async_to_raw_response_wrapper, 14 | async_to_streamed_response_wrapper, 15 | ) 16 | from ..._base_client import make_request_options 17 | from ...types.generations.concept_list_response import ConceptListResponse 18 | 19 | __all__ = ["ConceptsResource", "AsyncConceptsResource"] 20 | 21 | 22 | class ConceptsResource(SyncAPIResource): 23 | @cached_property 24 | def with_raw_response(self) -> ConceptsResourceWithRawResponse: 25 | """ 26 | This property can be used as a prefix for any HTTP method call to return 27 | the raw response object instead of the parsed content. 28 | 29 | For more information, see https://www.github.com/lumalabs/lumaai-python#accessing-raw-response-data-eg-headers 30 | """ 31 | return ConceptsResourceWithRawResponse(self) 32 | 33 | @cached_property 34 | def with_streaming_response(self) -> ConceptsResourceWithStreamingResponse: 35 | """ 36 | An alternative to `.with_raw_response` that doesn't eagerly read the response body. 37 | 38 | For more information, see https://www.github.com/lumalabs/lumaai-python#with_streaming_response 39 | """ 40 | return ConceptsResourceWithStreamingResponse(self) 41 | 42 | def list( 43 | self, 44 | *, 45 | # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. 46 | # The extra values given here take precedence over values defined on the client or passed to this method. 47 | extra_headers: Headers | None = None, 48 | extra_query: Query | None = None, 49 | extra_body: Body | None = None, 50 | timeout: float | httpx.Timeout | None | NotGiven = not_given, 51 | ) -> ConceptListResponse: 52 | """Get all possible concepts""" 53 | return self._get( 54 | "/generations/concepts/list", 55 | options=make_request_options( 56 | extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout 57 | ), 58 | cast_to=ConceptListResponse, 59 | ) 60 | 61 | 62 | class AsyncConceptsResource(AsyncAPIResource): 63 | @cached_property 64 | def with_raw_response(self) -> AsyncConceptsResourceWithRawResponse: 65 | """ 66 | This property can be used as a prefix for any HTTP method call to return 67 | the raw response object instead of the parsed content. 68 | 69 | For more information, see https://www.github.com/lumalabs/lumaai-python#accessing-raw-response-data-eg-headers 70 | """ 71 | return AsyncConceptsResourceWithRawResponse(self) 72 | 73 | @cached_property 74 | def with_streaming_response(self) -> AsyncConceptsResourceWithStreamingResponse: 75 | """ 76 | An alternative to `.with_raw_response` that doesn't eagerly read the response body. 77 | 78 | For more information, see https://www.github.com/lumalabs/lumaai-python#with_streaming_response 79 | """ 80 | return AsyncConceptsResourceWithStreamingResponse(self) 81 | 82 | async def list( 83 | self, 84 | *, 85 | # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. 86 | # The extra values given here take precedence over values defined on the client or passed to this method. 87 | extra_headers: Headers | None = None, 88 | extra_query: Query | None = None, 89 | extra_body: Body | None = None, 90 | timeout: float | httpx.Timeout | None | NotGiven = not_given, 91 | ) -> ConceptListResponse: 92 | """Get all possible concepts""" 93 | return await self._get( 94 | "/generations/concepts/list", 95 | options=make_request_options( 96 | extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout 97 | ), 98 | cast_to=ConceptListResponse, 99 | ) 100 | 101 | 102 | class ConceptsResourceWithRawResponse: 103 | def __init__(self, concepts: ConceptsResource) -> None: 104 | self._concepts = concepts 105 | 106 | self.list = to_raw_response_wrapper( 107 | concepts.list, 108 | ) 109 | 110 | 111 | class AsyncConceptsResourceWithRawResponse: 112 | def __init__(self, concepts: AsyncConceptsResource) -> None: 113 | self._concepts = concepts 114 | 115 | self.list = async_to_raw_response_wrapper( 116 | concepts.list, 117 | ) 118 | 119 | 120 | class ConceptsResourceWithStreamingResponse: 121 | def __init__(self, concepts: ConceptsResource) -> None: 122 | self._concepts = concepts 123 | 124 | self.list = to_streamed_response_wrapper( 125 | concepts.list, 126 | ) 127 | 128 | 129 | class AsyncConceptsResourceWithStreamingResponse: 130 | def __init__(self, concepts: AsyncConceptsResource) -> None: 131 | self._concepts = concepts 132 | 133 | self.list = async_to_streamed_response_wrapper( 134 | concepts.list, 135 | ) 136 | -------------------------------------------------------------------------------- /tests/utils.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import os 4 | import inspect 5 | import traceback 6 | import contextlib 7 | from typing import Any, TypeVar, Iterator, Sequence, cast 8 | from datetime import date, datetime 9 | from typing_extensions import Literal, get_args, get_origin, assert_type 10 | 11 | from lumaai._types import Omit, NoneType 12 | from lumaai._utils import ( 13 | is_dict, 14 | is_list, 15 | is_list_type, 16 | is_union_type, 17 | extract_type_arg, 18 | is_sequence_type, 19 | is_annotated_type, 20 | is_type_alias_type, 21 | ) 22 | from lumaai._compat import PYDANTIC_V1, field_outer_type, get_model_fields 23 | from lumaai._models import BaseModel 24 | 25 | BaseModelT = TypeVar("BaseModelT", bound=BaseModel) 26 | 27 | 28 | def assert_matches_model(model: type[BaseModelT], value: BaseModelT, *, path: list[str]) -> bool: 29 | for name, field in get_model_fields(model).items(): 30 | field_value = getattr(value, name) 31 | if PYDANTIC_V1: 32 | # in v1 nullability was structured differently 33 | # https://docs.pydantic.dev/2.0/migration/#required-optional-and-nullable-fields 34 | allow_none = getattr(field, "allow_none", False) 35 | else: 36 | allow_none = False 37 | 38 | assert_matches_type( 39 | field_outer_type(field), 40 | field_value, 41 | path=[*path, name], 42 | allow_none=allow_none, 43 | ) 44 | 45 | return True 46 | 47 | 48 | # Note: the `path` argument is only used to improve error messages when `--showlocals` is used 49 | def assert_matches_type( 50 | type_: Any, 51 | value: object, 52 | *, 53 | path: list[str], 54 | allow_none: bool = False, 55 | ) -> None: 56 | if is_type_alias_type(type_): 57 | type_ = type_.__value__ 58 | 59 | # unwrap `Annotated[T, ...]` -> `T` 60 | if is_annotated_type(type_): 61 | type_ = extract_type_arg(type_, 0) 62 | 63 | if allow_none and value is None: 64 | return 65 | 66 | if type_ is None or type_ is NoneType: 67 | assert value is None 68 | return 69 | 70 | origin = get_origin(type_) or type_ 71 | 72 | if is_list_type(type_): 73 | return _assert_list_type(type_, value) 74 | 75 | if is_sequence_type(type_): 76 | assert isinstance(value, Sequence) 77 | inner_type = get_args(type_)[0] 78 | for entry in value: # type: ignore 79 | assert_type(inner_type, entry) # type: ignore 80 | return 81 | 82 | if origin == str: 83 | assert isinstance(value, str) 84 | elif origin == int: 85 | assert isinstance(value, int) 86 | elif origin == bool: 87 | assert isinstance(value, bool) 88 | elif origin == float: 89 | assert isinstance(value, float) 90 | elif origin == bytes: 91 | assert isinstance(value, bytes) 92 | elif origin == datetime: 93 | assert isinstance(value, datetime) 94 | elif origin == date: 95 | assert isinstance(value, date) 96 | elif origin == object: 97 | # nothing to do here, the expected type is unknown 98 | pass 99 | elif origin == Literal: 100 | assert value in get_args(type_) 101 | elif origin == dict: 102 | assert is_dict(value) 103 | 104 | args = get_args(type_) 105 | key_type = args[0] 106 | items_type = args[1] 107 | 108 | for key, item in value.items(): 109 | assert_matches_type(key_type, key, path=[*path, ""]) 110 | assert_matches_type(items_type, item, path=[*path, ""]) 111 | elif is_union_type(type_): 112 | variants = get_args(type_) 113 | 114 | try: 115 | none_index = variants.index(type(None)) 116 | except ValueError: 117 | pass 118 | else: 119 | # special case Optional[T] for better error messages 120 | if len(variants) == 2: 121 | if value is None: 122 | # valid 123 | return 124 | 125 | return assert_matches_type(type_=variants[not none_index], value=value, path=path) 126 | 127 | for i, variant in enumerate(variants): 128 | try: 129 | assert_matches_type(variant, value, path=[*path, f"variant {i}"]) 130 | return 131 | except AssertionError: 132 | traceback.print_exc() 133 | continue 134 | 135 | raise AssertionError("Did not match any variants") 136 | elif issubclass(origin, BaseModel): 137 | assert isinstance(value, type_) 138 | assert assert_matches_model(type_, cast(Any, value), path=path) 139 | elif inspect.isclass(origin) and origin.__name__ == "HttpxBinaryResponseContent": 140 | assert value.__class__.__name__ == "HttpxBinaryResponseContent" 141 | else: 142 | assert None, f"Unhandled field type: {type_}" 143 | 144 | 145 | def _assert_list_type(type_: type[object], value: object) -> None: 146 | assert is_list(value) 147 | 148 | inner_type = get_args(type_)[0] 149 | for entry in value: 150 | assert_type(inner_type, entry) # type: ignore 151 | 152 | 153 | @contextlib.contextmanager 154 | def update_env(**new_env: str | Omit) -> Iterator[None]: 155 | old = os.environ.copy() 156 | 157 | try: 158 | for name, value in new_env.items(): 159 | if isinstance(value, Omit): 160 | os.environ.pop(name, None) 161 | else: 162 | os.environ[name] = value 163 | 164 | yield None 165 | finally: 166 | os.environ.clear() 167 | os.environ.update(old) 168 | -------------------------------------------------------------------------------- /tests/test_utils/test_datetime_parse.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copied from https://github.com/pydantic/pydantic/blob/v1.10.22/tests/test_datetime_parse.py 3 | with modifications so it works without pydantic v1 imports. 4 | """ 5 | 6 | from typing import Type, Union 7 | from datetime import date, datetime, timezone, timedelta 8 | 9 | import pytest 10 | 11 | from lumaai._utils import parse_date, parse_datetime 12 | 13 | 14 | def create_tz(minutes: int) -> timezone: 15 | return timezone(timedelta(minutes=minutes)) 16 | 17 | 18 | @pytest.mark.parametrize( 19 | "value,result", 20 | [ 21 | # Valid inputs 22 | ("1494012444.883309", date(2017, 5, 5)), 23 | (b"1494012444.883309", date(2017, 5, 5)), 24 | (1_494_012_444.883_309, date(2017, 5, 5)), 25 | ("1494012444", date(2017, 5, 5)), 26 | (1_494_012_444, date(2017, 5, 5)), 27 | (0, date(1970, 1, 1)), 28 | ("2012-04-23", date(2012, 4, 23)), 29 | (b"2012-04-23", date(2012, 4, 23)), 30 | ("2012-4-9", date(2012, 4, 9)), 31 | (date(2012, 4, 9), date(2012, 4, 9)), 32 | (datetime(2012, 4, 9, 12, 15), date(2012, 4, 9)), 33 | # Invalid inputs 34 | ("x20120423", ValueError), 35 | ("2012-04-56", ValueError), 36 | (19_999_999_999, date(2603, 10, 11)), # just before watershed 37 | (20_000_000_001, date(1970, 8, 20)), # just after watershed 38 | (1_549_316_052, date(2019, 2, 4)), # nowish in s 39 | (1_549_316_052_104, date(2019, 2, 4)), # nowish in ms 40 | (1_549_316_052_104_324, date(2019, 2, 4)), # nowish in μs 41 | (1_549_316_052_104_324_096, date(2019, 2, 4)), # nowish in ns 42 | ("infinity", date(9999, 12, 31)), 43 | ("inf", date(9999, 12, 31)), 44 | (float("inf"), date(9999, 12, 31)), 45 | ("infinity ", date(9999, 12, 31)), 46 | (int("1" + "0" * 100), date(9999, 12, 31)), 47 | (1e1000, date(9999, 12, 31)), 48 | ("-infinity", date(1, 1, 1)), 49 | ("-inf", date(1, 1, 1)), 50 | ("nan", ValueError), 51 | ], 52 | ) 53 | def test_date_parsing(value: Union[str, bytes, int, float], result: Union[date, Type[Exception]]) -> None: 54 | if type(result) == type and issubclass(result, Exception): # pyright: ignore[reportUnnecessaryIsInstance] 55 | with pytest.raises(result): 56 | parse_date(value) 57 | else: 58 | assert parse_date(value) == result 59 | 60 | 61 | @pytest.mark.parametrize( 62 | "value,result", 63 | [ 64 | # Valid inputs 65 | # values in seconds 66 | ("1494012444.883309", datetime(2017, 5, 5, 19, 27, 24, 883_309, tzinfo=timezone.utc)), 67 | (1_494_012_444.883_309, datetime(2017, 5, 5, 19, 27, 24, 883_309, tzinfo=timezone.utc)), 68 | ("1494012444", datetime(2017, 5, 5, 19, 27, 24, tzinfo=timezone.utc)), 69 | (b"1494012444", datetime(2017, 5, 5, 19, 27, 24, tzinfo=timezone.utc)), 70 | (1_494_012_444, datetime(2017, 5, 5, 19, 27, 24, tzinfo=timezone.utc)), 71 | # values in ms 72 | ("1494012444000.883309", datetime(2017, 5, 5, 19, 27, 24, 883, tzinfo=timezone.utc)), 73 | ("-1494012444000.883309", datetime(1922, 8, 29, 4, 32, 35, 999117, tzinfo=timezone.utc)), 74 | (1_494_012_444_000, datetime(2017, 5, 5, 19, 27, 24, tzinfo=timezone.utc)), 75 | ("2012-04-23T09:15:00", datetime(2012, 4, 23, 9, 15)), 76 | ("2012-4-9 4:8:16", datetime(2012, 4, 9, 4, 8, 16)), 77 | ("2012-04-23T09:15:00Z", datetime(2012, 4, 23, 9, 15, 0, 0, timezone.utc)), 78 | ("2012-4-9 4:8:16-0320", datetime(2012, 4, 9, 4, 8, 16, 0, create_tz(-200))), 79 | ("2012-04-23T10:20:30.400+02:30", datetime(2012, 4, 23, 10, 20, 30, 400_000, create_tz(150))), 80 | ("2012-04-23T10:20:30.400+02", datetime(2012, 4, 23, 10, 20, 30, 400_000, create_tz(120))), 81 | ("2012-04-23T10:20:30.400-02", datetime(2012, 4, 23, 10, 20, 30, 400_000, create_tz(-120))), 82 | (b"2012-04-23T10:20:30.400-02", datetime(2012, 4, 23, 10, 20, 30, 400_000, create_tz(-120))), 83 | (datetime(2017, 5, 5), datetime(2017, 5, 5)), 84 | (0, datetime(1970, 1, 1, 0, 0, 0, tzinfo=timezone.utc)), 85 | # Invalid inputs 86 | ("x20120423091500", ValueError), 87 | ("2012-04-56T09:15:90", ValueError), 88 | ("2012-04-23T11:05:00-25:00", ValueError), 89 | (19_999_999_999, datetime(2603, 10, 11, 11, 33, 19, tzinfo=timezone.utc)), # just before watershed 90 | (20_000_000_001, datetime(1970, 8, 20, 11, 33, 20, 1000, tzinfo=timezone.utc)), # just after watershed 91 | (1_549_316_052, datetime(2019, 2, 4, 21, 34, 12, 0, tzinfo=timezone.utc)), # nowish in s 92 | (1_549_316_052_104, datetime(2019, 2, 4, 21, 34, 12, 104_000, tzinfo=timezone.utc)), # nowish in ms 93 | (1_549_316_052_104_324, datetime(2019, 2, 4, 21, 34, 12, 104_324, tzinfo=timezone.utc)), # nowish in μs 94 | (1_549_316_052_104_324_096, datetime(2019, 2, 4, 21, 34, 12, 104_324, tzinfo=timezone.utc)), # nowish in ns 95 | ("infinity", datetime(9999, 12, 31, 23, 59, 59, 999999)), 96 | ("inf", datetime(9999, 12, 31, 23, 59, 59, 999999)), 97 | ("inf ", datetime(9999, 12, 31, 23, 59, 59, 999999)), 98 | (1e50, datetime(9999, 12, 31, 23, 59, 59, 999999)), 99 | (float("inf"), datetime(9999, 12, 31, 23, 59, 59, 999999)), 100 | ("-infinity", datetime(1, 1, 1, 0, 0)), 101 | ("-inf", datetime(1, 1, 1, 0, 0)), 102 | ("nan", ValueError), 103 | ], 104 | ) 105 | def test_datetime_parsing(value: Union[str, bytes, int, float], result: Union[datetime, Type[Exception]]) -> None: 106 | if type(result) == type and issubclass(result, Exception): # pyright: ignore[reportUnnecessaryIsInstance] 107 | with pytest.raises(result): 108 | parse_datetime(value) 109 | else: 110 | assert parse_datetime(value) == result 111 | -------------------------------------------------------------------------------- /scripts/utils/ruffen-docs.py: -------------------------------------------------------------------------------- 1 | # fork of https://github.com/asottile/blacken-docs adapted for ruff 2 | from __future__ import annotations 3 | 4 | import re 5 | import sys 6 | import argparse 7 | import textwrap 8 | import contextlib 9 | import subprocess 10 | from typing import Match, Optional, Sequence, Generator, NamedTuple, cast 11 | 12 | MD_RE = re.compile( 13 | r"(?P^(?P *)```\s*python\n)" r"(?P.*?)" r"(?P^(?P=indent)```\s*$)", 14 | re.DOTALL | re.MULTILINE, 15 | ) 16 | MD_PYCON_RE = re.compile( 17 | r"(?P^(?P *)```\s*pycon\n)" r"(?P.*?)" r"(?P^(?P=indent)```.*$)", 18 | re.DOTALL | re.MULTILINE, 19 | ) 20 | PYCON_PREFIX = ">>> " 21 | PYCON_CONTINUATION_PREFIX = "..." 22 | PYCON_CONTINUATION_RE = re.compile( 23 | rf"^{re.escape(PYCON_CONTINUATION_PREFIX)}( |$)", 24 | ) 25 | DEFAULT_LINE_LENGTH = 100 26 | 27 | 28 | class CodeBlockError(NamedTuple): 29 | offset: int 30 | exc: Exception 31 | 32 | 33 | def format_str( 34 | src: str, 35 | ) -> tuple[str, Sequence[CodeBlockError]]: 36 | errors: list[CodeBlockError] = [] 37 | 38 | @contextlib.contextmanager 39 | def _collect_error(match: Match[str]) -> Generator[None, None, None]: 40 | try: 41 | yield 42 | except Exception as e: 43 | errors.append(CodeBlockError(match.start(), e)) 44 | 45 | def _md_match(match: Match[str]) -> str: 46 | code = textwrap.dedent(match["code"]) 47 | with _collect_error(match): 48 | code = format_code_block(code) 49 | code = textwrap.indent(code, match["indent"]) 50 | return f"{match['before']}{code}{match['after']}" 51 | 52 | def _pycon_match(match: Match[str]) -> str: 53 | code = "" 54 | fragment = cast(Optional[str], None) 55 | 56 | def finish_fragment() -> None: 57 | nonlocal code 58 | nonlocal fragment 59 | 60 | if fragment is not None: 61 | with _collect_error(match): 62 | fragment = format_code_block(fragment) 63 | fragment_lines = fragment.splitlines() 64 | code += f"{PYCON_PREFIX}{fragment_lines[0]}\n" 65 | for line in fragment_lines[1:]: 66 | # Skip blank lines to handle Black adding a blank above 67 | # functions within blocks. A blank line would end the REPL 68 | # continuation prompt. 69 | # 70 | # >>> if True: 71 | # ... def f(): 72 | # ... pass 73 | # ... 74 | if line: 75 | code += f"{PYCON_CONTINUATION_PREFIX} {line}\n" 76 | if fragment_lines[-1].startswith(" "): 77 | code += f"{PYCON_CONTINUATION_PREFIX}\n" 78 | fragment = None 79 | 80 | indentation = None 81 | for line in match["code"].splitlines(): 82 | orig_line, line = line, line.lstrip() 83 | if indentation is None and line: 84 | indentation = len(orig_line) - len(line) 85 | continuation_match = PYCON_CONTINUATION_RE.match(line) 86 | if continuation_match and fragment is not None: 87 | fragment += line[continuation_match.end() :] + "\n" 88 | else: 89 | finish_fragment() 90 | if line.startswith(PYCON_PREFIX): 91 | fragment = line[len(PYCON_PREFIX) :] + "\n" 92 | else: 93 | code += orig_line[indentation:] + "\n" 94 | finish_fragment() 95 | return code 96 | 97 | def _md_pycon_match(match: Match[str]) -> str: 98 | code = _pycon_match(match) 99 | code = textwrap.indent(code, match["indent"]) 100 | return f"{match['before']}{code}{match['after']}" 101 | 102 | src = MD_RE.sub(_md_match, src) 103 | src = MD_PYCON_RE.sub(_md_pycon_match, src) 104 | return src, errors 105 | 106 | 107 | def format_code_block(code: str) -> str: 108 | return subprocess.check_output( 109 | [ 110 | sys.executable, 111 | "-m", 112 | "ruff", 113 | "format", 114 | "--stdin-filename=script.py", 115 | f"--line-length={DEFAULT_LINE_LENGTH}", 116 | ], 117 | encoding="utf-8", 118 | input=code, 119 | ) 120 | 121 | 122 | def format_file( 123 | filename: str, 124 | skip_errors: bool, 125 | ) -> int: 126 | with open(filename, encoding="UTF-8") as f: 127 | contents = f.read() 128 | new_contents, errors = format_str(contents) 129 | for error in errors: 130 | lineno = contents[: error.offset].count("\n") + 1 131 | print(f"{filename}:{lineno}: code block parse error {error.exc}") 132 | if errors and not skip_errors: 133 | return 1 134 | if contents != new_contents: 135 | print(f"{filename}: Rewriting...") 136 | with open(filename, "w", encoding="UTF-8") as f: 137 | f.write(new_contents) 138 | return 0 139 | else: 140 | return 0 141 | 142 | 143 | def main(argv: Sequence[str] | None = None) -> int: 144 | parser = argparse.ArgumentParser() 145 | parser.add_argument( 146 | "-l", 147 | "--line-length", 148 | type=int, 149 | default=DEFAULT_LINE_LENGTH, 150 | ) 151 | parser.add_argument( 152 | "-S", 153 | "--skip-string-normalization", 154 | action="store_true", 155 | ) 156 | parser.add_argument("-E", "--skip-errors", action="store_true") 157 | parser.add_argument("filenames", nargs="*") 158 | args = parser.parse_args(argv) 159 | 160 | retv = 0 161 | for filename in args.filenames: 162 | retv |= format_file(filename, skip_errors=args.skip_errors) 163 | return retv 164 | 165 | 166 | if __name__ == "__main__": 167 | raise SystemExit(main()) 168 | -------------------------------------------------------------------------------- /src/lumaai/_compat.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, Any, Union, Generic, TypeVar, Callable, cast, overload 4 | from datetime import date, datetime 5 | from typing_extensions import Self, Literal 6 | 7 | import pydantic 8 | from pydantic.fields import FieldInfo 9 | 10 | from ._types import IncEx, StrBytesIntFloat 11 | 12 | _T = TypeVar("_T") 13 | _ModelT = TypeVar("_ModelT", bound=pydantic.BaseModel) 14 | 15 | # --------------- Pydantic v2, v3 compatibility --------------- 16 | 17 | # Pyright incorrectly reports some of our functions as overriding a method when they don't 18 | # pyright: reportIncompatibleMethodOverride=false 19 | 20 | PYDANTIC_V1 = pydantic.VERSION.startswith("1.") 21 | 22 | if TYPE_CHECKING: 23 | 24 | def parse_date(value: date | StrBytesIntFloat) -> date: # noqa: ARG001 25 | ... 26 | 27 | def parse_datetime(value: Union[datetime, StrBytesIntFloat]) -> datetime: # noqa: ARG001 28 | ... 29 | 30 | def get_args(t: type[Any]) -> tuple[Any, ...]: # noqa: ARG001 31 | ... 32 | 33 | def is_union(tp: type[Any] | None) -> bool: # noqa: ARG001 34 | ... 35 | 36 | def get_origin(t: type[Any]) -> type[Any] | None: # noqa: ARG001 37 | ... 38 | 39 | def is_literal_type(type_: type[Any]) -> bool: # noqa: ARG001 40 | ... 41 | 42 | def is_typeddict(type_: type[Any]) -> bool: # noqa: ARG001 43 | ... 44 | 45 | else: 46 | # v1 re-exports 47 | if PYDANTIC_V1: 48 | from pydantic.typing import ( 49 | get_args as get_args, 50 | is_union as is_union, 51 | get_origin as get_origin, 52 | is_typeddict as is_typeddict, 53 | is_literal_type as is_literal_type, 54 | ) 55 | from pydantic.datetime_parse import parse_date as parse_date, parse_datetime as parse_datetime 56 | else: 57 | from ._utils import ( 58 | get_args as get_args, 59 | is_union as is_union, 60 | get_origin as get_origin, 61 | parse_date as parse_date, 62 | is_typeddict as is_typeddict, 63 | parse_datetime as parse_datetime, 64 | is_literal_type as is_literal_type, 65 | ) 66 | 67 | 68 | # refactored config 69 | if TYPE_CHECKING: 70 | from pydantic import ConfigDict as ConfigDict 71 | else: 72 | if PYDANTIC_V1: 73 | # TODO: provide an error message here? 74 | ConfigDict = None 75 | else: 76 | from pydantic import ConfigDict as ConfigDict 77 | 78 | 79 | # renamed methods / properties 80 | def parse_obj(model: type[_ModelT], value: object) -> _ModelT: 81 | if PYDANTIC_V1: 82 | return cast(_ModelT, model.parse_obj(value)) # pyright: ignore[reportDeprecated, reportUnnecessaryCast] 83 | else: 84 | return model.model_validate(value) 85 | 86 | 87 | def field_is_required(field: FieldInfo) -> bool: 88 | if PYDANTIC_V1: 89 | return field.required # type: ignore 90 | return field.is_required() 91 | 92 | 93 | def field_get_default(field: FieldInfo) -> Any: 94 | value = field.get_default() 95 | if PYDANTIC_V1: 96 | return value 97 | from pydantic_core import PydanticUndefined 98 | 99 | if value == PydanticUndefined: 100 | return None 101 | return value 102 | 103 | 104 | def field_outer_type(field: FieldInfo) -> Any: 105 | if PYDANTIC_V1: 106 | return field.outer_type_ # type: ignore 107 | return field.annotation 108 | 109 | 110 | def get_model_config(model: type[pydantic.BaseModel]) -> Any: 111 | if PYDANTIC_V1: 112 | return model.__config__ # type: ignore 113 | return model.model_config 114 | 115 | 116 | def get_model_fields(model: type[pydantic.BaseModel]) -> dict[str, FieldInfo]: 117 | if PYDANTIC_V1: 118 | return model.__fields__ # type: ignore 119 | return model.model_fields 120 | 121 | 122 | def model_copy(model: _ModelT, *, deep: bool = False) -> _ModelT: 123 | if PYDANTIC_V1: 124 | return model.copy(deep=deep) # type: ignore 125 | return model.model_copy(deep=deep) 126 | 127 | 128 | def model_json(model: pydantic.BaseModel, *, indent: int | None = None) -> str: 129 | if PYDANTIC_V1: 130 | return model.json(indent=indent) # type: ignore 131 | return model.model_dump_json(indent=indent) 132 | 133 | 134 | def model_dump( 135 | model: pydantic.BaseModel, 136 | *, 137 | exclude: IncEx | None = None, 138 | exclude_unset: bool = False, 139 | exclude_defaults: bool = False, 140 | warnings: bool = True, 141 | mode: Literal["json", "python"] = "python", 142 | ) -> dict[str, Any]: 143 | if (not PYDANTIC_V1) or hasattr(model, "model_dump"): 144 | return model.model_dump( 145 | mode=mode, 146 | exclude=exclude, 147 | exclude_unset=exclude_unset, 148 | exclude_defaults=exclude_defaults, 149 | # warnings are not supported in Pydantic v1 150 | warnings=True if PYDANTIC_V1 else warnings, 151 | ) 152 | return cast( 153 | "dict[str, Any]", 154 | model.dict( # pyright: ignore[reportDeprecated, reportUnnecessaryCast] 155 | exclude=exclude, 156 | exclude_unset=exclude_unset, 157 | exclude_defaults=exclude_defaults, 158 | ), 159 | ) 160 | 161 | 162 | def model_parse(model: type[_ModelT], data: Any) -> _ModelT: 163 | if PYDANTIC_V1: 164 | return model.parse_obj(data) # pyright: ignore[reportDeprecated] 165 | return model.model_validate(data) 166 | 167 | 168 | # generic models 169 | if TYPE_CHECKING: 170 | 171 | class GenericModel(pydantic.BaseModel): ... 172 | 173 | else: 174 | if PYDANTIC_V1: 175 | import pydantic.generics 176 | 177 | class GenericModel(pydantic.generics.GenericModel, pydantic.BaseModel): ... 178 | else: 179 | # there no longer needs to be a distinction in v2 but 180 | # we still have to create our own subclass to avoid 181 | # inconsistent MRO ordering errors 182 | class GenericModel(pydantic.BaseModel): ... 183 | 184 | 185 | # cached properties 186 | if TYPE_CHECKING: 187 | cached_property = property 188 | 189 | # we define a separate type (copied from typeshed) 190 | # that represents that `cached_property` is `set`able 191 | # at runtime, which differs from `@property`. 192 | # 193 | # this is a separate type as editors likely special case 194 | # `@property` and we don't want to cause issues just to have 195 | # more helpful internal types. 196 | 197 | class typed_cached_property(Generic[_T]): 198 | func: Callable[[Any], _T] 199 | attrname: str | None 200 | 201 | def __init__(self, func: Callable[[Any], _T]) -> None: ... 202 | 203 | @overload 204 | def __get__(self, instance: None, owner: type[Any] | None = None) -> Self: ... 205 | 206 | @overload 207 | def __get__(self, instance: object, owner: type[Any] | None = None) -> _T: ... 208 | 209 | def __get__(self, instance: object, owner: type[Any] | None = None) -> _T | Self: 210 | raise NotImplementedError() 211 | 212 | def __set_name__(self, owner: type[Any], name: str) -> None: ... 213 | 214 | # __set__ is not defined at runtime, but @cached_property is designed to be settable 215 | def __set__(self, instance: object, value: _T) -> None: ... 216 | else: 217 | from functools import cached_property as cached_property 218 | 219 | typed_cached_property = cached_property 220 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "lumaai" 3 | version = "1.18.1" 4 | description = "The official Python library for the lumaai API" 5 | dynamic = ["readme"] 6 | license = "Apache-2.0" 7 | authors = [ 8 | { name = "LumaAI", email = "support+api@lumalabs.ai" }, 9 | ] 10 | dependencies = [ 11 | "httpx>=0.23.0, <1", 12 | "pydantic>=1.9.0, <3", 13 | "typing-extensions>=4.10, <5", 14 | "anyio>=3.5.0, <5", 15 | "distro>=1.7.0, <2", 16 | "sniffio", 17 | ] 18 | requires-python = ">= 3.8" 19 | classifiers = [ 20 | "Typing :: Typed", 21 | "Intended Audience :: Developers", 22 | "Programming Language :: Python :: 3.8", 23 | "Programming Language :: Python :: 3.9", 24 | "Programming Language :: Python :: 3.10", 25 | "Programming Language :: Python :: 3.11", 26 | "Programming Language :: Python :: 3.12", 27 | "Programming Language :: Python :: 3.13", 28 | "Operating System :: OS Independent", 29 | "Operating System :: POSIX", 30 | "Operating System :: MacOS", 31 | "Operating System :: POSIX :: Linux", 32 | "Operating System :: Microsoft :: Windows", 33 | "Topic :: Software Development :: Libraries :: Python Modules", 34 | "License :: OSI Approved :: Apache Software License" 35 | ] 36 | 37 | [project.urls] 38 | Homepage = "https://github.com/lumalabs/lumaai-python" 39 | Repository = "https://github.com/lumalabs/lumaai-python" 40 | 41 | [project.optional-dependencies] 42 | aiohttp = ["aiohttp", "httpx_aiohttp>=0.1.9"] 43 | 44 | [tool.rye] 45 | managed = true 46 | # version pins are in requirements-dev.lock 47 | dev-dependencies = [ 48 | "pyright==1.1.399", 49 | "mypy", 50 | "respx", 51 | "pytest", 52 | "pytest-asyncio", 53 | "ruff", 54 | "time-machine", 55 | "nox", 56 | "dirty-equals>=0.6.0", 57 | "importlib-metadata>=6.7.0", 58 | "rich>=13.7.1", 59 | "pytest-xdist>=3.6.1", 60 | ] 61 | 62 | [tool.rye.scripts] 63 | format = { chain = [ 64 | "format:ruff", 65 | "format:docs", 66 | "fix:ruff", 67 | # run formatting again to fix any inconsistencies when imports are stripped 68 | "format:ruff", 69 | ]} 70 | "format:docs" = "python scripts/utils/ruffen-docs.py README.md api.md" 71 | "format:ruff" = "ruff format" 72 | 73 | "lint" = { chain = [ 74 | "check:ruff", 75 | "typecheck", 76 | "check:importable", 77 | ]} 78 | "check:ruff" = "ruff check ." 79 | "fix:ruff" = "ruff check --fix ." 80 | 81 | "check:importable" = "python -c 'import lumaai'" 82 | 83 | typecheck = { chain = [ 84 | "typecheck:pyright", 85 | "typecheck:mypy" 86 | ]} 87 | "typecheck:pyright" = "pyright" 88 | "typecheck:verify-types" = "pyright --verifytypes lumaai --ignoreexternal" 89 | "typecheck:mypy" = "mypy ." 90 | 91 | [build-system] 92 | requires = ["hatchling==1.26.3", "hatch-fancy-pypi-readme"] 93 | build-backend = "hatchling.build" 94 | 95 | [tool.hatch.build] 96 | include = [ 97 | "src/*" 98 | ] 99 | 100 | [tool.hatch.build.targets.wheel] 101 | packages = ["src/lumaai"] 102 | 103 | [tool.hatch.build.targets.sdist] 104 | # Basically everything except hidden files/directories (such as .github, .devcontainers, .python-version, etc) 105 | include = [ 106 | "/*.toml", 107 | "/*.json", 108 | "/*.lock", 109 | "/*.md", 110 | "/mypy.ini", 111 | "/noxfile.py", 112 | "bin/*", 113 | "examples/*", 114 | "src/*", 115 | "tests/*", 116 | ] 117 | 118 | [tool.hatch.metadata.hooks.fancy-pypi-readme] 119 | content-type = "text/markdown" 120 | 121 | [[tool.hatch.metadata.hooks.fancy-pypi-readme.fragments]] 122 | path = "README.md" 123 | 124 | [[tool.hatch.metadata.hooks.fancy-pypi-readme.substitutions]] 125 | # replace relative links with absolute links 126 | pattern = '\[(.+?)\]\(((?!https?://)\S+?)\)' 127 | replacement = '[\1](https://github.com/lumalabs/lumaai-python/tree/main/\g<2>)' 128 | 129 | [tool.pytest.ini_options] 130 | testpaths = ["tests"] 131 | addopts = "--tb=short -n auto" 132 | xfail_strict = true 133 | asyncio_mode = "auto" 134 | asyncio_default_fixture_loop_scope = "session" 135 | filterwarnings = [ 136 | "error" 137 | ] 138 | 139 | [tool.pyright] 140 | # this enables practically every flag given by pyright. 141 | # there are a couple of flags that are still disabled by 142 | # default in strict mode as they are experimental and niche. 143 | typeCheckingMode = "strict" 144 | pythonVersion = "3.8" 145 | 146 | exclude = [ 147 | "_dev", 148 | ".venv", 149 | ".nox", 150 | ".git", 151 | ] 152 | 153 | reportImplicitOverride = true 154 | reportOverlappingOverload = false 155 | 156 | reportImportCycles = false 157 | reportPrivateUsage = false 158 | 159 | [tool.mypy] 160 | pretty = true 161 | show_error_codes = true 162 | 163 | # Exclude _files.py because mypy isn't smart enough to apply 164 | # the correct type narrowing and as this is an internal module 165 | # it's fine to just use Pyright. 166 | # 167 | # We also exclude our `tests` as mypy doesn't always infer 168 | # types correctly and Pyright will still catch any type errors. 169 | exclude = ['src/lumaai/_files.py', '_dev/.*.py', 'tests/.*'] 170 | 171 | strict_equality = true 172 | implicit_reexport = true 173 | check_untyped_defs = true 174 | no_implicit_optional = true 175 | 176 | warn_return_any = true 177 | warn_unreachable = true 178 | warn_unused_configs = true 179 | 180 | # Turn these options off as it could cause conflicts 181 | # with the Pyright options. 182 | warn_unused_ignores = false 183 | warn_redundant_casts = false 184 | 185 | disallow_any_generics = true 186 | disallow_untyped_defs = true 187 | disallow_untyped_calls = true 188 | disallow_subclassing_any = true 189 | disallow_incomplete_defs = true 190 | disallow_untyped_decorators = true 191 | cache_fine_grained = true 192 | 193 | # By default, mypy reports an error if you assign a value to the result 194 | # of a function call that doesn't return anything. We do this in our test 195 | # cases: 196 | # ``` 197 | # result = ... 198 | # assert result is None 199 | # ``` 200 | # Changing this codegen to make mypy happy would increase complexity 201 | # and would not be worth it. 202 | disable_error_code = "func-returns-value,overload-cannot-match" 203 | 204 | # https://github.com/python/mypy/issues/12162 205 | [[tool.mypy.overrides]] 206 | module = "black.files.*" 207 | ignore_errors = true 208 | ignore_missing_imports = true 209 | 210 | 211 | [tool.ruff] 212 | line-length = 120 213 | output-format = "grouped" 214 | target-version = "py38" 215 | 216 | [tool.ruff.format] 217 | docstring-code-format = true 218 | 219 | [tool.ruff.lint] 220 | select = [ 221 | # isort 222 | "I", 223 | # bugbear rules 224 | "B", 225 | # remove unused imports 226 | "F401", 227 | # check for missing future annotations 228 | "FA102", 229 | # bare except statements 230 | "E722", 231 | # unused arguments 232 | "ARG", 233 | # print statements 234 | "T201", 235 | "T203", 236 | # misuse of typing.TYPE_CHECKING 237 | "TC004", 238 | # import rules 239 | "TID251", 240 | ] 241 | ignore = [ 242 | # mutable defaults 243 | "B006", 244 | ] 245 | unfixable = [ 246 | # disable auto fix for print statements 247 | "T201", 248 | "T203", 249 | ] 250 | 251 | extend-safe-fixes = ["FA102"] 252 | 253 | [tool.ruff.lint.flake8-tidy-imports.banned-api] 254 | "functools.lru_cache".msg = "This function does not retain type information for the wrapped function's arguments; The `lru_cache` function from `_utils` should be used instead" 255 | 256 | [tool.ruff.lint.isort] 257 | length-sort = true 258 | length-sort-straight = true 259 | combine-as-imports = true 260 | extra-standard-library = ["typing_extensions"] 261 | known-first-party = ["lumaai", "tests"] 262 | 263 | [tool.ruff.lint.per-file-ignores] 264 | "bin/**.py" = ["T201", "T203"] 265 | "scripts/**.py" = ["T201", "T203"] 266 | "tests/**.py" = ["T201", "T203"] 267 | "examples/**.py" = ["T201", "T203"] 268 | -------------------------------------------------------------------------------- /src/lumaai/_types.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from os import PathLike 4 | from typing import ( 5 | IO, 6 | TYPE_CHECKING, 7 | Any, 8 | Dict, 9 | List, 10 | Type, 11 | Tuple, 12 | Union, 13 | Mapping, 14 | TypeVar, 15 | Callable, 16 | Iterator, 17 | Optional, 18 | Sequence, 19 | ) 20 | from typing_extensions import ( 21 | Set, 22 | Literal, 23 | Protocol, 24 | TypeAlias, 25 | TypedDict, 26 | SupportsIndex, 27 | overload, 28 | override, 29 | runtime_checkable, 30 | ) 31 | 32 | import httpx 33 | import pydantic 34 | from httpx import URL, Proxy, Timeout, Response, BaseTransport, AsyncBaseTransport 35 | 36 | if TYPE_CHECKING: 37 | from ._models import BaseModel 38 | from ._response import APIResponse, AsyncAPIResponse 39 | 40 | Transport = BaseTransport 41 | AsyncTransport = AsyncBaseTransport 42 | Query = Mapping[str, object] 43 | Body = object 44 | AnyMapping = Mapping[str, object] 45 | ModelT = TypeVar("ModelT", bound=pydantic.BaseModel) 46 | _T = TypeVar("_T") 47 | 48 | 49 | # Approximates httpx internal ProxiesTypes and RequestFiles types 50 | # while adding support for `PathLike` instances 51 | ProxiesDict = Dict["str | URL", Union[None, str, URL, Proxy]] 52 | ProxiesTypes = Union[str, Proxy, ProxiesDict] 53 | if TYPE_CHECKING: 54 | Base64FileInput = Union[IO[bytes], PathLike[str]] 55 | FileContent = Union[IO[bytes], bytes, PathLike[str]] 56 | else: 57 | Base64FileInput = Union[IO[bytes], PathLike] 58 | FileContent = Union[IO[bytes], bytes, PathLike] # PathLike is not subscriptable in Python 3.8. 59 | FileTypes = Union[ 60 | # file (or bytes) 61 | FileContent, 62 | # (filename, file (or bytes)) 63 | Tuple[Optional[str], FileContent], 64 | # (filename, file (or bytes), content_type) 65 | Tuple[Optional[str], FileContent, Optional[str]], 66 | # (filename, file (or bytes), content_type, headers) 67 | Tuple[Optional[str], FileContent, Optional[str], Mapping[str, str]], 68 | ] 69 | RequestFiles = Union[Mapping[str, FileTypes], Sequence[Tuple[str, FileTypes]]] 70 | 71 | # duplicate of the above but without our custom file support 72 | HttpxFileContent = Union[IO[bytes], bytes] 73 | HttpxFileTypes = Union[ 74 | # file (or bytes) 75 | HttpxFileContent, 76 | # (filename, file (or bytes)) 77 | Tuple[Optional[str], HttpxFileContent], 78 | # (filename, file (or bytes), content_type) 79 | Tuple[Optional[str], HttpxFileContent, Optional[str]], 80 | # (filename, file (or bytes), content_type, headers) 81 | Tuple[Optional[str], HttpxFileContent, Optional[str], Mapping[str, str]], 82 | ] 83 | HttpxRequestFiles = Union[Mapping[str, HttpxFileTypes], Sequence[Tuple[str, HttpxFileTypes]]] 84 | 85 | # Workaround to support (cast_to: Type[ResponseT]) -> ResponseT 86 | # where ResponseT includes `None`. In order to support directly 87 | # passing `None`, overloads would have to be defined for every 88 | # method that uses `ResponseT` which would lead to an unacceptable 89 | # amount of code duplication and make it unreadable. See _base_client.py 90 | # for example usage. 91 | # 92 | # This unfortunately means that you will either have 93 | # to import this type and pass it explicitly: 94 | # 95 | # from lumaai import NoneType 96 | # client.get('/foo', cast_to=NoneType) 97 | # 98 | # or build it yourself: 99 | # 100 | # client.get('/foo', cast_to=type(None)) 101 | if TYPE_CHECKING: 102 | NoneType: Type[None] 103 | else: 104 | NoneType = type(None) 105 | 106 | 107 | class RequestOptions(TypedDict, total=False): 108 | headers: Headers 109 | max_retries: int 110 | timeout: float | Timeout | None 111 | params: Query 112 | extra_json: AnyMapping 113 | idempotency_key: str 114 | follow_redirects: bool 115 | 116 | 117 | # Sentinel class used until PEP 0661 is accepted 118 | class NotGiven: 119 | """ 120 | For parameters with a meaningful None value, we need to distinguish between 121 | the user explicitly passing None, and the user not passing the parameter at 122 | all. 123 | 124 | User code shouldn't need to use not_given directly. 125 | 126 | For example: 127 | 128 | ```py 129 | def create(timeout: Timeout | None | NotGiven = not_given): ... 130 | 131 | 132 | create(timeout=1) # 1s timeout 133 | create(timeout=None) # No timeout 134 | create() # Default timeout behavior 135 | ``` 136 | """ 137 | 138 | def __bool__(self) -> Literal[False]: 139 | return False 140 | 141 | @override 142 | def __repr__(self) -> str: 143 | return "NOT_GIVEN" 144 | 145 | 146 | not_given = NotGiven() 147 | # for backwards compatibility: 148 | NOT_GIVEN = NotGiven() 149 | 150 | 151 | class Omit: 152 | """ 153 | To explicitly omit something from being sent in a request, use `omit`. 154 | 155 | ```py 156 | # as the default `Content-Type` header is `application/json` that will be sent 157 | client.post("/upload/files", files={"file": b"my raw file content"}) 158 | 159 | # you can't explicitly override the header as it has to be dynamically generated 160 | # to look something like: 'multipart/form-data; boundary=0d8382fcf5f8c3be01ca2e11002d2983' 161 | client.post(..., headers={"Content-Type": "multipart/form-data"}) 162 | 163 | # instead you can remove the default `application/json` header by passing omit 164 | client.post(..., headers={"Content-Type": omit}) 165 | ``` 166 | """ 167 | 168 | def __bool__(self) -> Literal[False]: 169 | return False 170 | 171 | 172 | omit = Omit() 173 | 174 | 175 | @runtime_checkable 176 | class ModelBuilderProtocol(Protocol): 177 | @classmethod 178 | def build( 179 | cls: type[_T], 180 | *, 181 | response: Response, 182 | data: object, 183 | ) -> _T: ... 184 | 185 | 186 | Headers = Mapping[str, Union[str, Omit]] 187 | 188 | 189 | class HeadersLikeProtocol(Protocol): 190 | def get(self, __key: str) -> str | None: ... 191 | 192 | 193 | HeadersLike = Union[Headers, HeadersLikeProtocol] 194 | 195 | ResponseT = TypeVar( 196 | "ResponseT", 197 | bound=Union[ 198 | object, 199 | str, 200 | None, 201 | "BaseModel", 202 | List[Any], 203 | Dict[str, Any], 204 | Response, 205 | ModelBuilderProtocol, 206 | "APIResponse[Any]", 207 | "AsyncAPIResponse[Any]", 208 | ], 209 | ) 210 | 211 | StrBytesIntFloat = Union[str, bytes, int, float] 212 | 213 | # Note: copied from Pydantic 214 | # https://github.com/pydantic/pydantic/blob/6f31f8f68ef011f84357330186f603ff295312fd/pydantic/main.py#L79 215 | IncEx: TypeAlias = Union[Set[int], Set[str], Mapping[int, Union["IncEx", bool]], Mapping[str, Union["IncEx", bool]]] 216 | 217 | PostParser = Callable[[Any], Any] 218 | 219 | 220 | @runtime_checkable 221 | class InheritsGeneric(Protocol): 222 | """Represents a type that has inherited from `Generic` 223 | 224 | The `__orig_bases__` property can be used to determine the resolved 225 | type variable for a given base class. 226 | """ 227 | 228 | __orig_bases__: tuple[_GenericAlias] 229 | 230 | 231 | class _GenericAlias(Protocol): 232 | __origin__: type[object] 233 | 234 | 235 | class HttpxSendArgs(TypedDict, total=False): 236 | auth: httpx.Auth 237 | follow_redirects: bool 238 | 239 | 240 | _T_co = TypeVar("_T_co", covariant=True) 241 | 242 | 243 | if TYPE_CHECKING: 244 | # This works because str.__contains__ does not accept object (either in typeshed or at runtime) 245 | # https://github.com/hauntsaninja/useful_types/blob/5e9710f3875107d068e7679fd7fec9cfab0eff3b/useful_types/__init__.py#L285 246 | class SequenceNotStr(Protocol[_T_co]): 247 | @overload 248 | def __getitem__(self, index: SupportsIndex, /) -> _T_co: ... 249 | @overload 250 | def __getitem__(self, index: slice, /) -> Sequence[_T_co]: ... 251 | def __contains__(self, value: object, /) -> bool: ... 252 | def __len__(self) -> int: ... 253 | def __iter__(self) -> Iterator[_T_co]: ... 254 | def index(self, value: Any, start: int = 0, stop: int = ..., /) -> int: ... 255 | def count(self, value: Any, /) -> int: ... 256 | def __reversed__(self) -> Iterator[_T_co]: ... 257 | else: 258 | # just point this to a normal `Sequence` at runtime to avoid having to special case 259 | # deserializing our custom sequence type 260 | SequenceNotStr = Sequence 261 | -------------------------------------------------------------------------------- /tests/test_streaming.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Iterator, AsyncIterator 4 | 5 | import httpx 6 | import pytest 7 | 8 | from lumaai import LumaAI, AsyncLumaAI 9 | from lumaai._streaming import Stream, AsyncStream, ServerSentEvent 10 | 11 | 12 | @pytest.mark.asyncio 13 | @pytest.mark.parametrize("sync", [True, False], ids=["sync", "async"]) 14 | async def test_basic(sync: bool, client: LumaAI, async_client: AsyncLumaAI) -> None: 15 | def body() -> Iterator[bytes]: 16 | yield b"event: completion\n" 17 | yield b'data: {"foo":true}\n' 18 | yield b"\n" 19 | 20 | iterator = make_event_iterator(content=body(), sync=sync, client=client, async_client=async_client) 21 | 22 | sse = await iter_next(iterator) 23 | assert sse.event == "completion" 24 | assert sse.json() == {"foo": True} 25 | 26 | await assert_empty_iter(iterator) 27 | 28 | 29 | @pytest.mark.asyncio 30 | @pytest.mark.parametrize("sync", [True, False], ids=["sync", "async"]) 31 | async def test_data_missing_event(sync: bool, client: LumaAI, async_client: AsyncLumaAI) -> None: 32 | def body() -> Iterator[bytes]: 33 | yield b'data: {"foo":true}\n' 34 | yield b"\n" 35 | 36 | iterator = make_event_iterator(content=body(), sync=sync, client=client, async_client=async_client) 37 | 38 | sse = await iter_next(iterator) 39 | assert sse.event is None 40 | assert sse.json() == {"foo": True} 41 | 42 | await assert_empty_iter(iterator) 43 | 44 | 45 | @pytest.mark.asyncio 46 | @pytest.mark.parametrize("sync", [True, False], ids=["sync", "async"]) 47 | async def test_event_missing_data(sync: bool, client: LumaAI, async_client: AsyncLumaAI) -> None: 48 | def body() -> Iterator[bytes]: 49 | yield b"event: ping\n" 50 | yield b"\n" 51 | 52 | iterator = make_event_iterator(content=body(), sync=sync, client=client, async_client=async_client) 53 | 54 | sse = await iter_next(iterator) 55 | assert sse.event == "ping" 56 | assert sse.data == "" 57 | 58 | await assert_empty_iter(iterator) 59 | 60 | 61 | @pytest.mark.asyncio 62 | @pytest.mark.parametrize("sync", [True, False], ids=["sync", "async"]) 63 | async def test_multiple_events(sync: bool, client: LumaAI, async_client: AsyncLumaAI) -> None: 64 | def body() -> Iterator[bytes]: 65 | yield b"event: ping\n" 66 | yield b"\n" 67 | yield b"event: completion\n" 68 | yield b"\n" 69 | 70 | iterator = make_event_iterator(content=body(), sync=sync, client=client, async_client=async_client) 71 | 72 | sse = await iter_next(iterator) 73 | assert sse.event == "ping" 74 | assert sse.data == "" 75 | 76 | sse = await iter_next(iterator) 77 | assert sse.event == "completion" 78 | assert sse.data == "" 79 | 80 | await assert_empty_iter(iterator) 81 | 82 | 83 | @pytest.mark.asyncio 84 | @pytest.mark.parametrize("sync", [True, False], ids=["sync", "async"]) 85 | async def test_multiple_events_with_data(sync: bool, client: LumaAI, async_client: AsyncLumaAI) -> None: 86 | def body() -> Iterator[bytes]: 87 | yield b"event: ping\n" 88 | yield b'data: {"foo":true}\n' 89 | yield b"\n" 90 | yield b"event: completion\n" 91 | yield b'data: {"bar":false}\n' 92 | yield b"\n" 93 | 94 | iterator = make_event_iterator(content=body(), sync=sync, client=client, async_client=async_client) 95 | 96 | sse = await iter_next(iterator) 97 | assert sse.event == "ping" 98 | assert sse.json() == {"foo": True} 99 | 100 | sse = await iter_next(iterator) 101 | assert sse.event == "completion" 102 | assert sse.json() == {"bar": False} 103 | 104 | await assert_empty_iter(iterator) 105 | 106 | 107 | @pytest.mark.asyncio 108 | @pytest.mark.parametrize("sync", [True, False], ids=["sync", "async"]) 109 | async def test_multiple_data_lines_with_empty_line(sync: bool, client: LumaAI, async_client: AsyncLumaAI) -> None: 110 | def body() -> Iterator[bytes]: 111 | yield b"event: ping\n" 112 | yield b"data: {\n" 113 | yield b'data: "foo":\n' 114 | yield b"data: \n" 115 | yield b"data:\n" 116 | yield b"data: true}\n" 117 | yield b"\n\n" 118 | 119 | iterator = make_event_iterator(content=body(), sync=sync, client=client, async_client=async_client) 120 | 121 | sse = await iter_next(iterator) 122 | assert sse.event == "ping" 123 | assert sse.json() == {"foo": True} 124 | assert sse.data == '{\n"foo":\n\n\ntrue}' 125 | 126 | await assert_empty_iter(iterator) 127 | 128 | 129 | @pytest.mark.asyncio 130 | @pytest.mark.parametrize("sync", [True, False], ids=["sync", "async"]) 131 | async def test_data_json_escaped_double_new_line(sync: bool, client: LumaAI, async_client: AsyncLumaAI) -> None: 132 | def body() -> Iterator[bytes]: 133 | yield b"event: ping\n" 134 | yield b'data: {"foo": "my long\\n\\ncontent"}' 135 | yield b"\n\n" 136 | 137 | iterator = make_event_iterator(content=body(), sync=sync, client=client, async_client=async_client) 138 | 139 | sse = await iter_next(iterator) 140 | assert sse.event == "ping" 141 | assert sse.json() == {"foo": "my long\n\ncontent"} 142 | 143 | await assert_empty_iter(iterator) 144 | 145 | 146 | @pytest.mark.asyncio 147 | @pytest.mark.parametrize("sync", [True, False], ids=["sync", "async"]) 148 | async def test_multiple_data_lines(sync: bool, client: LumaAI, async_client: AsyncLumaAI) -> None: 149 | def body() -> Iterator[bytes]: 150 | yield b"event: ping\n" 151 | yield b"data: {\n" 152 | yield b'data: "foo":\n' 153 | yield b"data: true}\n" 154 | yield b"\n\n" 155 | 156 | iterator = make_event_iterator(content=body(), sync=sync, client=client, async_client=async_client) 157 | 158 | sse = await iter_next(iterator) 159 | assert sse.event == "ping" 160 | assert sse.json() == {"foo": True} 161 | 162 | await assert_empty_iter(iterator) 163 | 164 | 165 | @pytest.mark.parametrize("sync", [True, False], ids=["sync", "async"]) 166 | async def test_special_new_line_character( 167 | sync: bool, 168 | client: LumaAI, 169 | async_client: AsyncLumaAI, 170 | ) -> None: 171 | def body() -> Iterator[bytes]: 172 | yield b'data: {"content":" culpa"}\n' 173 | yield b"\n" 174 | yield b'data: {"content":" \xe2\x80\xa8"}\n' 175 | yield b"\n" 176 | yield b'data: {"content":"foo"}\n' 177 | yield b"\n" 178 | 179 | iterator = make_event_iterator(content=body(), sync=sync, client=client, async_client=async_client) 180 | 181 | sse = await iter_next(iterator) 182 | assert sse.event is None 183 | assert sse.json() == {"content": " culpa"} 184 | 185 | sse = await iter_next(iterator) 186 | assert sse.event is None 187 | assert sse.json() == {"content": " 
"} 188 | 189 | sse = await iter_next(iterator) 190 | assert sse.event is None 191 | assert sse.json() == {"content": "foo"} 192 | 193 | await assert_empty_iter(iterator) 194 | 195 | 196 | @pytest.mark.parametrize("sync", [True, False], ids=["sync", "async"]) 197 | async def test_multi_byte_character_multiple_chunks( 198 | sync: bool, 199 | client: LumaAI, 200 | async_client: AsyncLumaAI, 201 | ) -> None: 202 | def body() -> Iterator[bytes]: 203 | yield b'data: {"content":"' 204 | # bytes taken from the string 'известни' and arbitrarily split 205 | # so that some multi-byte characters span multiple chunks 206 | yield b"\xd0" 207 | yield b"\xb8\xd0\xb7\xd0" 208 | yield b"\xb2\xd0\xb5\xd1\x81\xd1\x82\xd0\xbd\xd0\xb8" 209 | yield b'"}\n' 210 | yield b"\n" 211 | 212 | iterator = make_event_iterator(content=body(), sync=sync, client=client, async_client=async_client) 213 | 214 | sse = await iter_next(iterator) 215 | assert sse.event is None 216 | assert sse.json() == {"content": "известни"} 217 | 218 | 219 | async def to_aiter(iter: Iterator[bytes]) -> AsyncIterator[bytes]: 220 | for chunk in iter: 221 | yield chunk 222 | 223 | 224 | async def iter_next(iter: Iterator[ServerSentEvent] | AsyncIterator[ServerSentEvent]) -> ServerSentEvent: 225 | if isinstance(iter, AsyncIterator): 226 | return await iter.__anext__() 227 | 228 | return next(iter) 229 | 230 | 231 | async def assert_empty_iter(iter: Iterator[ServerSentEvent] | AsyncIterator[ServerSentEvent]) -> None: 232 | with pytest.raises((StopAsyncIteration, RuntimeError)): 233 | await iter_next(iter) 234 | 235 | 236 | def make_event_iterator( 237 | content: Iterator[bytes], 238 | *, 239 | sync: bool, 240 | client: LumaAI, 241 | async_client: AsyncLumaAI, 242 | ) -> Iterator[ServerSentEvent] | AsyncIterator[ServerSentEvent]: 243 | if sync: 244 | return Stream(cast_to=object, client=client, response=httpx.Response(200, content=content))._iter_events() 245 | 246 | return AsyncStream( 247 | cast_to=object, client=async_client, response=httpx.Response(200, content=to_aiter(content)) 248 | )._iter_events() 249 | -------------------------------------------------------------------------------- /tests/test_response.py: -------------------------------------------------------------------------------- 1 | import json 2 | from typing import Any, List, Union, cast 3 | from typing_extensions import Annotated 4 | 5 | import httpx 6 | import pytest 7 | import pydantic 8 | 9 | from lumaai import LumaAI, BaseModel, AsyncLumaAI 10 | from lumaai._response import ( 11 | APIResponse, 12 | BaseAPIResponse, 13 | AsyncAPIResponse, 14 | BinaryAPIResponse, 15 | AsyncBinaryAPIResponse, 16 | extract_response_type, 17 | ) 18 | from lumaai._streaming import Stream 19 | from lumaai._base_client import FinalRequestOptions 20 | 21 | 22 | class ConcreteBaseAPIResponse(APIResponse[bytes]): ... 23 | 24 | 25 | class ConcreteAPIResponse(APIResponse[List[str]]): ... 26 | 27 | 28 | class ConcreteAsyncAPIResponse(APIResponse[httpx.Response]): ... 29 | 30 | 31 | def test_extract_response_type_direct_classes() -> None: 32 | assert extract_response_type(BaseAPIResponse[str]) == str 33 | assert extract_response_type(APIResponse[str]) == str 34 | assert extract_response_type(AsyncAPIResponse[str]) == str 35 | 36 | 37 | def test_extract_response_type_direct_class_missing_type_arg() -> None: 38 | with pytest.raises( 39 | RuntimeError, 40 | match="Expected type to have a type argument at index 0 but it did not", 41 | ): 42 | extract_response_type(AsyncAPIResponse) 43 | 44 | 45 | def test_extract_response_type_concrete_subclasses() -> None: 46 | assert extract_response_type(ConcreteBaseAPIResponse) == bytes 47 | assert extract_response_type(ConcreteAPIResponse) == List[str] 48 | assert extract_response_type(ConcreteAsyncAPIResponse) == httpx.Response 49 | 50 | 51 | def test_extract_response_type_binary_response() -> None: 52 | assert extract_response_type(BinaryAPIResponse) == bytes 53 | assert extract_response_type(AsyncBinaryAPIResponse) == bytes 54 | 55 | 56 | class PydanticModel(pydantic.BaseModel): ... 57 | 58 | 59 | def test_response_parse_mismatched_basemodel(client: LumaAI) -> None: 60 | response = APIResponse( 61 | raw=httpx.Response(200, content=b"foo"), 62 | client=client, 63 | stream=False, 64 | stream_cls=None, 65 | cast_to=str, 66 | options=FinalRequestOptions.construct(method="get", url="/foo"), 67 | ) 68 | 69 | with pytest.raises( 70 | TypeError, 71 | match="Pydantic models must subclass our base model type, e.g. `from lumaai import BaseModel`", 72 | ): 73 | response.parse(to=PydanticModel) 74 | 75 | 76 | @pytest.mark.asyncio 77 | async def test_async_response_parse_mismatched_basemodel(async_client: AsyncLumaAI) -> None: 78 | response = AsyncAPIResponse( 79 | raw=httpx.Response(200, content=b"foo"), 80 | client=async_client, 81 | stream=False, 82 | stream_cls=None, 83 | cast_to=str, 84 | options=FinalRequestOptions.construct(method="get", url="/foo"), 85 | ) 86 | 87 | with pytest.raises( 88 | TypeError, 89 | match="Pydantic models must subclass our base model type, e.g. `from lumaai import BaseModel`", 90 | ): 91 | await response.parse(to=PydanticModel) 92 | 93 | 94 | def test_response_parse_custom_stream(client: LumaAI) -> None: 95 | response = APIResponse( 96 | raw=httpx.Response(200, content=b"foo"), 97 | client=client, 98 | stream=True, 99 | stream_cls=None, 100 | cast_to=str, 101 | options=FinalRequestOptions.construct(method="get", url="/foo"), 102 | ) 103 | 104 | stream = response.parse(to=Stream[int]) 105 | assert stream._cast_to == int 106 | 107 | 108 | @pytest.mark.asyncio 109 | async def test_async_response_parse_custom_stream(async_client: AsyncLumaAI) -> None: 110 | response = AsyncAPIResponse( 111 | raw=httpx.Response(200, content=b"foo"), 112 | client=async_client, 113 | stream=True, 114 | stream_cls=None, 115 | cast_to=str, 116 | options=FinalRequestOptions.construct(method="get", url="/foo"), 117 | ) 118 | 119 | stream = await response.parse(to=Stream[int]) 120 | assert stream._cast_to == int 121 | 122 | 123 | class CustomModel(BaseModel): 124 | foo: str 125 | bar: int 126 | 127 | 128 | def test_response_parse_custom_model(client: LumaAI) -> None: 129 | response = APIResponse( 130 | raw=httpx.Response(200, content=json.dumps({"foo": "hello!", "bar": 2})), 131 | client=client, 132 | stream=False, 133 | stream_cls=None, 134 | cast_to=str, 135 | options=FinalRequestOptions.construct(method="get", url="/foo"), 136 | ) 137 | 138 | obj = response.parse(to=CustomModel) 139 | assert obj.foo == "hello!" 140 | assert obj.bar == 2 141 | 142 | 143 | @pytest.mark.asyncio 144 | async def test_async_response_parse_custom_model(async_client: AsyncLumaAI) -> None: 145 | response = AsyncAPIResponse( 146 | raw=httpx.Response(200, content=json.dumps({"foo": "hello!", "bar": 2})), 147 | client=async_client, 148 | stream=False, 149 | stream_cls=None, 150 | cast_to=str, 151 | options=FinalRequestOptions.construct(method="get", url="/foo"), 152 | ) 153 | 154 | obj = await response.parse(to=CustomModel) 155 | assert obj.foo == "hello!" 156 | assert obj.bar == 2 157 | 158 | 159 | def test_response_parse_annotated_type(client: LumaAI) -> None: 160 | response = APIResponse( 161 | raw=httpx.Response(200, content=json.dumps({"foo": "hello!", "bar": 2})), 162 | client=client, 163 | stream=False, 164 | stream_cls=None, 165 | cast_to=str, 166 | options=FinalRequestOptions.construct(method="get", url="/foo"), 167 | ) 168 | 169 | obj = response.parse( 170 | to=cast("type[CustomModel]", Annotated[CustomModel, "random metadata"]), 171 | ) 172 | assert obj.foo == "hello!" 173 | assert obj.bar == 2 174 | 175 | 176 | async def test_async_response_parse_annotated_type(async_client: AsyncLumaAI) -> None: 177 | response = AsyncAPIResponse( 178 | raw=httpx.Response(200, content=json.dumps({"foo": "hello!", "bar": 2})), 179 | client=async_client, 180 | stream=False, 181 | stream_cls=None, 182 | cast_to=str, 183 | options=FinalRequestOptions.construct(method="get", url="/foo"), 184 | ) 185 | 186 | obj = await response.parse( 187 | to=cast("type[CustomModel]", Annotated[CustomModel, "random metadata"]), 188 | ) 189 | assert obj.foo == "hello!" 190 | assert obj.bar == 2 191 | 192 | 193 | @pytest.mark.parametrize( 194 | "content, expected", 195 | [ 196 | ("false", False), 197 | ("true", True), 198 | ("False", False), 199 | ("True", True), 200 | ("TrUe", True), 201 | ("FalSe", False), 202 | ], 203 | ) 204 | def test_response_parse_bool(client: LumaAI, content: str, expected: bool) -> None: 205 | response = APIResponse( 206 | raw=httpx.Response(200, content=content), 207 | client=client, 208 | stream=False, 209 | stream_cls=None, 210 | cast_to=str, 211 | options=FinalRequestOptions.construct(method="get", url="/foo"), 212 | ) 213 | 214 | result = response.parse(to=bool) 215 | assert result is expected 216 | 217 | 218 | @pytest.mark.parametrize( 219 | "content, expected", 220 | [ 221 | ("false", False), 222 | ("true", True), 223 | ("False", False), 224 | ("True", True), 225 | ("TrUe", True), 226 | ("FalSe", False), 227 | ], 228 | ) 229 | async def test_async_response_parse_bool(client: AsyncLumaAI, content: str, expected: bool) -> None: 230 | response = AsyncAPIResponse( 231 | raw=httpx.Response(200, content=content), 232 | client=client, 233 | stream=False, 234 | stream_cls=None, 235 | cast_to=str, 236 | options=FinalRequestOptions.construct(method="get", url="/foo"), 237 | ) 238 | 239 | result = await response.parse(to=bool) 240 | assert result is expected 241 | 242 | 243 | class OtherModel(BaseModel): 244 | a: str 245 | 246 | 247 | @pytest.mark.parametrize("client", [False], indirect=True) # loose validation 248 | def test_response_parse_expect_model_union_non_json_content(client: LumaAI) -> None: 249 | response = APIResponse( 250 | raw=httpx.Response(200, content=b"foo", headers={"Content-Type": "application/text"}), 251 | client=client, 252 | stream=False, 253 | stream_cls=None, 254 | cast_to=str, 255 | options=FinalRequestOptions.construct(method="get", url="/foo"), 256 | ) 257 | 258 | obj = response.parse(to=cast(Any, Union[CustomModel, OtherModel])) 259 | assert isinstance(obj, str) 260 | assert obj == "foo" 261 | 262 | 263 | @pytest.mark.asyncio 264 | @pytest.mark.parametrize("async_client", [False], indirect=True) # loose validation 265 | async def test_async_response_parse_expect_model_union_non_json_content(async_client: AsyncLumaAI) -> None: 266 | response = AsyncAPIResponse( 267 | raw=httpx.Response(200, content=b"foo", headers={"Content-Type": "application/text"}), 268 | client=async_client, 269 | stream=False, 270 | stream_cls=None, 271 | cast_to=str, 272 | options=FinalRequestOptions.construct(method="get", url="/foo"), 273 | ) 274 | 275 | obj = await response.parse(to=cast(Any, Union[CustomModel, OtherModel])) 276 | assert isinstance(obj, str) 277 | assert obj == "foo" 278 | -------------------------------------------------------------------------------- /tests/api_resources/generations/test_image.py: -------------------------------------------------------------------------------- 1 | # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. 2 | 3 | from __future__ import annotations 4 | 5 | import os 6 | from typing import Any, cast 7 | 8 | import pytest 9 | 10 | from lumaai import LumaAI, AsyncLumaAI 11 | from tests.utils import assert_matches_type 12 | from lumaai.types import Generation 13 | 14 | base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") 15 | 16 | 17 | class TestImage: 18 | parametrize = pytest.mark.parametrize("client", [False, True], indirect=True, ids=["loose", "strict"]) 19 | 20 | @parametrize 21 | def test_method_create(self, client: LumaAI) -> None: 22 | image = client.generations.image.create( 23 | model="photon-1", 24 | ) 25 | assert_matches_type(Generation, image, path=["response"]) 26 | 27 | @parametrize 28 | def test_method_create_with_all_params(self, client: LumaAI) -> None: 29 | image = client.generations.image.create( 30 | model="photon-1", 31 | aspect_ratio="16:9", 32 | callback_url="https://example.com", 33 | character_ref={"identity0": {"images": ["https://example.com"]}}, 34 | format="jpg", 35 | generation_type="image", 36 | image_ref=[ 37 | { 38 | "url": "https://example.com", 39 | "weight": 0, 40 | } 41 | ], 42 | modify_image_ref={ 43 | "url": "https://example.com", 44 | "weight": 0, 45 | }, 46 | prompt="prompt", 47 | style_ref=[ 48 | { 49 | "url": "https://example.com", 50 | "weight": 0, 51 | } 52 | ], 53 | sync=True, 54 | sync_timeout=0, 55 | ) 56 | assert_matches_type(Generation, image, path=["response"]) 57 | 58 | @parametrize 59 | def test_raw_response_create(self, client: LumaAI) -> None: 60 | response = client.generations.image.with_raw_response.create( 61 | model="photon-1", 62 | ) 63 | 64 | assert response.is_closed is True 65 | assert response.http_request.headers.get("X-Stainless-Lang") == "python" 66 | image = response.parse() 67 | assert_matches_type(Generation, image, path=["response"]) 68 | 69 | @parametrize 70 | def test_streaming_response_create(self, client: LumaAI) -> None: 71 | with client.generations.image.with_streaming_response.create( 72 | model="photon-1", 73 | ) as response: 74 | assert not response.is_closed 75 | assert response.http_request.headers.get("X-Stainless-Lang") == "python" 76 | 77 | image = response.parse() 78 | assert_matches_type(Generation, image, path=["response"]) 79 | 80 | assert cast(Any, response.is_closed) is True 81 | 82 | @parametrize 83 | def test_method_reframe(self, client: LumaAI) -> None: 84 | image = client.generations.image.reframe( 85 | aspect_ratio="16:9", 86 | generation_type="reframe_image", 87 | media={"url": "https://example.com"}, 88 | model="photon-1", 89 | ) 90 | assert_matches_type(Generation, image, path=["response"]) 91 | 92 | @parametrize 93 | def test_method_reframe_with_all_params(self, client: LumaAI) -> None: 94 | image = client.generations.image.reframe( 95 | aspect_ratio="16:9", 96 | generation_type="reframe_image", 97 | media={"url": "https://example.com"}, 98 | model="photon-1", 99 | callback_url="https://example.com", 100 | format="jpg", 101 | grid_position_x=0, 102 | grid_position_y=0, 103 | prompt="prompt", 104 | resized_height=0, 105 | resized_width=0, 106 | x_end=0, 107 | x_start=0, 108 | y_end=0, 109 | y_start=0, 110 | ) 111 | assert_matches_type(Generation, image, path=["response"]) 112 | 113 | @parametrize 114 | def test_raw_response_reframe(self, client: LumaAI) -> None: 115 | response = client.generations.image.with_raw_response.reframe( 116 | aspect_ratio="16:9", 117 | generation_type="reframe_image", 118 | media={"url": "https://example.com"}, 119 | model="photon-1", 120 | ) 121 | 122 | assert response.is_closed is True 123 | assert response.http_request.headers.get("X-Stainless-Lang") == "python" 124 | image = response.parse() 125 | assert_matches_type(Generation, image, path=["response"]) 126 | 127 | @parametrize 128 | def test_streaming_response_reframe(self, client: LumaAI) -> None: 129 | with client.generations.image.with_streaming_response.reframe( 130 | aspect_ratio="16:9", 131 | generation_type="reframe_image", 132 | media={"url": "https://example.com"}, 133 | model="photon-1", 134 | ) as response: 135 | assert not response.is_closed 136 | assert response.http_request.headers.get("X-Stainless-Lang") == "python" 137 | 138 | image = response.parse() 139 | assert_matches_type(Generation, image, path=["response"]) 140 | 141 | assert cast(Any, response.is_closed) is True 142 | 143 | 144 | class TestAsyncImage: 145 | parametrize = pytest.mark.parametrize( 146 | "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] 147 | ) 148 | 149 | @parametrize 150 | async def test_method_create(self, async_client: AsyncLumaAI) -> None: 151 | image = await async_client.generations.image.create( 152 | model="photon-1", 153 | ) 154 | assert_matches_type(Generation, image, path=["response"]) 155 | 156 | @parametrize 157 | async def test_method_create_with_all_params(self, async_client: AsyncLumaAI) -> None: 158 | image = await async_client.generations.image.create( 159 | model="photon-1", 160 | aspect_ratio="16:9", 161 | callback_url="https://example.com", 162 | character_ref={"identity0": {"images": ["https://example.com"]}}, 163 | format="jpg", 164 | generation_type="image", 165 | image_ref=[ 166 | { 167 | "url": "https://example.com", 168 | "weight": 0, 169 | } 170 | ], 171 | modify_image_ref={ 172 | "url": "https://example.com", 173 | "weight": 0, 174 | }, 175 | prompt="prompt", 176 | style_ref=[ 177 | { 178 | "url": "https://example.com", 179 | "weight": 0, 180 | } 181 | ], 182 | sync=True, 183 | sync_timeout=0, 184 | ) 185 | assert_matches_type(Generation, image, path=["response"]) 186 | 187 | @parametrize 188 | async def test_raw_response_create(self, async_client: AsyncLumaAI) -> None: 189 | response = await async_client.generations.image.with_raw_response.create( 190 | model="photon-1", 191 | ) 192 | 193 | assert response.is_closed is True 194 | assert response.http_request.headers.get("X-Stainless-Lang") == "python" 195 | image = await response.parse() 196 | assert_matches_type(Generation, image, path=["response"]) 197 | 198 | @parametrize 199 | async def test_streaming_response_create(self, async_client: AsyncLumaAI) -> None: 200 | async with async_client.generations.image.with_streaming_response.create( 201 | model="photon-1", 202 | ) as response: 203 | assert not response.is_closed 204 | assert response.http_request.headers.get("X-Stainless-Lang") == "python" 205 | 206 | image = await response.parse() 207 | assert_matches_type(Generation, image, path=["response"]) 208 | 209 | assert cast(Any, response.is_closed) is True 210 | 211 | @parametrize 212 | async def test_method_reframe(self, async_client: AsyncLumaAI) -> None: 213 | image = await async_client.generations.image.reframe( 214 | aspect_ratio="16:9", 215 | generation_type="reframe_image", 216 | media={"url": "https://example.com"}, 217 | model="photon-1", 218 | ) 219 | assert_matches_type(Generation, image, path=["response"]) 220 | 221 | @parametrize 222 | async def test_method_reframe_with_all_params(self, async_client: AsyncLumaAI) -> None: 223 | image = await async_client.generations.image.reframe( 224 | aspect_ratio="16:9", 225 | generation_type="reframe_image", 226 | media={"url": "https://example.com"}, 227 | model="photon-1", 228 | callback_url="https://example.com", 229 | format="jpg", 230 | grid_position_x=0, 231 | grid_position_y=0, 232 | prompt="prompt", 233 | resized_height=0, 234 | resized_width=0, 235 | x_end=0, 236 | x_start=0, 237 | y_end=0, 238 | y_start=0, 239 | ) 240 | assert_matches_type(Generation, image, path=["response"]) 241 | 242 | @parametrize 243 | async def test_raw_response_reframe(self, async_client: AsyncLumaAI) -> None: 244 | response = await async_client.generations.image.with_raw_response.reframe( 245 | aspect_ratio="16:9", 246 | generation_type="reframe_image", 247 | media={"url": "https://example.com"}, 248 | model="photon-1", 249 | ) 250 | 251 | assert response.is_closed is True 252 | assert response.http_request.headers.get("X-Stainless-Lang") == "python" 253 | image = await response.parse() 254 | assert_matches_type(Generation, image, path=["response"]) 255 | 256 | @parametrize 257 | async def test_streaming_response_reframe(self, async_client: AsyncLumaAI) -> None: 258 | async with async_client.generations.image.with_streaming_response.reframe( 259 | aspect_ratio="16:9", 260 | generation_type="reframe_image", 261 | media={"url": "https://example.com"}, 262 | model="photon-1", 263 | ) as response: 264 | assert not response.is_closed 265 | assert response.http_request.headers.get("X-Stainless-Lang") == "python" 266 | 267 | image = await response.parse() 268 | assert_matches_type(Generation, image, path=["response"]) 269 | 270 | assert cast(Any, response.is_closed) is True 271 | -------------------------------------------------------------------------------- /src/lumaai/_streaming.py: -------------------------------------------------------------------------------- 1 | # Note: initially copied from https://github.com/florimondmanca/httpx-sse/blob/master/src/httpx_sse/_decoders.py 2 | from __future__ import annotations 3 | 4 | import json 5 | import inspect 6 | from types import TracebackType 7 | from typing import TYPE_CHECKING, Any, Generic, TypeVar, Iterator, AsyncIterator, cast 8 | from typing_extensions import Self, Protocol, TypeGuard, override, get_origin, runtime_checkable 9 | 10 | import httpx 11 | 12 | from ._utils import extract_type_var_from_base 13 | 14 | if TYPE_CHECKING: 15 | from ._client import LumaAI, AsyncLumaAI 16 | 17 | 18 | _T = TypeVar("_T") 19 | 20 | 21 | class Stream(Generic[_T]): 22 | """Provides the core interface to iterate over a synchronous stream response.""" 23 | 24 | response: httpx.Response 25 | 26 | _decoder: SSEBytesDecoder 27 | 28 | def __init__( 29 | self, 30 | *, 31 | cast_to: type[_T], 32 | response: httpx.Response, 33 | client: LumaAI, 34 | ) -> None: 35 | self.response = response 36 | self._cast_to = cast_to 37 | self._client = client 38 | self._decoder = client._make_sse_decoder() 39 | self._iterator = self.__stream__() 40 | 41 | def __next__(self) -> _T: 42 | return self._iterator.__next__() 43 | 44 | def __iter__(self) -> Iterator[_T]: 45 | for item in self._iterator: 46 | yield item 47 | 48 | def _iter_events(self) -> Iterator[ServerSentEvent]: 49 | yield from self._decoder.iter_bytes(self.response.iter_bytes()) 50 | 51 | def __stream__(self) -> Iterator[_T]: 52 | cast_to = cast(Any, self._cast_to) 53 | response = self.response 54 | process_data = self._client._process_response_data 55 | iterator = self._iter_events() 56 | 57 | for sse in iterator: 58 | yield process_data(data=sse.json(), cast_to=cast_to, response=response) 59 | 60 | # Ensure the entire stream is consumed 61 | for _sse in iterator: 62 | ... 63 | 64 | def __enter__(self) -> Self: 65 | return self 66 | 67 | def __exit__( 68 | self, 69 | exc_type: type[BaseException] | None, 70 | exc: BaseException | None, 71 | exc_tb: TracebackType | None, 72 | ) -> None: 73 | self.close() 74 | 75 | def close(self) -> None: 76 | """ 77 | Close the response and release the connection. 78 | 79 | Automatically called if the response body is read to completion. 80 | """ 81 | self.response.close() 82 | 83 | 84 | class AsyncStream(Generic[_T]): 85 | """Provides the core interface to iterate over an asynchronous stream response.""" 86 | 87 | response: httpx.Response 88 | 89 | _decoder: SSEDecoder | SSEBytesDecoder 90 | 91 | def __init__( 92 | self, 93 | *, 94 | cast_to: type[_T], 95 | response: httpx.Response, 96 | client: AsyncLumaAI, 97 | ) -> None: 98 | self.response = response 99 | self._cast_to = cast_to 100 | self._client = client 101 | self._decoder = client._make_sse_decoder() 102 | self._iterator = self.__stream__() 103 | 104 | async def __anext__(self) -> _T: 105 | return await self._iterator.__anext__() 106 | 107 | async def __aiter__(self) -> AsyncIterator[_T]: 108 | async for item in self._iterator: 109 | yield item 110 | 111 | async def _iter_events(self) -> AsyncIterator[ServerSentEvent]: 112 | async for sse in self._decoder.aiter_bytes(self.response.aiter_bytes()): 113 | yield sse 114 | 115 | async def __stream__(self) -> AsyncIterator[_T]: 116 | cast_to = cast(Any, self._cast_to) 117 | response = self.response 118 | process_data = self._client._process_response_data 119 | iterator = self._iter_events() 120 | 121 | async for sse in iterator: 122 | yield process_data(data=sse.json(), cast_to=cast_to, response=response) 123 | 124 | # Ensure the entire stream is consumed 125 | async for _sse in iterator: 126 | ... 127 | 128 | async def __aenter__(self) -> Self: 129 | return self 130 | 131 | async def __aexit__( 132 | self, 133 | exc_type: type[BaseException] | None, 134 | exc: BaseException | None, 135 | exc_tb: TracebackType | None, 136 | ) -> None: 137 | await self.close() 138 | 139 | async def close(self) -> None: 140 | """ 141 | Close the response and release the connection. 142 | 143 | Automatically called if the response body is read to completion. 144 | """ 145 | await self.response.aclose() 146 | 147 | 148 | class ServerSentEvent: 149 | def __init__( 150 | self, 151 | *, 152 | event: str | None = None, 153 | data: str | None = None, 154 | id: str | None = None, 155 | retry: int | None = None, 156 | ) -> None: 157 | if data is None: 158 | data = "" 159 | 160 | self._id = id 161 | self._data = data 162 | self._event = event or None 163 | self._retry = retry 164 | 165 | @property 166 | def event(self) -> str | None: 167 | return self._event 168 | 169 | @property 170 | def id(self) -> str | None: 171 | return self._id 172 | 173 | @property 174 | def retry(self) -> int | None: 175 | return self._retry 176 | 177 | @property 178 | def data(self) -> str: 179 | return self._data 180 | 181 | def json(self) -> Any: 182 | return json.loads(self.data) 183 | 184 | @override 185 | def __repr__(self) -> str: 186 | return f"ServerSentEvent(event={self.event}, data={self.data}, id={self.id}, retry={self.retry})" 187 | 188 | 189 | class SSEDecoder: 190 | _data: list[str] 191 | _event: str | None 192 | _retry: int | None 193 | _last_event_id: str | None 194 | 195 | def __init__(self) -> None: 196 | self._event = None 197 | self._data = [] 198 | self._last_event_id = None 199 | self._retry = None 200 | 201 | def iter_bytes(self, iterator: Iterator[bytes]) -> Iterator[ServerSentEvent]: 202 | """Given an iterator that yields raw binary data, iterate over it & yield every event encountered""" 203 | for chunk in self._iter_chunks(iterator): 204 | # Split before decoding so splitlines() only uses \r and \n 205 | for raw_line in chunk.splitlines(): 206 | line = raw_line.decode("utf-8") 207 | sse = self.decode(line) 208 | if sse: 209 | yield sse 210 | 211 | def _iter_chunks(self, iterator: Iterator[bytes]) -> Iterator[bytes]: 212 | """Given an iterator that yields raw binary data, iterate over it and yield individual SSE chunks""" 213 | data = b"" 214 | for chunk in iterator: 215 | for line in chunk.splitlines(keepends=True): 216 | data += line 217 | if data.endswith((b"\r\r", b"\n\n", b"\r\n\r\n")): 218 | yield data 219 | data = b"" 220 | if data: 221 | yield data 222 | 223 | async def aiter_bytes(self, iterator: AsyncIterator[bytes]) -> AsyncIterator[ServerSentEvent]: 224 | """Given an iterator that yields raw binary data, iterate over it & yield every event encountered""" 225 | async for chunk in self._aiter_chunks(iterator): 226 | # Split before decoding so splitlines() only uses \r and \n 227 | for raw_line in chunk.splitlines(): 228 | line = raw_line.decode("utf-8") 229 | sse = self.decode(line) 230 | if sse: 231 | yield sse 232 | 233 | async def _aiter_chunks(self, iterator: AsyncIterator[bytes]) -> AsyncIterator[bytes]: 234 | """Given an iterator that yields raw binary data, iterate over it and yield individual SSE chunks""" 235 | data = b"" 236 | async for chunk in iterator: 237 | for line in chunk.splitlines(keepends=True): 238 | data += line 239 | if data.endswith((b"\r\r", b"\n\n", b"\r\n\r\n")): 240 | yield data 241 | data = b"" 242 | if data: 243 | yield data 244 | 245 | def decode(self, line: str) -> ServerSentEvent | None: 246 | # See: https://html.spec.whatwg.org/multipage/server-sent-events.html#event-stream-interpretation # noqa: E501 247 | 248 | if not line: 249 | if not self._event and not self._data and not self._last_event_id and self._retry is None: 250 | return None 251 | 252 | sse = ServerSentEvent( 253 | event=self._event, 254 | data="\n".join(self._data), 255 | id=self._last_event_id, 256 | retry=self._retry, 257 | ) 258 | 259 | # NOTE: as per the SSE spec, do not reset last_event_id. 260 | self._event = None 261 | self._data = [] 262 | self._retry = None 263 | 264 | return sse 265 | 266 | if line.startswith(":"): 267 | return None 268 | 269 | fieldname, _, value = line.partition(":") 270 | 271 | if value.startswith(" "): 272 | value = value[1:] 273 | 274 | if fieldname == "event": 275 | self._event = value 276 | elif fieldname == "data": 277 | self._data.append(value) 278 | elif fieldname == "id": 279 | if "\0" in value: 280 | pass 281 | else: 282 | self._last_event_id = value 283 | elif fieldname == "retry": 284 | try: 285 | self._retry = int(value) 286 | except (TypeError, ValueError): 287 | pass 288 | else: 289 | pass # Field is ignored. 290 | 291 | return None 292 | 293 | 294 | @runtime_checkable 295 | class SSEBytesDecoder(Protocol): 296 | def iter_bytes(self, iterator: Iterator[bytes]) -> Iterator[ServerSentEvent]: 297 | """Given an iterator that yields raw binary data, iterate over it & yield every event encountered""" 298 | ... 299 | 300 | def aiter_bytes(self, iterator: AsyncIterator[bytes]) -> AsyncIterator[ServerSentEvent]: 301 | """Given an async iterator that yields raw binary data, iterate over it & yield every event encountered""" 302 | ... 303 | 304 | 305 | def is_stream_class_type(typ: type) -> TypeGuard[type[Stream[object]] | type[AsyncStream[object]]]: 306 | """TypeGuard for determining whether or not the given type is a subclass of `Stream` / `AsyncStream`""" 307 | origin = get_origin(typ) or typ 308 | return inspect.isclass(origin) and issubclass(origin, (Stream, AsyncStream)) 309 | 310 | 311 | def extract_stream_chunk_type( 312 | stream_cls: type, 313 | *, 314 | failure_message: str | None = None, 315 | ) -> type: 316 | """Given a type like `Stream[T]`, returns the generic type variable `T`. 317 | 318 | This also handles the case where a concrete subclass is given, e.g. 319 | ```py 320 | class MyStream(Stream[bytes]): 321 | ... 322 | 323 | extract_stream_chunk_type(MyStream) -> bytes 324 | ``` 325 | """ 326 | from ._base_client import Stream, AsyncStream 327 | 328 | return extract_type_var_from_base( 329 | stream_cls, 330 | index=0, 331 | generic_bases=cast("tuple[type, ...]", (Stream, AsyncStream)), 332 | failure_message=failure_message, 333 | ) 334 | --------------------------------------------------------------------------------