From 1ebfc78f2c1649dcafbdddc45b062fa55c788fca Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 20 Nov 2025 20:23:38 +0000 Subject: [PATCH 1/8] Initial plan From d119b100a934d7219d16e9283dfb4dabc823f76c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 20 Nov 2025 20:29:18 +0000 Subject: [PATCH 2/8] Implement Sites API client with CRUD operations Co-authored-by: rnovatorov <20299819+rnovatorov@users.noreply.github.com> --- src/enapter/http/api/__init__.py | 4 +- src/enapter/http/api/client.py | 6 ++- src/enapter/http/api/sites/__init__.py | 7 ++++ src/enapter/http/api/sites/client.py | 56 ++++++++++++++++++++++++++ src/enapter/http/api/sites/site.py | 29 +++++++++++++ 5 files changed, 99 insertions(+), 3 deletions(-) create mode 100644 src/enapter/http/api/sites/__init__.py create mode 100644 src/enapter/http/api/sites/client.py create mode 100644 src/enapter/http/api/sites/site.py 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..91d0a0b --- /dev/null +++ b/src/enapter/http/api/sites/__init__.py @@ -0,0 +1,7 @@ +from .client import Client +from .site import Site + +__all__ = [ + "Client", + "Site", +] diff --git a/src/enapter/http/api/sites/client.py b/src/enapter/http/api/sites/client.py new file mode 100644 index 0000000..76be2b3 --- /dev/null +++ b/src/enapter/http/api/sites/client.py @@ -0,0 +1,56 @@ +from typing import AsyncGenerator + +import httpx + +from enapter import async_ +from enapter.http import api + +from .site import Site + + +class Client: + + def __init__(self, client: httpx.AsyncClient) -> None: + self._client = client + + async def create(self, name: str) -> Site: + url = "v3/sites" + response = await self._client.post(url, json={"name": name}) + api.check_error(response) + return Site.from_dto(response.json()["site"]) + + async def get(self, site_id: str) -> Site: + url = f"v3/sites/{site_id}" + 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, name: str | None = None) -> Site: + if name is None: + return await self.get(site_id) + url = f"v3/sites/{site_id}" + response = await self._client.patch(url, json={"name": name}) + 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/site.py b/src/enapter/http/api/sites/site.py new file mode 100644 index 0000000..ba65ea4 --- /dev/null +++ b/src/enapter/http/api/sites/site.py @@ -0,0 +1,29 @@ +import dataclasses +import datetime +from typing import Any, Self + + +@dataclasses.dataclass +class Site: + + id: str + name: str + created_at: datetime.datetime + updated_at: datetime.datetime + + @classmethod + def from_dto(cls, dto: dict[str, Any]) -> Self: + return cls( + id=dto["id"], + name=dto["name"], + created_at=datetime.datetime.fromisoformat(dto["created_at"]), + updated_at=datetime.datetime.fromisoformat(dto["updated_at"]), + ) + + def to_dto(self) -> dict[str, Any]: + return { + "id": self.id, + "name": self.name, + "created_at": self.created_at.isoformat(), + "updated_at": self.updated_at.isoformat(), + } From 8a77a1e2fbb0b3f9b0789796299ba0a35c704341 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 20 Nov 2025 20:33:27 +0000 Subject: [PATCH 3/8] Add example for listing sites using Sites API Co-authored-by: rnovatorov <20299819+rnovatorov@users.noreply.github.com> --- examples/http/list_sites.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 examples/http/list_sites.py 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()) From 5fd2e3147312d3c3231e422c812a044be026b0a3 Mon Sep 17 00:00:00 2001 From: Roman Novatorov Date: Mon, 1 Dec 2025 11:50:49 +0100 Subject: [PATCH 4/8] cli: http: api: fix device create standalone `name` arg description --- src/enapter/cli/http/api/device_create_standalone_command.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) 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 ) From 6b628e3b27f6a0f2b798e89010dba6939ae9da40 Mon Sep 17 00:00:00 2001 From: Roman Novatorov Date: Mon, 1 Dec 2025 11:20:30 +0100 Subject: [PATCH 5/8] http: api: sites: fix Site model --- src/enapter/http/api/sites/__init__.py | 2 ++ src/enapter/http/api/sites/client.py | 14 ++++++++++++-- src/enapter/http/api/sites/location.py | 23 +++++++++++++++++++++++ src/enapter/http/api/sites/site.py | 24 ++++++++++++++++-------- 4 files changed, 53 insertions(+), 10 deletions(-) create mode 100644 src/enapter/http/api/sites/location.py diff --git a/src/enapter/http/api/sites/__init__.py b/src/enapter/http/api/sites/__init__.py index 91d0a0b..f3d19e0 100644 --- a/src/enapter/http/api/sites/__init__.py +++ b/src/enapter/http/api/sites/__init__.py @@ -1,7 +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 index 76be2b3..b66954b 100644 --- a/src/enapter/http/api/sites/client.py +++ b/src/enapter/http/api/sites/client.py @@ -5,6 +5,7 @@ from enapter import async_ from enapter.http import api +from .location import Location from .site import Site @@ -13,9 +14,18 @@ class Client: def __init__(self, client: httpx.AsyncClient) -> None: self._client = client - async def create(self, name: str) -> Site: + 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}) + 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"]) 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 index ba65ea4..d6a9620 100644 --- a/src/enapter/http/api/sites/site.py +++ b/src/enapter/http/api/sites/site.py @@ -1,6 +1,7 @@ import dataclasses -import datetime -from typing import Any, Self +from typing import Any, Literal, Self + +from .location import Location @dataclasses.dataclass @@ -8,22 +9,29 @@ class Site: id: str name: str - created_at: datetime.datetime - updated_at: datetime.datetime + 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"], - created_at=datetime.datetime.fromisoformat(dto["created_at"]), - updated_at=datetime.datetime.fromisoformat(dto["updated_at"]), + 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, - "created_at": self.created_at.isoformat(), - "updated_at": self.updated_at.isoformat(), + "timezone": self.timezone, + "version": self.version, + "location": self.location.to_dto() if self.location is not None else None, } From d4245f2aff1fcb566f18545413813a6368d5fc42 Mon Sep 17 00:00:00 2001 From: Roman Novatorov Date: Mon, 1 Dec 2025 12:10:49 +0100 Subject: [PATCH 6/8] http: api: sites: support /v3/site shortcut --- src/enapter/http/api/sites/client.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/enapter/http/api/sites/client.py b/src/enapter/http/api/sites/client.py index b66954b..cd2947c 100644 --- a/src/enapter/http/api/sites/client.py +++ b/src/enapter/http/api/sites/client.py @@ -29,8 +29,8 @@ async def create( api.check_error(response) return Site.from_dto(response.json()["site"]) - async def get(self, site_id: str) -> Site: - url = f"v3/sites/{site_id}" + 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"]) @@ -52,10 +52,10 @@ async def list(self) -> AsyncGenerator[Site, None]: yield Site.from_dto(dto) offset += limit - async def update(self, site_id: str, name: str | None = None) -> Site: + async def update(self, site_id: str | None, name: str | None = None) -> Site: if name is None: return await self.get(site_id) - url = f"v3/sites/{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}) api.check_error(response) return Site.from_dto(response.json()["site"]) From 07458b314c093c58258c311863f53321bdd88607 Mon Sep 17 00:00:00 2001 From: Roman Novatorov Date: Mon, 1 Dec 2025 12:26:15 +0100 Subject: [PATCH 7/8] http: api: sites: extend `update` parameters --- src/enapter/http/api/sites/client.py | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/src/enapter/http/api/sites/client.py b/src/enapter/http/api/sites/client.py index cd2947c..b8e5193 100644 --- a/src/enapter/http/api/sites/client.py +++ b/src/enapter/http/api/sites/client.py @@ -52,11 +52,24 @@ async def list(self) -> AsyncGenerator[Site, None]: yield Site.from_dto(dto) offset += limit - async def update(self, site_id: str | None, name: str | None = None) -> Site: - if name is None: + 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}) + 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"]) From 8de5e2febaa957c15a7ac0dd412fe4f49af86a51 Mon Sep 17 00:00:00 2001 From: Roman Novatorov Date: Mon, 1 Dec 2025 12:26:43 +0100 Subject: [PATCH 8/8] cli: http: api: add `site` command --- src/enapter/cli/http/api/command.py | 4 ++ src/enapter/cli/http/api/site_command.py | 43 ++++++++++++++++++ .../cli/http/api/site_create_command.py | 43 ++++++++++++++++++ .../cli/http/api/site_delete_command.py | 18 ++++++++ src/enapter/cli/http/api/site_get_command.py | 22 +++++++++ src/enapter/cli/http/api/site_list_command.py | 33 ++++++++++++++ src/enapter/cli/http/api/site_location.py | 11 +++++ .../cli/http/api/site_update_command.py | 45 +++++++++++++++++++ 8 files changed, 219 insertions(+) create mode 100644 src/enapter/cli/http/api/site_command.py create mode 100644 src/enapter/cli/http/api/site_create_command.py create mode 100644 src/enapter/cli/http/api/site_delete_command.py create mode 100644 src/enapter/cli/http/api/site_get_command.py create mode 100644 src/enapter/cli/http/api/site_list_command.py create mode 100644 src/enapter/cli/http/api/site_location.py create mode 100644 src/enapter/cli/http/api/site_update_command.py 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/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()))