diff --git a/examples/http/list_sites.py b/examples/http/list_sites.py new file mode 100644 index 0000000..e8d08e3 --- /dev/null +++ b/examples/http/list_sites.py @@ -0,0 +1,14 @@ +import asyncio + +import enapter + + +async def main(): + config = enapter.http.api.Config.from_env() + async with enapter.http.api.Client(config=config) as client: + async for site in client.sites.list(): + print(site) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/src/enapter/cli/http/api/command.py b/src/enapter/cli/http/api/command.py index 9f1796f..e4aceaf 100644 --- a/src/enapter/cli/http/api/command.py +++ b/src/enapter/cli/http/api/command.py @@ -3,6 +3,7 @@ from enapter import cli from .device_command import DeviceCommand +from .site_command import SiteCommand class Command(cli.Command): @@ -15,6 +16,7 @@ def register(parent: cli.Subparsers) -> None: subparsers = parser.add_subparsers(dest="http_api_command", required=True) for command in [ DeviceCommand, + SiteCommand, ]: command.register(subparsers) @@ -23,5 +25,7 @@ async def run(args: argparse.Namespace) -> None: match args.http_api_command: case "device": await DeviceCommand.run(args) + case "site": + await SiteCommand.run(args) case _: raise NotImplementedError(args.device_command) diff --git a/src/enapter/cli/http/api/device_create_standalone_command.py b/src/enapter/cli/http/api/device_create_standalone_command.py index 3867f9a..dc52867 100644 --- a/src/enapter/cli/http/api/device_create_standalone_command.py +++ b/src/enapter/cli/http/api/device_create_standalone_command.py @@ -11,9 +11,7 @@ def register(parent: cli.Subparsers) -> None: parser = parent.add_parser( "create-standalone", formatter_class=argparse.ArgumentDefaultsHelpFormatter ) - parser.add_argument( - "name", help="ID or slug of the device to get information about" - ) + parser.add_argument("name", help="Name of the standalone device to create") parser.add_argument( "-s", "--site-id", help="Site ID to create device in", default=None ) diff --git a/src/enapter/cli/http/api/site_command.py b/src/enapter/cli/http/api/site_command.py new file mode 100644 index 0000000..8fadd1c --- /dev/null +++ b/src/enapter/cli/http/api/site_command.py @@ -0,0 +1,43 @@ +import argparse + +from enapter import cli + +from .site_create_command import SiteCreateCommand +from .site_delete_command import SiteDeleteCommand +from .site_get_command import SiteGetCommand +from .site_list_command import SiteListCommand +from .site_update_command import SiteUpdateCommand + + +class SiteCommand(cli.Command): + + @staticmethod + def register(parent: cli.Subparsers) -> None: + parser = parent.add_parser( + "site", formatter_class=argparse.ArgumentDefaultsHelpFormatter + ) + subparsers = parser.add_subparsers(dest="http_api_site_command", required=True) + for command in [ + SiteCreateCommand, + SiteDeleteCommand, + SiteGetCommand, + SiteListCommand, + SiteUpdateCommand, + ]: + command.register(subparsers) + + @staticmethod + async def run(args: argparse.Namespace) -> None: + match args.http_api_site_command: + case "create": + await SiteCreateCommand.run(args) + case "delete": + await SiteDeleteCommand.run(args) + case "get": + await SiteGetCommand.run(args) + case "list": + await SiteListCommand.run(args) + case "update": + await SiteUpdateCommand.run(args) + case _: + raise NotImplementedError(args.device_command) diff --git a/src/enapter/cli/http/api/site_create_command.py b/src/enapter/cli/http/api/site_create_command.py new file mode 100644 index 0000000..1058f5a --- /dev/null +++ b/src/enapter/cli/http/api/site_create_command.py @@ -0,0 +1,43 @@ +import argparse +import json + +from enapter import cli, http + +from .site_location import parse_site_location + + +class SiteCreateCommand(cli.Command): + + @staticmethod + def register(parent: cli.Subparsers) -> None: + parser = parent.add_parser( + "create", formatter_class=argparse.ArgumentDefaultsHelpFormatter + ) + parser.add_argument("name", help="Name of the site to create") + parser.add_argument( + "-t", "--timezone", help="Timezone of the site to create", default="UTC" + ) + parser.add_argument( + "-l", + "--location", + type=parse_site_location, + help="Site location in the format NAME,LATITUDE,LONGITUDE", + ) + + @staticmethod + async def run(args: argparse.Namespace) -> None: + async with http.api.Client(http.api.Config.from_env()) as client: + site = await client.sites.create( + name=args.name, + timezone=args.timezone, + location=( + http.api.sites.Location( + name=args.location[0], + latitude=args.location[1], + longitude=args.location[2], + ) + if args.location is not None + else None + ), + ) + print(json.dumps(site.to_dto())) diff --git a/src/enapter/cli/http/api/site_delete_command.py b/src/enapter/cli/http/api/site_delete_command.py new file mode 100644 index 0000000..5f57f7c --- /dev/null +++ b/src/enapter/cli/http/api/site_delete_command.py @@ -0,0 +1,18 @@ +import argparse + +from enapter import cli, http + + +class SiteDeleteCommand(cli.Command): + + @staticmethod + def register(parent: cli.Subparsers) -> None: + parser = parent.add_parser( + "delete", formatter_class=argparse.ArgumentDefaultsHelpFormatter + ) + parser.add_argument("id", type=str, help="ID of the site to delete") + + @staticmethod + async def run(args: argparse.Namespace) -> None: + async with http.api.Client(http.api.Config.from_env()) as client: + await client.sites.delete(args.id) diff --git a/src/enapter/cli/http/api/site_get_command.py b/src/enapter/cli/http/api/site_get_command.py new file mode 100644 index 0000000..4e10841 --- /dev/null +++ b/src/enapter/cli/http/api/site_get_command.py @@ -0,0 +1,22 @@ +import argparse +import json + +from enapter import cli, http + + +class SiteGetCommand(cli.Command): + + @staticmethod + def register(parent: cli.Subparsers) -> None: + parser = parent.add_parser( + "get", formatter_class=argparse.ArgumentDefaultsHelpFormatter + ) + parser.add_argument( + "id", nargs="?", type=str, help="ID of the site to retrieve" + ) + + @staticmethod + async def run(args: argparse.Namespace) -> None: + async with http.api.Client(http.api.Config.from_env()) as client: + site = await client.sites.get(args.id) + print(json.dumps(site.to_dto())) diff --git a/src/enapter/cli/http/api/site_list_command.py b/src/enapter/cli/http/api/site_list_command.py new file mode 100644 index 0000000..f62af26 --- /dev/null +++ b/src/enapter/cli/http/api/site_list_command.py @@ -0,0 +1,33 @@ +import argparse +import json + +from enapter import cli, http + + +class SiteListCommand(cli.Command): + + @staticmethod + def register(parent: cli.Subparsers) -> None: + parser = parent.add_parser( + "list", formatter_class=argparse.ArgumentDefaultsHelpFormatter + ) + parser.add_argument( + "-l", + "--limit", + type=int, + help="Maximum number of sites to list", + default=-1, + ) + + @staticmethod + async def run(args: argparse.Namespace) -> None: + if args.limit == 0: + return + async with http.api.Client(http.api.Config.from_env()) as client: + async with client.sites.list() as stream: + count = 0 + async for site in stream: + print(json.dumps(site.to_dto())) + count += 1 + if args.limit > 0 and count == args.limit: + break diff --git a/src/enapter/cli/http/api/site_location.py b/src/enapter/cli/http/api/site_location.py new file mode 100644 index 0000000..9504cbf --- /dev/null +++ b/src/enapter/cli/http/api/site_location.py @@ -0,0 +1,11 @@ +import argparse + + +def parse_site_location(location_str: str) -> tuple[str, float, float]: + try: + name, lat_str, lon_str = location_str.split(",") + return name, float(lat_str), float(lon_str) + except ValueError: + raise argparse.ArgumentTypeError( + "Location must be in the format NAME,LATITUDE,LONGITUDE" + ) diff --git a/src/enapter/cli/http/api/site_update_command.py b/src/enapter/cli/http/api/site_update_command.py new file mode 100644 index 0000000..c6673c6 --- /dev/null +++ b/src/enapter/cli/http/api/site_update_command.py @@ -0,0 +1,45 @@ +import argparse +import json + +from enapter import cli, http + +from .site_location import parse_site_location + + +class SiteUpdateCommand(cli.Command): + + @staticmethod + def register(parent: cli.Subparsers) -> None: + parser = parent.add_parser( + "update", formatter_class=argparse.ArgumentDefaultsHelpFormatter + ) + parser.add_argument("id", nargs="?", type=str, help="ID of the site to update") + parser.add_argument("-n", "--name", type=str, help="New name for the site") + parser.add_argument( + "-t", "--timezone", type=str, help="New timezone for the site" + ) + parser.add_argument( + "-l", + "--location", + type=parse_site_location, + help="New location for the site", + ) + + @staticmethod + async def run(args: argparse.Namespace) -> None: + async with http.api.Client(http.api.Config.from_env()) as client: + site = await client.sites.update( + site_id=args.id, + name=args.name, + timezone=args.timezone, + location=( + http.api.sites.Location( + name=args.location[0], + latitude=args.location[1], + longitude=args.location[2], + ) + if args.location is not None + else None + ), + ) + print(json.dumps(site.to_dto())) diff --git a/src/enapter/http/api/__init__.py b/src/enapter/http/api/__init__.py index 58b55ee..791abca 100644 --- a/src/enapter/http/api/__init__.py +++ b/src/enapter/http/api/__init__.py @@ -2,6 +2,6 @@ from .config import Config from .errors import Error, MultiError, check_error -from . import devices # isort: skip +from . import devices, sites # isort: skip -__all__ = ["Client", "Config", "devices", "Error", "MultiError", "check_error"] +__all__ = ["Client", "Config", "devices", "sites", "Error", "MultiError", "check_error"] diff --git a/src/enapter/http/api/client.py b/src/enapter/http/api/client.py index 6f9f63e..53d8005 100644 --- a/src/enapter/http/api/client.py +++ b/src/enapter/http/api/client.py @@ -2,7 +2,7 @@ import httpx -from enapter.http.api import devices +from enapter.http.api import devices, sites from .config import Config @@ -33,3 +33,7 @@ async def __aexit__(self, *exc) -> None: @property def devices(self) -> devices.Client: return devices.Client(client=self._client) + + @property + def sites(self) -> sites.Client: + return sites.Client(client=self._client) diff --git a/src/enapter/http/api/sites/__init__.py b/src/enapter/http/api/sites/__init__.py new file mode 100644 index 0000000..f3d19e0 --- /dev/null +++ b/src/enapter/http/api/sites/__init__.py @@ -0,0 +1,9 @@ +from .client import Client +from .location import Location +from .site import Site + +__all__ = [ + "Client", + "Site", + "Location", +] diff --git a/src/enapter/http/api/sites/client.py b/src/enapter/http/api/sites/client.py new file mode 100644 index 0000000..b8e5193 --- /dev/null +++ b/src/enapter/http/api/sites/client.py @@ -0,0 +1,79 @@ +from typing import AsyncGenerator + +import httpx + +from enapter import async_ +from enapter.http import api + +from .location import Location +from .site import Site + + +class Client: + + def __init__(self, client: httpx.AsyncClient) -> None: + self._client = client + + async def create( + self, name: str, timezone: str, location: Location | None = None + ) -> Site: + url = "v3/sites" + response = await self._client.post( + url, + json={ + "name": name, + "timezone": timezone, + "location": location.to_dto() if location is not None else None, + }, + ) + api.check_error(response) + return Site.from_dto(response.json()["site"]) + + async def get(self, site_id: str | None) -> Site: + url = f"v3/sites/{site_id}" if site_id is not None else "v3/site" + response = await self._client.get(url) + api.check_error(response) + return Site.from_dto(response.json()["site"]) + + @async_.generator + async def list(self) -> AsyncGenerator[Site, None]: + url = "v3/sites" + limit = 50 + offset = 0 + while True: + response = await self._client.get( + url, params={"limit": limit, "offset": offset} + ) + api.check_error(response) + payload = response.json() + if not payload["sites"]: + return + for dto in payload["sites"]: + yield Site.from_dto(dto) + offset += limit + + async def update( + self, + site_id: str | None, + name: str | None = None, + timezone: str | None = None, + location: Location | None = None, + ) -> Site: + if name is None and timezone is None and location is None: + return await self.get(site_id) + url = f"v3/sites/{site_id}" if site_id is not None else "v3/site" + response = await self._client.patch( + url, + json={ + "name": name, + "timezone": timezone, + "location": location.to_dto() if location is not None else None, + }, + ) + api.check_error(response) + return Site.from_dto(response.json()["site"]) + + async def delete(self, site_id: str) -> None: + url = f"v3/sites/{site_id}" + response = await self._client.delete(url) + api.check_error(response) diff --git a/src/enapter/http/api/sites/location.py b/src/enapter/http/api/sites/location.py new file mode 100644 index 0000000..77e11b7 --- /dev/null +++ b/src/enapter/http/api/sites/location.py @@ -0,0 +1,23 @@ +import dataclasses +from typing import Any + + +@dataclasses.dataclass +class Location: + + name: str + latitude: float + longitude: float + + @classmethod + def from_dto(cls, dto: dict[str, Any]) -> "Location": + return cls( + name=dto["name"], latitude=dto["latitude"], longitude=dto["longitude"] + ) + + def to_dto(self) -> dict[str, Any]: + return { + "name": self.name, + "latitude": self.latitude, + "longitude": self.longitude, + } diff --git a/src/enapter/http/api/sites/site.py b/src/enapter/http/api/sites/site.py new file mode 100644 index 0000000..d6a9620 --- /dev/null +++ b/src/enapter/http/api/sites/site.py @@ -0,0 +1,37 @@ +import dataclasses +from typing import Any, Literal, Self + +from .location import Location + + +@dataclasses.dataclass +class Site: + + id: str + name: str + timezone: str + version: Literal["v3"] + location: Location | None = None + + @classmethod + def from_dto(cls, dto: dict[str, Any]) -> Self: + return cls( + id=dto["id"], + name=dto["name"], + timezone=dto["timezone"], + version=dto["version"], + location=( + Location.from_dto(dto["location"]) + if dto.get("location") is not None + else None + ), + ) + + def to_dto(self) -> dict[str, Any]: + return { + "id": self.id, + "name": self.name, + "timezone": self.timezone, + "version": self.version, + "location": self.location.to_dto() if self.location is not None else None, + }