diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d349644..b8f0e08 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -26,12 +26,31 @@ * pytest files which end in `_api_calls.py` run last, and never run during the CI. It is currently incumbent on individual developers to confirm that these tests run successfully locally. In the future, part of the CI will be to spawn an AI-Horde and worker instances and test it there. +## Verifying the horde SDK API surface + +You can run the following: + +```bash +pytest -m "object_verify" +``` + +This will run the tests which validate the objects defined in the SDK are: +- In the appropriate place +- Match the live API (or if `AI_HORDE_DEV_URL` that version of the API) +- That the models are exposed via `__init__.py` +- And will run any other tests which ensure internal consistency. + - This generally does not include specific object validation beyond what can be automatically derived directly from the API docs or from general conventions from the SDK itself. + - If adding objects, you should add tests more specific to the expected functionality of that endpoint and the `object_verify` tests should only be treated as the bare-minimum. + ## When the API adds an endpoint or changes a model With the top level directory (the one that contains `pyproject.toml`) as your working directory: -```python +```bash python horde_sdk/scripts/write_all_payload_examples_for_tests.py python horde_sdk/scripts/write_all_response_examples_for_tests.py +python docs/build_docs.py ``` This will update the data found in `tests/test_data/` from the default horde URL, or if any of the override environment variables are set, from there. -Be sure to run the test suite (without any `*_api_calls.py` tests) after. +Running `build_docs.py` will update any automatically generated mkdocs documentation stubs or resources (such as the API Model <-> SDK Model map). + +Be sure to run the test suite (without any `*_api_calls.py` tests) after. You may also may want to just start with `pytest -m "object_verify"` (see also the section on verifying the horde SDK API surface). diff --git a/docs/api_to_sdk_map.md b/docs/api_to_sdk_map.md index 8f9acf7..0f5a5df 100644 --- a/docs/api_to_sdk_map.md +++ b/docs/api_to_sdk_map.md @@ -38,6 +38,7 @@ This is a mapping of the AI-Horde API models (defined at [https://stablehorde.ne | /v2/users/{user_id} | PUT | [ModifyUserRequest][horde_sdk.ai_horde_api.apimodels._users.ModifyUserRequest] | | /v2/users/{user_id} | GET | [SingleUserDetailsRequest][horde_sdk.ai_horde_api.apimodels._users.SingleUserDetailsRequest] | | /v2/workers | GET | [AllWorkersDetailsRequest][horde_sdk.ai_horde_api.apimodels.workers._workers.AllWorkersDetailsRequest] | +| /v2/workers/name/{worker_name} | GET | [SingleWorkerNameDetailsRequest][horde_sdk.ai_horde_api.apimodels.workers._workers.SingleWorkerNameDetailsRequest] | | /v2/workers/{worker_id} | DELETE | [DeleteWorkerRequest][horde_sdk.ai_horde_api.apimodels.workers._workers.DeleteWorkerRequest] | | /v2/workers/{worker_id} | PUT | [ModifyWorkerRequest][horde_sdk.ai_horde_api.apimodels.workers._workers.ModifyWorkerRequest] | | /v2/workers/{worker_id} | GET | [SingleWorkerDetailsRequest][horde_sdk.ai_horde_api.apimodels.workers._workers.SingleWorkerDetailsRequest] | @@ -78,4 +79,5 @@ This is a mapping of the AI-Horde API models (defined at [https://stablehorde.ne | /v2/users | 200 | [ListUsersDetailsResponse][horde_sdk.ai_horde_api.apimodels._users.ListUsersDetailsResponse] | | /v2/users/{user_id} | 200 | [UserDetailsResponse][horde_sdk.ai_horde_api.apimodels._users.UserDetailsResponse] | | /v2/workers | 200 | [AllWorkersDetailsResponse][horde_sdk.ai_horde_api.apimodels.workers._workers.AllWorkersDetailsResponse] | +| /v2/workers/name/{worker_name} | 200 | [SingleWorkerDetailsResponse][horde_sdk.ai_horde_api.apimodels.workers._workers.SingleWorkerDetailsResponse] | | /v2/workers/{worker_id} | 200 | [SingleWorkerDetailsResponse][horde_sdk.ai_horde_api.apimodels.workers._workers.SingleWorkerDetailsResponse] | diff --git a/docs/api_to_sdk_payload_map.json b/docs/api_to_sdk_payload_map.json index be61ee9..2cfd2d4 100644 --- a/docs/api_to_sdk_payload_map.json +++ b/docs/api_to_sdk_payload_map.json @@ -83,6 +83,9 @@ "/v2/status/news": { "GET": "horde_sdk.ai_horde_api.apimodels._status.NewsRequest" }, + "/v2/workers/name/{worker_name}": { + "GET": "horde_sdk.ai_horde_api.apimodels.workers._workers.SingleWorkerNameDetailsRequest" + }, "/v2/generate/text/async": { "POST": "horde_sdk.ai_horde_api.apimodels.generate.text._async.TextGenerateAsyncRequest" }, diff --git a/docs/api_to_sdk_response_map.json b/docs/api_to_sdk_response_map.json index b991944..74b0ac5 100644 --- a/docs/api_to_sdk_response_map.json +++ b/docs/api_to_sdk_response_map.json @@ -78,6 +78,9 @@ "/v2/status/news": { "200": "horde_sdk.ai_horde_api.apimodels._status.NewsResponse" }, + "/v2/workers/name/{worker_name}": { + "200": "horde_sdk.ai_horde_api.apimodels.workers._workers.SingleWorkerDetailsResponse" + }, "/v2/generate/text/async": { "200": "horde_sdk.ai_horde_api.apimodels.generate.text._async.TextGenerateAsyncDryRunResponse", "202": "horde_sdk.ai_horde_api.apimodels.generate.text._async.TextGenerateAsyncResponse" diff --git a/docs/request_field_names_and_descriptions.json b/docs/request_field_names_and_descriptions.json index bbcd9f9..02b6d75 100644 --- a/docs/request_field_names_and_descriptions.json +++ b/docs/request_field_names_and_descriptions.json @@ -271,6 +271,13 @@ "types": [ "horde_sdk.ai_horde_api.consts.WORKER_TYPE" ] + }, + "name": { + "description": "Returns a worker matching the exact name provided. Case insensitive.", + "types": [ + "str", + "None" + ] } }, "DeleteImageGenerateRequest": { @@ -1201,6 +1208,33 @@ ] } }, + "SingleWorkerNameDetailsRequest": { + "apikey": { + "description": "Defaults to `ANON_API_KEY`. See also `.is_api_key_required()`", + "types": [ + "str", + "None" + ] + }, + "worker_name": { + "description": "The name of the worker in question for this request.", + "types": [ + "str" + ] + }, + "accept": { + "description": "The 'accept' header field.", + "types": [ + "horde_sdk.generic_api.metadata.GenericAcceptTypes" + ] + }, + "client_agent": { + "description": "The requesting client's agent. You should set this to reflect the name, version and contact information\nfor your client.", + "types": [ + "str" + ] + } + }, "TextGenerateAsyncRequest": { "trusted_workers": { "description": "When true, only trusted workers will serve this request. When False, Evaluating workers will also be used\nwhich can increase speed but adds more risk!", diff --git a/examples/ai_horde_client/workers.py b/examples/ai_horde_client/workers.py index 9ee8d01..fb36165 100644 --- a/examples/ai_horde_client/workers.py +++ b/examples/ai_horde_client/workers.py @@ -21,7 +21,7 @@ def all_workers( ) -> None: all_workers_response: AllWorkersDetailsResponse - all_workers_response = simple_client.workers_all_details(worker_name=worker_name) + all_workers_response = simple_client.workers_all_details(worker_name=worker_name, api_key=api_key) if worker_name is None: logger.info("Getting details for all workers.") @@ -33,23 +33,49 @@ def all_workers( logger.info(f"Number of workers: {len(all_workers_response)}") - with open(filename, "w") as f: + with open(filename, "w", encoding="utf-8") as f: f.write(all_workers_response.model_dump_json(indent=4)) logger.info(f"Workers written to {filename}") -def single_worker(api_key: str, simple_client: AIHordeAPISimpleClient, worker_id: str, filename: str) -> None: +def single_worker( + api_key: str, + simple_client: AIHordeAPISimpleClient, + worker_id: str, + filename: str, +) -> None: + single_worker_response: SingleWorkerDetailsResponse + + single_worker_response = simple_client.worker_details(worker_id=worker_id, api_key=api_key) + + if single_worker_response is None: + raise ValueError("No worker returned in the response.") + + logger.info(f"Worker: {single_worker_response}") + + with open(filename, "w", encoding="utf-8") as f: + f.write(f"{single_worker_response}\n") + + logger.info(f"Worker details written to {filename}") + + +def single_worker_name( + api_key: str, + simple_client: AIHordeAPISimpleClient, + worker_name: str, + filename: str, +) -> None: single_worker_response: SingleWorkerDetailsResponse - single_worker_response = simple_client.worker_details(worker_id=worker_id) + single_worker_response = simple_client.worker_details_by_name(worker_name=worker_name, api_key=api_key) if single_worker_response is None: raise ValueError("No worker returned in the response.") logger.info(f"Worker: {single_worker_response}") - with open(filename, "w") as f: + with open(filename, "w", encoding="utf-8") as f: f.write(f"{single_worker_response}\n") logger.info(f"Worker details written to {filename}") @@ -96,8 +122,16 @@ def set_maintenance_mode( help="The filename to write the workers to.", ) + parser.add_argument( + "--worker_name", + "-n", + type=str, + help="The worker name to get details for.", + default=None, + ) + # Either all or worker_id must be specified. - group = parser.add_mutually_exclusive_group(required=True) + group = parser.add_mutually_exclusive_group() group.add_argument( "--all", @@ -112,13 +146,6 @@ def set_maintenance_mode( help="The worker ID to get details for.", ) - group.add_argument( - "--worker_name", - "-n", - type=str, - help="The worker name to get details for.", - ) - group2 = parser.add_mutually_exclusive_group() group2.add_argument( "--maintenance-mode-on", @@ -141,14 +168,7 @@ def set_maintenance_mode( simple_client = AIHordeAPISimpleClient() - if args.worker_name: - all_workers( - api_key=args.apikey, - simple_client=simple_client, - filename=args.filename, - worker_name=args.worker_name, - ) - elif args.all: + if args.all: all_workers( api_key=args.apikey, simple_client=simple_client, @@ -176,3 +196,10 @@ def set_maintenance_mode( worker_id=args.worker_id, filename=args.filename, ) + elif args.worker_name: + single_worker_name( + api_key=args.apikey, + simple_client=simple_client, + filename=args.filename, + worker_name=args.worker_name, + ) diff --git a/horde_sdk/ai_horde_api/ai_horde_clients.py b/horde_sdk/ai_horde_api/ai_horde_clients.py index a9d0060..68bc008 100644 --- a/horde_sdk/ai_horde_api/ai_horde_clients.py +++ b/horde_sdk/ai_horde_api/ai_horde_clients.py @@ -50,6 +50,7 @@ ResponseGenerationProgressCombinedMixin, SingleWorkerDetailsRequest, SingleWorkerDetailsResponse, + SingleWorkerNameDetailsRequest, TextGenerateAsyncDryRunResponse, TextGenerateAsyncRequest, TextGenerateStatusResponse, @@ -956,15 +957,21 @@ def text_generate_request_dry_run( def workers_all_details( self, worker_name: str | None = None, + *, + api_key: str | None = None, ) -> AllWorkersDetailsResponse: """Get all the details for all workers. + Args: + worker_name (str, optional): The name of the worker to get the details for. + api_key (str, optional): The API key to use for the request. + Returns: WorkersAllDetailsResponse: The response from the API. """ with AIHordeAPIClientSession() as horde_session: response = horde_session.submit_request( - AllWorkersDetailsRequest(name=worker_name), + AllWorkersDetailsRequest(name=worker_name, apikey=api_key), AllWorkersDetailsResponse, ) @@ -978,18 +985,49 @@ def workers_all_details( def worker_details( self, worker_id: WorkerID | str, + *, + api_key: str | None = None, ) -> SingleWorkerDetailsResponse: """Get the details for a worker. Args: worker_id (WorkerID): The ID of the worker to get the details for. + api_key (str, optional): The API key to use for the request. + + Returns: + SingleWorkerDetailsResponse: The response from the API. + """ + with AIHordeAPIClientSession() as horde_session: + response = horde_session.submit_request( + SingleWorkerDetailsRequest(worker_id=worker_id, apikey=api_key), + SingleWorkerDetailsResponse, + ) + + if isinstance(response, RequestErrorResponse): + raise AIHordeRequestError(response) + + return response + + raise RuntimeError("Something went wrong with the request") + + def worker_details_by_name( + self, + worker_name: str, + *, + api_key: str | None = None, + ) -> SingleWorkerDetailsResponse: + """Get the details for a worker by worker name. + + Args: + worker_name (str): The ID of the worker to get the details for. + api_key (str, optional): The API key to use for the request. Returns: SingleWorkerDetailsResponse: The response from the API. """ with AIHordeAPIClientSession() as horde_session: response = horde_session.submit_request( - SingleWorkerDetailsRequest(worker_id=worker_id), + SingleWorkerNameDetailsRequest(worker_name=worker_name, apikey=api_key), SingleWorkerDetailsResponse, ) @@ -1029,17 +1067,23 @@ def worker_modify( def worker_delete( self, worker_id: WorkerID | str, + *, + api_key: str | None = None, ) -> DeleteWorkerResponse: """Delete a worker. Args: worker_id (WorkerID): The ID of the worker to delete. + api_key (str, optional): The API key to use for the request. Returns: DeleteWorkerResponse: The response from the API. """ with AIHordeAPIClientSession() as horde_session: - response = horde_session.submit_request(DeleteWorkerRequest(worker_id=worker_id), DeleteWorkerResponse) + response = horde_session.submit_request( + DeleteWorkerRequest(worker_id=worker_id, apikey=api_key), + DeleteWorkerResponse, + ) if isinstance(response, RequestErrorResponse): raise AIHordeRequestError(response) @@ -1648,6 +1692,8 @@ async def text_generate_request_dry_run( async def workers_all_details( self, worker_name: str | None = None, + *, + api_key: str | None = None, ) -> AllWorkersDetailsResponse: """Get all the details for all workers. @@ -1656,7 +1702,7 @@ async def workers_all_details( """ if self._horde_client_session is not None: response = await self._horde_client_session.submit_request( - AllWorkersDetailsRequest(name=worker_name), + AllWorkersDetailsRequest(name=worker_name, apikey=api_key), AllWorkersDetailsResponse, ) else: @@ -1670,18 +1716,21 @@ async def workers_all_details( async def worker_details( self, worker_id: WorkerID | str, + *, + api_key: str | None = None, ) -> SingleWorkerDetailsResponse: """Get the details for a worker. Args: worker_id (WorkerID): The ID of the worker to get the details for. + api_key (str, optional): The API key to use for the request. Returns: SingleWorkerDetailsResponse: The response from the API. """ if self._horde_client_session is not None: response = await self._horde_client_session.submit_request( - SingleWorkerDetailsRequest(worker_id=worker_id), + SingleWorkerDetailsRequest(worker_id=worker_id, apikey=api_key), SingleWorkerDetailsResponse, ) else: @@ -1721,18 +1770,21 @@ async def worker_modify( async def worker_delete( self, worker_id: WorkerID | str, + *, + api_key: str | None = None, ) -> DeleteWorkerResponse: """Delete a worker. Args: worker_id (WorkerID): The ID of the worker to delete. + api_key (str, optional): The API key to use for the request. Returns: DeleteWorkerResponse: The response from the API. """ if self._horde_client_session is not None: response = await self._horde_client_session.submit_request( - DeleteWorkerRequest(worker_id=worker_id), + DeleteWorkerRequest(worker_id=worker_id, apikey=api_key), DeleteWorkerResponse, ) else: diff --git a/horde_sdk/ai_horde_api/apimodels/__init__.py b/horde_sdk/ai_horde_api/apimodels/__init__.py index bb51a23..7ba9be4 100644 --- a/horde_sdk/ai_horde_api/apimodels/__init__.py +++ b/horde_sdk/ai_horde_api/apimodels/__init__.py @@ -100,6 +100,7 @@ SingleWarningEntry, TIPayloadEntry, WorkerRequestMixin, + WorkerRequestNameMixin, ) from horde_sdk.ai_horde_api.apimodels.generate._async import ( ImageGenerateAsyncDryRunResponse, @@ -160,6 +161,7 @@ ModifyWorkerResponse, SingleWorkerDetailsRequest, SingleWorkerDetailsResponse, + SingleWorkerNameDetailsRequest, TeamDetailsLite, WorkerDetailItem, WorkerKudosDetails, @@ -258,6 +260,7 @@ "SingleWarningEntry", "TIPayloadEntry", "WorkerRequestMixin", + "WorkerRequestNameMixin", "ImageGenerateAsyncDryRunResponse", "ImageGenerateAsyncRequest", "ImageGenerateAsyncResponse", @@ -298,6 +301,7 @@ "DeleteWorkerResponse", "ModifyWorkerResponse", "ModifyWorkerRequest", + "SingleWorkerNameDetailsRequest", "SingleWorkerDetailsRequest", "SingleWorkerDetailsResponse", "TeamDetailsLite", diff --git a/horde_sdk/ai_horde_api/apimodels/base.py b/horde_sdk/ai_horde_api/apimodels/base.py index 6995c28..894e505 100644 --- a/horde_sdk/ai_horde_api/apimodels/base.py +++ b/horde_sdk/ai_horde_api/apimodels/base.py @@ -85,6 +85,13 @@ class WorkerRequestMixin(HordeAPIDataObject): """The UUID of the worker in question for this request.""" +class WorkerRequestNameMixin(HordeAPIDataObject): + """Mix-in class for data relating to worker requests.""" + + worker_name: str + """The name of the worker in question for this request.""" + + class LorasPayloadEntry(HordeAPIDataObject): """Represents a single lora parameter. diff --git a/horde_sdk/ai_horde_api/apimodels/workers/_workers.py b/horde_sdk/ai_horde_api/apimodels/workers/_workers.py index 4009257..1aceb35 100644 --- a/horde_sdk/ai_horde_api/apimodels/workers/_workers.py +++ b/horde_sdk/ai_horde_api/apimodels/workers/_workers.py @@ -3,7 +3,7 @@ from pydantic import AliasChoices, Field, RootModel from typing_extensions import override -from horde_sdk.ai_horde_api.apimodels.base import BaseAIHordeRequest, WorkerRequestMixin +from horde_sdk.ai_horde_api.apimodels.base import BaseAIHordeRequest, WorkerRequestMixin, WorkerRequestNameMixin from horde_sdk.ai_horde_api.consts import WORKER_TYPE from horde_sdk.ai_horde_api.endpoints import AI_HORDE_API_ENDPOINT_SUBPATH from horde_sdk.ai_horde_api.fields import TeamID, WorkerID @@ -239,6 +239,38 @@ def get_api_model_name(cls) -> str | None: return "WorkerDetails" +class SingleWorkerNameDetailsRequest(BaseAIHordeRequest, WorkerRequestNameMixin, APIKeyAllowedInRequestMixin): + """Returns information on a single worker. + + If a moderator API key is specified, additional information is returned. + """ + + @override + @classmethod + def get_api_model_name(cls) -> str | None: + return None + + @override + @classmethod + def get_api_endpoint_subpath(cls) -> AI_HORDE_API_ENDPOINT_SUBPATH: + return AI_HORDE_API_ENDPOINT_SUBPATH.v2_workers_single_name + + @override + @classmethod + def get_http_method(cls) -> HTTPMethod: + return HTTPMethod.GET + + @override + @classmethod + def get_default_success_response_type(cls) -> type[SingleWorkerDetailsResponse]: + return SingleWorkerDetailsResponse + + @classmethod + def is_api_key_required(cls) -> bool: + """Return whether this endpoint requires an API key.""" + return False + + class SingleWorkerDetailsRequest(BaseAIHordeRequest, WorkerRequestMixin, APIKeyAllowedInRequestMixin): """Returns information on a single worker. diff --git a/horde_sdk/ai_horde_api/endpoints.py b/horde_sdk/ai_horde_api/endpoints.py index 6314e12..493c41d 100644 --- a/horde_sdk/ai_horde_api/endpoints.py +++ b/horde_sdk/ai_horde_api/endpoints.py @@ -83,6 +83,7 @@ class AI_HORDE_API_ENDPOINT_SUBPATH(GENERIC_API_ENDPOINT_SUBPATH): v2_workers_all = "/v2/workers" v2_workers_single = "/v2/workers/{worker_id}" + v2_workers_single_name = "/v2/workers/name/{worker_name}" v2_filters = "/v2/filters" v2_filters_regex = "/v2/filters/regex" diff --git a/horde_sdk/ai_horde_api/metadata.py b/horde_sdk/ai_horde_api/metadata.py index 307a118..df4905c 100644 --- a/horde_sdk/ai_horde_api/metadata.py +++ b/horde_sdk/ai_horde_api/metadata.py @@ -18,6 +18,8 @@ class AIHordePathData(GenericPathFields): """The UUID of a team.""" worker_id = auto() """The UUID of a worker.""" + worker_name = auto() + """The name of a worker.""" sharedkey_id = auto() """The UUID representing a shared key.""" model_name = auto() diff --git a/horde_sdk/scripts/write_all_response_examples_for_tests.py b/horde_sdk/scripts/write_all_response_examples_for_tests.py index c47e905..0640a24 100644 --- a/horde_sdk/scripts/write_all_response_examples_for_tests.py +++ b/horde_sdk/scripts/write_all_response_examples_for_tests.py @@ -1,7 +1,10 @@ """Write all example responses to a file in the tests/test_data directory.""" +import json from pathlib import Path +from loguru import logger + from horde_sdk.ai_horde_api.endpoints import get_ai_horde_swagger_url from horde_sdk.generic_api.utils.swagger import SwaggerParser @@ -17,6 +20,26 @@ def write_all_example_responses(*, test_data_path: Path | None = None) -> None: ) ai_horde_swagger_doc.write_all_response_examples_to_file(test_data_path) + # Compatibility hacks: + # `_v2_users_get_200.json` needs to have the object added to an array and overwritten + with open(test_data_path / "_v2_users_get_200.json") as f: + _v2_users_get_200 = f.read() + + if not _v2_users_get_200.startswith("["): + logger.warning( + "The _v2_users_get_200.json file is not an array, converting it to one to make it compatible with the " + "tests. This is a compatibility hack due to the API docs not being correct.", + ) + _v2_users_get_200 = f"[{_v2_users_get_200}]" + _v2_users_get_200 = json.loads(_v2_users_get_200) + + with open(test_data_path / "_v2_users_get_200.json", "w") as f: + json.dump(_v2_users_get_200, f, indent=4) + f.write("\n") + else: + logger.info("The _v2_users_get_200.json file is already compatible with the tests.") + logger.info("This script should be updated to remove this compatibility hack.") + if __name__ == "__main__": write_all_example_responses() diff --git a/tests/test_data/ai_horde_api/example_responses/_v2_workers_name_worker_name_get_200.json b/tests/test_data/ai_horde_api/example_responses/_v2_workers_name_worker_name_get_200.json new file mode 100644 index 0000000..26bdee0 --- /dev/null +++ b/tests/test_data/ai_horde_api/example_responses/_v2_workers_name_worker_name_get_200.json @@ -0,0 +1,48 @@ +{ + "type": "image", + "name": "", + "id": "", + "online": false, + "requests_fulfilled": 0, + "kudos_rewards": 0.0, + "kudos_details": { + "generated": 0.0, + "uptime": 0 + }, + "performance": "", + "threads": 0, + "uptime": 0, + "maintenance_mode": false, + "paused": false, + "info": "https://dbzer0.com", + "nsfw": false, + "owner": "username#1", + "ipaddr": "username#1", + "trusted": false, + "flagged": false, + "suspicious": 0, + "uncompleted_jobs": 0, + "models": [ + "" + ], + "forms": [ + "" + ], + "team": { + "name": "", + "id": "" + }, + "contact": "email@example.com", + "bridge_agent": "AI Horde Worker reGen:4.1.0:https://github.com/Haidra-Org/horde-worker-reGen", + "max_pixels": 262144, + "megapixelsteps_generated": 0.0, + "img2img": false, + "painting": false, + "post-processing": false, + "lora": false, + "controlnet": false, + "sdxl_controlnet": false, + "max_length": 80, + "max_context_length": 80, + "tokens_generated": 0.0 +}