diff --git a/.github/workflows/python-package-main.yml b/.github/workflows/python-package-main.yml index 79b1ad7..0196557 100644 --- a/.github/workflows/python-package-main.yml +++ b/.github/workflows/python-package-main.yml @@ -24,7 +24,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.10", "3.11", "3.12", "3.13"] + python-version: ["3.11", "3.12", "3.13"] fail-fast: false defaults: run: @@ -42,7 +42,7 @@ jobs: run: uv run python -m pytest tests - name: Check typing - run: uv run ty check + run: uv run basedpyright check-docs: runs-on: ubuntu-latest diff --git a/pyproject.toml b/pyproject.toml index e1f6890..1ff41c7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,13 +44,13 @@ tariff-fetch-gas = "tariff_fetch.cli_gas:main_cli" dev = [ "pytest>=7.2.0", "ruff>=0.11.5", - "ty>=0.0.1a21", "deptry>=0.20.0", "mkdocs>=1.6.0", "mkdocs-material>=9.5.0", "mkdocstrings[python]>=0.26.1", "build>=1.0.0", "twine>=4.0.0", + "basedpyright>=1.38.1", ] [build-system] requires = ["hatchling"] @@ -75,6 +75,6 @@ ignore = ["E501"] [tool.ruff.lint.per-file-ignores] "tests/*" = ["S101"] -[tool.ty.environment] -python = "./.venv" -python-version = "3.13" +[tool.basedpyright] +include = ["tariff_fetch"] +pythonVersion = "3.11" diff --git a/tariff_fetch/_cli/__init__.py b/tariff_fetch/_cli/__init__.py index 0a156a2..aa204ae 100644 --- a/tariff_fetch/_cli/__init__.py +++ b/tariff_fetch/_cli/__init__.py @@ -1,6 +1,7 @@ import os from datetime import datetime from pathlib import Path +from typing import cast import questionary from pathvalidate import sanitize_filename @@ -24,10 +25,13 @@ def prompt_filename(output_folder: Path, suggested_filename: str, extension: str filepath = output_folder.joinpath(f"{suggested_filename}-0{os.extsep}{extension}") return Path( - questionary.path( - message="Path to save the results", - default=filepath.as_posix(), - file_filter=lambda _: Path(_).suffix == extension, - validate=lambda _: (not os.path.exists(_)) or "A file with that name already exists", - ).ask() + cast( + str, + questionary.path( + message="Path to save the results", + default=filepath.as_posix(), + file_filter=lambda _: Path(_).suffix == extension, + validate=lambda _: (not os.path.exists(_)) or "A file with that name already exists", # pyright: ignore[reportUnknownLambdaType, reportUnknownArgumentType] + ).ask(), + ) ) diff --git a/tariff_fetch/_cli/genability.py b/tariff_fetch/_cli/genability.py index c8d4b3c..f5b57f3 100644 --- a/tariff_fetch/_cli/genability.py +++ b/tariff_fetch/_cli/genability.py @@ -1,26 +1,30 @@ -import json import os -from datetime import datetime, timezone +from datetime import date from pathlib import Path +from typing import cast import questionary from dotenv import load_dotenv +from pydantic import TypeAdapter -from tariff_fetch.genability.lse import get_lses_page -from tariff_fetch.genability.tariffs import CustomerClass, TariffType, tariffs_paginate +# from tariff_fetch.genability.lse import get_lses_page +# from tariff_fetch.genability.tariffs import CustomerClass, TariffType, tariffs_paginate +from tariff_fetch.arcadia.api import ArcadiaSignalAPI +from tariff_fetch.arcadia.schema import tariff +from tariff_fetch.arcadia.schema.common import CustomerClass, TariffType from . import console, prompt_filename from .types import Utility -def _find_utility_lse_id(utility: Utility) -> int | None: +def _find_utility_lse_id(api: ArcadiaSignalAPI, utility: Utility) -> int | None: with console.status("Fetching lses..."): - lses = get_lses_page( + lses = api.lses.get_page( fields="min", - searchOn=["code"], + search_on=["code"], search=str(utility.eia_id), - startsWith=True, - endsWith=True, + starts_with=True, + ends_with=True, )["results"] if len(lses) == 0: # No utilities found with this eia id @@ -30,16 +34,19 @@ def _find_utility_lse_id(utility: Utility) -> int | None: return None if len(lses) == 1: # Found one utility - utility_lse_id = lses[0]["lseId"] + utility_lse_id = lses[0]["lse_id"] return utility_lse_id else: # Nothing found; this should *theoretically* never happen but let's keep it just in case - choices = [questionary.Choice(title=_["name"], value=_["lseId"]) for _ in lses] + choices = [questionary.Choice(title=_["name"], value=_["lse_id"]) for _ in lses] choices.append(questionary.Separator()) choices.append(questionary.Choice(title="None of these", value=None)) - utility_lse_id: int | None = questionary.select( - message=f"Found multiple utilities with lse id = {utility.eia_id}. Select one.", choices=choices - ).ask() + utility_lse_id = cast( + int | None, + questionary.select( + message=f"Found multiple utilities with lse id = {utility.eia_id}. Select one.", choices=choices + ).ask(), + ) if utility_lse_id is None: console.print("No utility chosen") return None @@ -47,86 +54,97 @@ def _find_utility_lse_id(utility: Utility) -> int | None: def _select_tariffs( - lse_id: int, customer_classes: list[CustomerClass], tariff_types: list[TariffType] + api: ArcadiaSignalAPI, lse_id: int, customer_classes: list[CustomerClass], tariff_types: list[TariffType] ) -> list[tuple[str, int]]: with console.status("Fetching tariffs..."): tariffs = list( - tariffs_paginate( - lseId=lse_id, - fields="min", - effectiveOn=datetime.now(timezone.utc), - customerClasses=customer_classes, - tariffTypes=tariff_types, + api.tariffs.iter_pages( + lse_id=lse_id, + effective_on=date.today(), + customer_classes=customer_classes, + tariff_types=tariff_types, ) ) if not tariffs: return [] - return questionary.checkbox( - message="Select tariffs", - choices=[ - questionary.Choice(title=_["tariffName"], value=(_["tariffName"], _["masterTariffId"]), checked=True) - for _ in tariffs - ], - use_search_filter=True, - use_jk_keys=False, - ).ask() + return cast( + list[tuple[str, int]], + questionary.checkbox( + message="Select tariffs", + choices=[ + questionary.Choice( + title=f"{_['tariff_name']} ({_['tariff_id']})", + value=(_["tariff_name"], _["master_tariff_id"]), # pyright: ignore[reportAny] + checked=True, + ) + for _ in tariffs + ], + use_search_filter=True, + use_jk_keys=False, + ).ask(), + ) def _select_customer_classes() -> list[CustomerClass]: - return questionary.checkbox( - message="Select customer classes", - choices=[ - questionary.Choice(title="Residential", value="RESIDENTIAL"), - questionary.Choice(title="General", value="GENERAL"), - questionary.Choice(title="Special Use", value="SPECIAL_USE"), - ], - validate=lambda _: True if _ else "Select at least one customer class", - ).ask() + return cast( + list[CustomerClass], + questionary.checkbox( + message="Select customer classes", + choices=[ + questionary.Choice(title="Residential", value="RESIDENTIAL"), + questionary.Choice(title="General", value="GENERAL"), + questionary.Choice(title="Special Use", value="SPECIAL_USE"), + ], + validate=lambda _: True if _ else "Select at least one customer class", + ).ask(), + ) def _select_tariff_types() -> list[TariffType]: - return questionary.checkbox( - message="Select tariff types", - choices=[ - questionary.Choice(title="Default", value="DEFAULT"), - questionary.Choice(title="Alternative", value="ALTERNATIVE"), - questionary.Choice(title="Optional extra", value="OPTIONAL_EXTRA"), - questionary.Choice(title="Rider", value="RIDER"), - ], - validate=lambda _: bool(_) or "Select at least one tariff type", - ).ask() - - -def _fetch_tariffs(tariffs: list[tuple[str, int]]): - result = [] + return cast( + list[TariffType], + questionary.checkbox( + message="Select tariff types", + choices=[ + questionary.Choice(title="Default", value="DEFAULT"), + questionary.Choice(title="Alternative", value="ALTERNATIVE"), + questionary.Choice(title="Optional extra", value="OPTIONAL_EXTRA"), + questionary.Choice(title="Rider", value="RIDER"), + ], + validate=lambda _: bool(_) or "Select at least one tariff type", + ).ask(), + ) + + +def _fetch_tariffs(api: ArcadiaSignalAPI, tariffs: list[tuple[str, int]]): + result: list[tariff.TariffExtended] = [] with console.status("Fetching tariffs..."): for name, id_ in tariffs: console.print(f"Tariff id: {name}") - page = list( - tariffs_paginate( - masterTariffId=id_, - effectiveOn=datetime.now(timezone.utc), - fields="ext", - populateProperties=True, - populateRates=True, - ) + page = api.tariffs.iter_pages( + fields="ext", + master_tariff_id=id_, + effective_on=date.today(), + populate_properties=True, + populate_rates=True, ) result.extend(page) return result def process_genability(utility: Utility, output_folder: Path): - load_dotenv() + _ = load_dotenv() if not os.getenv("ARCADIA_APP_ID"): console.print("[b]ARCADIA_APP_ID[/] environment variable is not set.") if not os.getenv("ARCADIA_APP_KEY"): console.print("[b]ARCADIA_APP_KEY[/] environment variable is not set.") if not (os.getenv("ARCADIA_APP_ID") and os.getenv("ARCADIA_APP_KEY")): console.print("Cannot use Arcadia API due to missing credentials") - console.input("Press enter to proceed...") + _ = console.input("Press enter to proceed...") return + api = ArcadiaSignalAPI() - lse_id = _find_utility_lse_id(utility) + lse_id = _find_utility_lse_id(api, utility) if lse_id is None: return @@ -136,17 +154,17 @@ def process_genability(utility: Utility, output_folder: Path): if not (tariff_types := _select_tariff_types()): return - if not (tariffs := _select_tariffs(lse_id, customer_classes, tariff_types)): + if not (tariffs := _select_tariffs(api, lse_id, customer_classes, tariff_types)): console.print("[red]No tariffs found[/]") - console.input("Press enter to proceed...") + _ = console.input("Press enter to proceed...") return - results = _fetch_tariffs(tariffs) + results = _fetch_tariffs(api, tariffs) suggested_filename = f"arcadia_{utility.name}" if not (filename := prompt_filename(output_folder, suggested_filename, "json")): return filename.parent.mkdir(exist_ok=True) - filename.write_text(json.dumps(results, indent=2)) + _ = filename.write_bytes(TypeAdapter(list[tariff.TariffExtended]).dump_json(results, indent=2)) console.print(f"Wrote [blue]{len(results)}[/] records to {filename}") diff --git a/tariff_fetch/_cli/openei.py b/tariff_fetch/_cli/openei.py index 377d5f3..efd7448 100644 --- a/tariff_fetch/_cli/openei.py +++ b/tariff_fetch/_cli/openei.py @@ -2,7 +2,7 @@ import os from datetime import datetime, timezone from pathlib import Path -from typing import Literal +from typing import Literal, cast import questionary from dotenv import load_dotenv @@ -14,22 +14,28 @@ def _prompt_sector() -> UtilityRateSector: - return questionary.select( - message="Select sector", - choices=[ - "Residential", - "Commercial", - "Industrial", - "Lighting", - ], - ).ask() + return cast( + UtilityRateSector, + questionary.select( + message="Select sector", + choices=[ + "Residential", + "Commercial", + "Industrial", + "Lighting", + ], + ).ask(), + ) def _prompt_detail_level() -> Literal["full", "minimal"]: - return questionary.select( - message="Select level of detail", - choices=["full", "minimal"], - ).ask() + return cast( + Literal["full", "minimal"], + questionary.select( + message="Select level of detail", + choices=["full", "minimal"], + ).ask(), + ) def _get_tariffs( @@ -50,18 +56,21 @@ def _get_tariffs( def _prompt_tariffs(tariffs: list[UtilityRatesResponseItem]) -> list[UtilityRatesResponseItem]: - return questionary.checkbox( - message="Select tariffs to include", - choices=[questionary.Choice(title=_["name"], value=_, checked=True) for _ in tariffs], - ).ask() + return cast( + list[UtilityRatesResponseItem], + questionary.checkbox( + message="Select tariffs to include", + choices=[questionary.Choice(title=_["name"], value=_, checked=True) for _ in tariffs], + ).ask(), + ) def process_openei(utility: Utility, output_folder: Path): - load_dotenv() + _ = load_dotenv() if not os.getenv("OPENEI_API_KEY"): console.print("[b]OPENEI_API_KEY[/] environment variable is not set") console.print("Cannot use OpenEI API due to missing credentials") - console.input("Press enter to proceed...") + _ = console.input("Press enter to proceed...") return if not (sector := _prompt_sector()): @@ -71,12 +80,12 @@ def process_openei(utility: Utility, output_folder: Path): tariffs = _get_tariffs(utility.eia_id, sector, detail_level) if not tariffs: console.print("[red]No tariffs found[/]") - console.input("Press enter to proceed...") + _ = console.input("Press enter to proceed...") return tariffs = _prompt_tariffs(tariffs) if not tariffs: console.print("[red]No tariffs selected[/]") - console.input("Press enter to proceed...") + _ = console.input("Press enter to proceed...") return suggested_filename = f"openei_{utility.name}_{sector}_{detail_level}" @@ -85,5 +94,5 @@ def process_openei(utility: Utility, output_folder: Path): filepath.parent.mkdir(exist_ok=True) print(filepath) - filepath.write_text(json.dumps(tariffs, indent=2)) + _ = filepath.write_text(json.dumps(tariffs, indent=2)) console.print(f"Wrote [blue]{len(tariffs)}[/] items to {filepath}") diff --git a/tariff_fetch/_cli/rateacuity.py b/tariff_fetch/_cli/rateacuity.py index 51863a2..51bf365 100644 --- a/tariff_fetch/_cli/rateacuity.py +++ b/tariff_fetch/_cli/rateacuity.py @@ -1,33 +1,35 @@ import json import os from pathlib import Path +from typing import cast import questionary import tenacity from dotenv import load_dotenv -from fuzzywuzzy import fuzz +from fuzzywuzzy import fuzz # pyright: ignore[reportMissingTypeStubs] from selenium.common.exceptions import WebDriverException from tariff_fetch._cli.types import Utility from tariff_fetch.rateacuity import LoginState, create_context +from tariff_fetch.rateacuity.schema import Tariff from . import console, prompt_filename def process_rateacuity_gas(output_folder: Path, state: str): - load_dotenv() + _ = load_dotenv() if not (username := os.getenv("RATEACUITY_USERNAME")): console.print("[b]RATEACUITY_USERNAME[/] environment variable is not set") if not (password := os.getenv("RATEACUITY_PASSWORD")): console.print("[b]RATEACUITY_PASSWORD[/] environment variable is not set") if not (username and password): console.print("Cannot use RateAcuity due to missing credentials") - console.input("Press enter to proceed...") + _ = console.input("Press enter to proceed...") return selected_utility = None tariffs_to_include = None - results = [] + results: list[Tariff] = [] for attempt in tenacity.Retrying( stop=tenacity.stop_after_attempt(3), retry=tenacity.retry_if_exception_type(WebDriverException) @@ -43,13 +45,16 @@ def process_rateacuity_gas(output_folder: Path, state: str): raise RuntimeError(f"Something's wrong: rateacuity shows no utilities for this state ({state})") if selected_utility is None: - selected_utility = questionary.select( - message="Select a utility from available choices", - choices=utilities, - use_jk_keys=False, - use_search_filter=True, - use_shortcuts=False, - ).ask() + selected_utility = cast( + str, + questionary.select( + message="Select a utility from available choices", + choices=utilities, + use_jk_keys=False, + use_search_filter=True, + use_shortcuts=False, + ).ask(), + ) if not selected_utility: return @@ -58,17 +63,20 @@ def process_rateacuity_gas(output_folder: Path, state: str): tariffs = [_ for _ in scraping_state.get_schedules() if _] if tariffs_to_include is None: - tariffs_to_include = questionary.checkbox( - message="Select tariffs to include", - choices=tariffs, - use_jk_keys=False, - use_search_filter=True, - validate=lambda _: bool(_) or "Select at least one tariff", - ).ask() + tariffs_to_include = cast( + list[str], + questionary.checkbox( + message="Select tariffs to include", + choices=tariffs, + use_jk_keys=False, + use_search_filter=True, + validate=lambda _: bool(_) or "Select at least one tariff", + ).ask(), + ) if not tariffs_to_include: console.print("[red]No tariffs selected[/]") - console.input("Press enter to proceed...") + _ = console.input("Press enter to proceed...") return with console.status("Fetching tariffs..."): @@ -84,23 +92,23 @@ def process_rateacuity_gas(output_folder: Path, state: str): suggested_filename = f"gas_rateacuity_{selected_utility}" filename = prompt_filename(output_folder, suggested_filename, "json") filename.parent.mkdir(exist_ok=True) - filename.write_text(json.dumps(results, indent=2)) + _ = filename.write_text(json.dumps(results, indent=2)) def process_rateacuity(output_folder: Path, state: str, utility: Utility): - load_dotenv() + _ = load_dotenv() if not (username := os.getenv("RATEACUITY_USERNAME")): console.print("[b]RATEACUITY_USERNAME[/] environment variable is not set") if not (password := os.getenv("RATEACUITY_PASSWORD")): console.print("[b]RATEACUITY_PASSWORD[/] environment variable is not set") if not (username and password): console.print("Cannot use RateAcuity due to missing credentials") - console.input("Press enter to proceed...") + _ = console.input("Press enter to proceed...") return selected_utility = None tariffs_to_include = None - results = [] + results: list[Tariff] = [] for attempt in tenacity.Retrying( stop=tenacity.stop_after_attempt(3), retry=tenacity.retry_if_exception_type(WebDriverException) @@ -116,16 +124,19 @@ def process_rateacuity(output_folder: Path, state: str, utility: Utility): raise RuntimeError(f"Something's wrong: rateacuity shows no utilities for this state ({state})") if selected_utility is None: - utilities_scored = sorted(utilities, key=lambda _: fuzz.ratio(utility.name, _), reverse=True) + utilities_scored = sorted(utilities, key=lambda _: fuzz.ratio(utility.name, _), reverse=True) # pyright: ignore[reportUnknownMemberType] selected_utility = utilities_scored.pop(0) if not questionary.confirm(f"Is this the correct utility: {selected_utility} ?").ask(): - selected_utility = questionary.select( - message="Select a utility from available choices", - choices=utilities_scored, - use_jk_keys=False, - use_search_filter=True, - use_shortcuts=False, - ).ask() + selected_utility = cast( + str, + questionary.select( + message="Select a utility from available choices", + choices=utilities_scored, + use_jk_keys=False, + use_search_filter=True, + use_shortcuts=False, + ).ask(), + ) if not selected_utility: return @@ -134,17 +145,20 @@ def process_rateacuity(output_folder: Path, state: str, utility: Utility): tariffs = [_ for _ in scraping_state.get_schedules() if _] if tariffs_to_include is None: - tariffs_to_include = questionary.checkbox( - message="Select tariffs to include", - choices=tariffs, - use_jk_keys=False, - use_search_filter=True, - validate=lambda _: bool(_) or "Select at least one tariff", - ).ask() + tariffs_to_include = cast( + list[str], + questionary.checkbox( + message="Select tariffs to include", + choices=tariffs, + use_jk_keys=False, + use_search_filter=True, + validate=lambda _: bool(_) or "Select at least one tariff", + ).ask(), + ) if not tariffs_to_include: console.print("[red]No tariffs selected[/]") - console.input("Press enter to proceed...") + _ = console.input("Press enter to proceed...") return with console.status("Fetching tariffs..."): @@ -161,4 +175,4 @@ def process_rateacuity(output_folder: Path, state: str, utility: Utility): if not (filename := prompt_filename(output_folder, suggested_filename, "json")): return filename.parent.mkdir(exist_ok=True) - filename.write_text(json.dumps(results, indent=2)) + _ = filename.write_text(json.dumps(results, indent=2)) diff --git a/tariff_fetch/arcadia/__init__.py b/tariff_fetch/arcadia/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tariff_fetch/arcadia/api.py b/tariff_fetch/arcadia/api.py new file mode 100644 index 0000000..a2b31c6 --- /dev/null +++ b/tariff_fetch/arcadia/api.py @@ -0,0 +1,292 @@ +import os +from collections.abc import Iterator +from dataclasses import dataclass, field +from datetime import date +from typing import Annotated, Any, Generic, Literal, Self, TypedDict, TypeVar, Unpack, overload +from urllib.parse import urljoin + +import requests +from pydantic import ConfigDict, PlainSerializer, TypeAdapter +from pydantic.alias_generators import to_camel +from requests.auth import HTTPBasicAuth + +from .schema.common import CustomerClass, TariffType +from .schema.lookup import Lookup +from .schema.lse import LSEExtended, LSEMinimal, ServiceType +from .schema.tariff import TariffExtended, TariffStandard + +_BASE_URL = "https://api.genability.com/rest/public/" +_comma_separated = PlainSerializer(",".join) + +_T = TypeVar("_T") +_C = TypeVar("_C") + + +class SearchParams(TypedDict, total=False): + search: str + """ + The string of text to search on. + This can also be a regular expression, in which case you should + set the isRegex flag to true (see below). + """ + search_on: Annotated[list[str], _comma_separated] + """ + Comma-separated list of fields to query on. When searchOn is specified, the text provided + in the search string field will be searched within these fields. The list of fields to search on + depends on the entity being searched for. Read the documentation for the entity for more details + """ + starts_with: bool + "When true, the search will only return results that begin with the specified search string." + ends_with: bool + "When true, the search will only return results that end with the specified search string." + is_regex: bool + """ + When true, the provided search string will be regarded as a regular expression + and the search will return results matching the regular expression. + Default is False. + """ + sort_on: Annotated[list[str], _comma_separated] + sort_order: Annotated[list[Literal["ASC", "DESC"]], _comma_separated] + """ + Comma-separated list of ordering. Possible values are ASC and DESC. Default is ASC. + If your sortOn contains multiple fields and you would like to order fields individually, + you can pass in a comma-separated list here + """ + + +class PagingParams(TypedDict, total=False): + __pydantic_config__ = ConfigDict(alias_generator=to_camel) # pyright: ignore[reportGeneralTypeIssues,reportUnannotatedClassAttribute] + page_start: Annotated[int, PlainSerializer(str)] + page_count: Annotated[int, PlainSerializer(str)] + + +class GetLSEsParams(TypedDict, total=False): + __pydantic_config__ = ConfigDict(alias_generator=to_camel) # pyright: ignore[reportGeneralTypeIssues,reportUnannotatedClassAttribute] + zip_code: str + "Zip or post code where you would like to see a list of LSEs for (e.g. 5 digit ZIP code for USA)." + country: str + "ISO country code" + ownerships: Annotated[list[Literal["INVESTOR", "COOP", "MUNI"]], _comma_separated] + "Filter results by the type of ownership structure for the LSE." + serviceTypes: Annotated[list[ServiceType], _comma_separated] + "Filter results to LSEs that just offer this service type to a customer class" + residential_service_types: Annotated[list[ServiceType], _comma_separated] + commercial_service_types: Annotated[list[ServiceType], _comma_separated] + industrial_service_types: Annotated[list[ServiceType], _comma_separated] + transportation_service_types: Annotated[list[ServiceType], _comma_separated] + + +class TariffsParams(TypedDict, total=False): + lse_id: int + effective_on: date + customer_classes: Annotated[list[CustomerClass], _comma_separated] + master_tariff_id: int + tariff_types: Annotated[list[TariffType], _comma_separated] + populate_properties: bool + populate_rates: bool + populate_documents: bool + + +class LookupsParams(PagingParams, total=False): + __pydantic_config__ = ConfigDict(alias_generator=to_camel) # pyright: ignore[reportGeneralTypeIssues,reportUnannotatedClassAttribute] + sub_property_key: str + """Subproperty key name""" + from_date_time: date + to_date_time: date + + +class PropertyParams(TypedDict, total=False): + __pydantic_config__ = ConfigDict(alias_generator=to_camel) # pyright: ignore[reportGeneralTypeIssues,reportUnannotatedClassAttribute] + + +class PageResponse(TypedDict, Generic[_T]): + __pydantic_config__ = {"alias_generator": to_camel, "extra": "forbid"} # pyright: ignore[reportGeneralTypeIssues, reportUnannotatedClassAttribute] # noqa + status: str + count: int + type: str + results: list[_T] + page_count: int + page_start: int + + +class GetLSEsPageParams(PagingParams, SearchParams, GetLSEsParams): + __pydantic_config__ = {"alias_generator": to_camel, "extra": "forbid"} # pyright: ignore[reportGeneralTypeIssues, reportUnannotatedClassAttribute] # noqa + + +class GetTariffsPageParams(PagingParams, SearchParams, TariffsParams): + __pydantic_config__ = {"alias_generator": to_camel, "extra": "forbid"} # pyright: ignore[reportGeneralTypeIssues, reportUnannotatedClassAttribute] # noqa + + +@dataclass(frozen=True) +class ArcadiaSignalAPIAuth: + app_id: str + app_key: str + + @classmethod + def from_env(cls) -> Self: + app_id = os.getenv("ARCADIA_APP_ID") + if app_id is None: + raise ValueError("ARCADIA_APP_ID variable is not set. Either set it or use auth as a parameter.") + app_key = os.getenv("ARCADIA_APP_KEY") + if app_key is None: + raise ValueError("ARCADIA_APP_KEY variable is not set. Either set it or use auth as a parameter.") + return cls(app_id, app_key) + + +@dataclass(frozen=True) +class ArcadiaSignalAPI: + auth: ArcadiaSignalAPIAuth = field(default_factory=ArcadiaSignalAPIAuth.from_env) + base_url: str = _BASE_URL + session: requests.Session = field(default_factory=requests.Session) + + def _request(self, path: str, **params) -> dict[str, Any]: # pyright: ignore[reportMissingParameterType, reportUnknownParameterType, reportExplicitAny] + response = self.session.get( + urljoin(self.base_url, path), + params=params, # pyright: ignore[reportUnknownArgumentType] + auth=HTTPBasicAuth(self.auth.app_id, self.auth.app_key), + ) + response.raise_for_status() + return response.json() # pyright: ignore[reportAny] + + def _request_typed( + self, + path: str, + params: _C, + extras: dict[str, Any], # pyright: ignore[reportExplicitAny] + params_type: type[_C], + response_type: type[_T], + ) -> _T: + param_type_adapter = TypeAdapter(params_type) + params_to_api = param_type_adapter.dump_python(params, by_alias=True) # pyright: ignore[reportAny] + response = self._request(path, **params_to_api, **extras) # pyright: ignore[reportUnknownMemberType, reportAny] + result_adapter = TypeAdapter(response_type) + return result_adapter.validate_python(response) + + @property + def lses(self) -> "ArcadiaSignalAPILse": + return ArcadiaSignalAPILse(self.auth, self.base_url, self.session) + + @property + def tariffs(self) -> "ArcadiaSignalAPITariff": + return ArcadiaSignalAPITariff(self.auth, self.base_url, self.session) + + @property + def properties(self) -> "ArcadiaPropertiesAPI": + return ArcadiaPropertiesAPI(self.auth, self.base_url, self.session) + + +@dataclass(frozen=True) +class ArcadiaSignalAPILse(ArcadiaSignalAPI): + @overload + def get_page(self, **params: Unpack[GetLSEsPageParams]) -> PageResponse[LSEMinimal]: ... + + @overload + def get_page(self, fields: None, **params: Unpack[GetLSEsPageParams]) -> PageResponse[LSEMinimal]: ... + + @overload + def get_page(self, fields: Literal["min"], **params: Unpack[GetLSEsPageParams]) -> PageResponse[LSEMinimal]: ... + + @overload + def get_page(self, fields: Literal["ext"], **params: Unpack[GetLSEsPageParams]) -> PageResponse[LSEExtended]: ... + + def get_page(self, fields: Literal["min", "ext"] | None = None, **params: Unpack[GetLSEsPageParams]): + match fields: + case "min" | None: + return self._request_typed( + "lses", params, {"fields": "min"}, GetLSEsPageParams, PageResponse[LSEMinimal] + ) + case "ext": + return self._request_typed( + "lses", params, {"fields": "ext"}, GetLSEsPageParams, PageResponse[LSEExtended] + ) + + +@dataclass(frozen=True) +class ArcadiaSignalAPITariff(ArcadiaSignalAPI): + # @overload + # def get_page( + # self, fields: Literal["min"], **params: Unpack[GetTariffsPageParams] + # ) -> PageResponse[TariffMinimal]: ... + + @overload + def get_page( + self, fields: Literal["ext"], **params: Unpack[GetTariffsPageParams] + ) -> PageResponse[TariffExtended]: ... + + @overload + def get_page(self, fields: None, **params: Unpack[GetTariffsPageParams]) -> PageResponse[TariffStandard]: ... + + @overload + def get_page(self, **params: Unpack[GetTariffsPageParams]) -> PageResponse[TariffStandard]: ... + + def get_page(self, fields: Literal["ext"] | None = None, **params: Unpack[GetTariffsPageParams]): + match fields: + # case "min": + # return self._request_typed( + # "tariffs", params, {"fields": "min"}, GetTariffsPageParams, PageResponse[TariffMinimal] + # ) + case "ext": + return self._request_typed( + "tariffs", params, {"fields": "ext"}, GetTariffsPageParams, PageResponse[TariffExtended] + ) + case None: + return self._request_typed("tariffs", params, {}, GetTariffsPageParams, PageResponse[TariffStandard]) + + # @overload + # def iter_pages(self, fields: Literal["min"], **params: Unpack[GetTariffsPageParams]) -> Iterator[TariffMinimal]: ... + + @overload + def iter_pages( + self, fields: Literal["ext"], **params: Unpack[GetTariffsPageParams] + ) -> Iterator[TariffExtended]: ... + + @overload + def iter_pages(self, fields: None, **params: Unpack[GetTariffsPageParams]) -> Iterator[TariffStandard]: ... + + @overload + def iter_pages(self, **params: Unpack[GetTariffsPageParams]) -> Iterator[TariffStandard]: ... + + def iter_pages( + self, fields: Literal["ext"] | None = None, **params: Unpack[GetTariffsPageParams] + ) -> Iterator[TariffExtended] | Iterator[TariffStandard]: + page_start = params.get("page_start", 0) + while True: + params_: GetTariffsPageParams = {**params, "page_start": page_start} + response = self.get_page(fields, **params_) + count, page_start = response["count"], response["page_start"] + size = len(response["results"]) + yield from response["results"] # pyright: ignore[reportReturnType] + if page_start + size >= count: + return + if size <= 0: + return + page_start += size + + +@dataclass(frozen=True) +class ArcadiaPropertiesAPI(ArcadiaSignalAPI): + @property + def lookups(self) -> "ArcadiaLookupsAPI": + return ArcadiaLookupsAPI(self.auth, self.base_url, self.session) + + +@dataclass(frozen=True) +class ArcadiaLookupsAPI(ArcadiaPropertiesAPI): + def get_page(self, property_key: str, **params: Unpack[LookupsParams]) -> PageResponse[Lookup]: + return self._request_typed( + f"properties/{property_key}/lookups", params, {}, LookupsParams, PageResponse[Lookup] + ) + + def iter_pages(self, property_key: str, **params: Unpack[LookupsParams]) -> Iterator[Lookup]: + page_start = params.get("page_start", 0) + while True: + params_: LookupsParams = {**params, "page_start": page_start} + response = self.get_page(property_key, **params_) + count, page_start = response["count"], response["page_start"] + size = len(response["results"]) + yield from response["results"] + if page_start + size >= count: + return + if size <= 0: + return + page_start += size diff --git a/tariff_fetch/arcadia/schema/__init__.py b/tariff_fetch/arcadia/schema/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tariff_fetch/arcadia/schema/common.py b/tariff_fetch/arcadia/schema/common.py new file mode 100644 index 0000000..a3bd772 --- /dev/null +++ b/tariff_fetch/arcadia/schema/common.py @@ -0,0 +1,42 @@ +from typing import Literal + +TariffPrivacy = Literal["PUBLIC", "UNLISTED", "PRIVATE"] + +LoadServingServiceType = Literal["ELECTRICITY", "SOLAR_PV"] +TariffServiceType = Literal[LoadServingServiceType, "GAS"] +TariffType = Literal["DEFAULT", "ALTERNATIVE", "OPTIONAL_EXTRA", "RIDER"] +CustomerClass = Literal["RESIDENTIAL", "GENERAL", "SPECIAL_USE"] +TariffEffectiveOnRule = Literal["TARIFF_EFFECTIVE_DATE", "BILLING_PERIOD_START"] +TariffChargeType = Literal[ + "FIXED_PRICE", + "CONSUMPTION_BASED", + "DEMAND_BASED", + "QUANTITY", + "FORMULA", + "MINIMUM", + "MAXIMUM", + "TAX", +] +TariffChargePeriod = Literal["MONTHLY", "DAILY", "QUARTERLY", "ANNUALLY", "HOURLY"] + +RateChargeClass = Literal[ + "SUPPLY", + "TRANSMISSION", + "DISTRIBUTION", + "TAX", + "CONTRACTED", + "USER_ADJUSTED", + "AFTER_TAX", + "OTHER", + "NON_BYPASSABLE", +] +RateTransactionType = Literal["BUY", "SELL", "NET", "BUY_IMPORT", "SELL_IMPORT"] +RateUnit = Literal["COST_PER_UNIT", "PERCENTAGE"] + +TerritoryUsageType = Literal["SERVICE", "TARIFF", "CLIMATE_ZONE", "UTILITY_CLIMATE_ZONE"] +TerritoryItemType = Literal["STATE", "COUNTY", "CITY", "ZIPCODE"] + +SeasonPredominance = Literal["PREDOMINANT", "SUBSERVIENT"] + +TimeOfUseType = Literal["SUPER_OFF_PEAK", "OFF_PEAK", "PARTIAL_PEAK", "ON_PEAK", "SUPER_ON_PEAK", "CRITICAL_PEAK"] +TimeOfUsePrivacy = Literal["PUBLIC", "PRIVATE"] diff --git a/tariff_fetch/arcadia/schema/lookup.py b/tariff_fetch/arcadia/schema/lookup.py new file mode 100644 index 0000000..534e333 --- /dev/null +++ b/tariff_fetch/arcadia/schema/lookup.py @@ -0,0 +1,30 @@ +from datetime import datetime +from typing import NotRequired, TypedDict + +from pydantic.alias_generators import to_camel + + +class Lookup(TypedDict): + __pydantic_config__ = {"alias_generator": to_camel, "extra": "allow"} # pyright: ignore[reportGeneralTypeIssues, reportUnannotatedClassAttribute] # noqa + lookup_id: int + """The unique ID for this lookup value entry in the lookup table""" + property_key: str + """The unique name for the property this lookup belongs to""" + sub_property_key: NotRequired[str] + """Sub-property this lookup value applies to (when needed, e.g., wholesale index node or zone)""" + from_date_time: datetime + to_date_time: datetime | None + best_value: float | None + """Best kWh/kW price for the property key for the date range""" + best_accuracy: float | None + """Reserved for future use""" + actual_value: float | None + """Actual kWh/kW price for the property key for the date range""" + """kWh/kW price forecasted by the utility for the property key for the date range""" + lse_forecast_value: float | None + lse_forecast_accuracy: float | None + """Reserved for future use""" + forecast_value: float | None + """kWh/kW price forecasted by Arcadia for the property key for the date range based on previous values""" + forecast_accuracy: float | None + """Reserved for future use""" diff --git a/tariff_fetch/arcadia/schema/lse.py b/tariff_fetch/arcadia/schema/lse.py new file mode 100644 index 0000000..04ece35 --- /dev/null +++ b/tariff_fetch/arcadia/schema/lse.py @@ -0,0 +1,84 @@ +from typing import Annotated, Literal, TypedDict + +from pydantic import BeforeValidator +from pydantic.alias_generators import to_camel + +from .validators import comma_separated_str + +OfferingType = Literal["Bundle", "Delivery", "Energy"] +Ownership = Literal[ + "INVESTOR", + "COOP", + "MUNI", + "FEDERAL", + "POLITICAL_SUBDIVISION", + "RETAIL_ENERGY_MARKETER", + "WHOLESALE_ENERGY_MARKETER", + "TRANSMISSION", + "STATE", + "UNREGULATED", +] +ServiceType = Literal["ELECTRICITY", "SOLAR_PV"] + + +class BillingPeriodRepresentation(TypedDict): + from_date_offset: int + """The number of days to add to a Arcadia-style fromDate to obtain the LSE's start of a billing period.""" + to_date_offset: int + """The number of days to add to a Arcadia-style toDate to obtain the LSE's end of a billing period.""" + style: str + """Arcadia name for this particular style of a Billing Period Representation.""" + + +class LSEMinimalFields(TypedDict): + lse_id: int + """Unique Arcadia ID (primary key) for each LSE""" + name: str + """Published name of the company""" + code: str + """Shortcode (an alternate key). For US companies this is the EIA ID""" + website_home: str + """The URL to the home page of the LSE website""" + + +class LSEExtendedFields(TypedDict): + lse_code: str + offering_type: OfferingType + """Utility classification is based on the type of service they offer. + + Possible values: + Bundled - The utility provides all the electricity services bundled into one, including the transportation (transmission and distribution) and the generation (energy) portion. + Delivery - Responsible for the transportation (transmission and distribution) of power only. + Energy - Provides only the generation (energy) portion of the service. Energy Suppliers are sometimes referred to as Retail Energy Providers. + """ + ownership: Ownership + """Ownership structure""" + service_types: Annotated[list[ServiceType], BeforeValidator(comma_separated_str)] + totalRevenues: int + totalSales: int + totalCustomers: int + residential_service_types: Annotated[list[ServiceType], BeforeValidator(comma_separated_str)] | None + residential_revenues: int + residential_sales: int + residential_customers: int + commercial_service_types: Annotated[list[ServiceType], BeforeValidator(comma_separated_str)] | None + commercial_revenues: int + commercial_sales: int + commercial_customers: int + industrial_service_types: Annotated[list[ServiceType], BeforeValidator(comma_separated_str)] | None + industrial_revenues: int + industrial_sales: int + industrial_customers: int + transportation_service_types: Annotated[list[ServiceType], BeforeValidator(comma_separated_str)] | None + transportation_revenues: int + transportation_sales: int + transportation_customers: int + billing_period_representation: BillingPeriodRepresentation + + +class LSEMinimal(LSEMinimalFields): + __pydantic_config__ = {"alias_generator": to_camel, "extra": "forbid"} # pyright: ignore[reportGeneralTypeIssues, reportUnannotatedClassAttribute] # noqa + + +class LSEExtended(LSEMinimalFields, LSEExtendedFields): + __pydantic_config__ = {"alias_generator": to_camel, "extra": "forbid"} # pyright: ignore[reportGeneralTypeIssues, reportUnannotatedClassAttribute] # noqa diff --git a/tariff_fetch/arcadia/schema/season.py b/tariff_fetch/arcadia/schema/season.py new file mode 100644 index 0000000..05cf6f7 --- /dev/null +++ b/tariff_fetch/arcadia/schema/season.py @@ -0,0 +1,48 @@ +from typing import Annotated, NotRequired, TypedDict + +from pydantic import Field +from pydantic.alias_generators import to_camel + +from .common import SeasonPredominance + + +class SeasonStandardFields(TypedDict): + season_id: int + """Unique Arcadia ID (primary key) for each season""" + lse_id: int + """The ID of the LSE this territory belongs to""" + season_group_id: int + """The ID of the season group that contains this season""" + season_name: str + """The name of the season (i.e. "Summer" or "Winter")""" + season_from_month: Annotated[int, Field(ge=1, le=12)] + """ Value of 1-12 representing the month this season begins""" + season_from_day: Annotated[int, Field(ge=1, le=31)] + """Value of 1-31 (depending on month) representing the day this season begins""" + season_to_month: Annotated[int, Field(ge=1, le=12)] + """Value of 1-12 representing the month this season ends""" + season_to_day: Annotated[int, Field(ge=1, le=31)] + """Value of 1-31 (depending on month) representing the day this season ends""" + + +class SeasonExtendedFields(TypedDict): + from_edge_predominance: NotRequired[SeasonPredominance | None] + """ + Can be None (from date is a fixed date), + PREDOMINANT (from date is the start of the bill the season starts in), + or SUBSERVIENT (from date is the end of the bill the season starts in). + """ + to_edge_predominance: NotRequired[SeasonPredominance | None] + """ + Can be None (to date is a fixed date), + PREDOMINANT (to date is the start of the bill the season starts ends in), + or SUBSERVIENT (to date is the end of the bill the season starts in). + """ + + +class SeasonStandard(SeasonStandardFields): + __pydantic_config__ = {"alias_generator": to_camel, "extra": "forbid"} # pyright: ignore[reportGeneralTypeIssues, reportUnannotatedClassAttribute] # noqa + + +class SeasonExtended(SeasonStandardFields, SeasonExtendedFields): + __pydantic_config__ = {"alias_generator": to_camel, "extra": "forbid"} # pyright: ignore[reportGeneralTypeIssues, reportUnannotatedClassAttribute] # noqa diff --git a/tariff_fetch/arcadia/schema/tariff.py b/tariff_fetch/arcadia/schema/tariff.py new file mode 100644 index 0000000..8e57901 --- /dev/null +++ b/tariff_fetch/arcadia/schema/tariff.py @@ -0,0 +1,162 @@ +from datetime import date, datetime +from typing import Annotated, NotRequired, TypedDict + +from pydantic import BeforeValidator, FailFast +from pydantic.alias_generators import to_camel + +from .common import ( + CustomerClass, + TariffChargePeriod, + TariffChargeType, + TariffEffectiveOnRule, + TariffPrivacy, + TariffServiceType, + TariffType, +) +from .tariffproperty import TariffPropertyStandard +from .tariffrate import TariffRateExtended, TariffRateStandard + + +class TariffStandardFields(TypedDict): + prior_tariff_id: NotRequired[int] + "Unique Arcadia ID that identifies the prior revision of the tariffId above" + distribution_lse_id: NotRequired[int | None] + """ + In states like Texas where the load-serving entity that sells the power is + different than the load-serving entity that distributes the power, this will + contain the ID of the distribution LSE. Otherwise, it will be None. + """ + tariff_type: TariffType + """ + Possible values are: + DEFAULT - tariff that is automatically given to this service class + ALTERNATIVE - opt-in alternate tariff for this service class + OPTIONAL_EXTRA - opt-in extra, such as green power or a smart thermostat program + RIDER - charge that can apply to multiple tariffs. Often a regulatory-mandated charge + """ + customer_class: CustomerClass | None + """ + Possible values are: + RESIDENTIAL - homes, apartments etc. + GENERAL - commercial, industrial, and other business and organization service types + (often have additional applicability criteria) + SPECIAL_USE - examples are government, agriculture, street lighting, transportation + PROPOSED - Utility rates that have been proposed by utilities and approved by utility commissions, + but are not yet effective (requires product subscription) + """ + customer_count: NotRequired[int] + "Number of customers that are on this master tariff" + """ + The likelihood that a customer is on this tariff of all the tariffs in the search results. + """ + customer_likelihood: NotRequired[float | None] + """ + The likelihood that a customer is on this tariff of all the tariffs in the search results. + Only populated when getting more than one tariff. + """ + territory_id: int + """ + ID of the territory that this tariff applies to. + This is typically the service area for the LSE in this regulatory region (i.e. a state in the USA) + """ + effective_date: date + "Date on which this tariff is no longer effective. Can be null which means end date is not known or tariff is open-ended" + end_date: date | None + "Date on which the tariff was or will be effective" + time_zone: str + "If populated (usually is), it's the timezone that this tariffs dates and times refer to" + effective_on_rule: NotRequired[TariffEffectiveOnRule] + billing_period: str + "How frequently bills are generated." + currency: str + "ISO Currency code that the rates for this tariff refer to (e.g. USD for USA Dollar)" + charge_types: Annotated[ + list[TariffChargeType], BeforeValidator(lambda _: _.split(",") if isinstance(_, str) else _) # pyright: ignore[reportAny] + ] + "List of all the different ChargeType rates on this tariff" + charge_period: TariffChargePeriod + "The most fine-grained period for which charges are calculated." + has_time_of_use_rates: bool + "Indicates whether this tariff contains one or more Time of Use Rate." + has_tiered_rates: bool + "Indicates whether this tariff contains one or more Tiered Rate." + has_contracted_rates: bool + """ + Indicates whether this tariff contains one or more Rate that can be contracted + (sometimes called by-passable or associated with a price to compare). + """ + has_rate_applicability: bool + """ + Indicates that one or more rates on this tariff are only applicable to customers with a particular circumstance. + When true, this will be specified in the TariffProperty collection, and also on the TariffRate or rates in question. + """ + + +class TariffExtendedFields(TypedDict): + tariff_book_name: str + "Name of the tariff as it appears in the tariff document" + lse_code: str + "Abbreviated name of the load-serving entity" + customer_count_source: NotRequired[str] + "Where we got the customer count numbers from. Typically FERC (form 1 filings) or Arcadia (our own estimates)." + closed_date: datetime | None + """ + Date on which a tariff became closed to new customers, + but still available for customers who were on it at the time. + Can be null which means that the tariff is not closed. + All versions of a particular tariff + (i.e. those that share a particular masterTariffId) + will have the same closedDate value. + """ + min_monthly_consumption: float | None + "When applicable, the minimum monthly consumption allowed to be eligible for this tariff." + "When applicable, the maximum monthly consumption allowed to be eligible for this tariff." + max_monthly_consumption: float | None + "When applicable, the minimum monthly demand allowed to be eligible for this tariff." + min_monthly_demand: float | None + "When applicable, the maximum monthly demand allowed to be eligible for this tariff." + max_monthly_demand: float | None + "Indicates that this tariff has additional eligibility criteria, as specified in the TariffProperty collection" + has_tariff_applicability: bool + has_net_metering: bool + "Indicates whether this tariff contains one or more net metered rates." + privacy: TariffPrivacy + "Privacy status of the tariff." + + +class TariffMinimalFields(TypedDict): + is_active: bool + + tariff_id: int + "Unique Arcadia ID (primary key) for this tariff" + master_tariff_id: int + "Unique Arcadia ID that persists across all revisions of this tariff" + tariff_code: str + "Shortcode that the LSE uses as an alternate name for the tariff" + tariff_name: str + "Name of the tariff as used by the LSE" + lse_id: int + "ID of load serving entity this tariff belongs to" + lse_name: str + "Name of the load-serving entity" + service_type: NotRequired[TariffServiceType] + "Type of service for the tariff" + + +class TariffMinimal(TariffMinimalFields): + __pydantic_config__ = {"alias_generator": to_camel, "extra": "forbid"} # pyright: ignore[reportGeneralTypeIssues, reportUnannotatedClassAttribute] # noqa + + +class TariffStandard(TariffMinimalFields, TariffStandardFields): + __pydantic_config__ = {"alias_generator": to_camel, "extra": "forbid"} # pyright: ignore[reportGeneralTypeIssues, reportUnannotatedClassAttribute] # noqa + properties: NotRequired[list[TariffPropertyStandard]] + rates: NotRequired[list[TariffRateStandard]] + + +class TariffExtended(TariffMinimalFields, TariffStandardFields, TariffExtendedFields): + __pydantic_config__ = {"alias_generator": to_camel, "extra": "forbid"} # pyright: ignore[reportGeneralTypeIssues, reportUnannotatedClassAttribute] # noqa + properties: NotRequired[list[TariffPropertyStandard]] + rates: NotRequired[Annotated[list[TariffRateExtended], FailFast()]] + + +Tariff = TariffMinimal | TariffStandard | TariffExtended diff --git a/tariff_fetch/arcadia/schema/tariffproperty.py b/tariff_fetch/arcadia/schema/tariffproperty.py new file mode 100644 index 0000000..22f0760 --- /dev/null +++ b/tariff_fetch/arcadia/schema/tariffproperty.py @@ -0,0 +1,78 @@ +from typing import Literal, NotRequired, TypedDict + +from pydantic.alias_generators import to_camel + +TariffPropertyFormulaType = Literal["FORMULA"] + + +TariffPropertyPrunedDataType = Literal[ + "STRING", + "CHOICE", + "BOOLEAN", + "DATE", + "DECIMAL", + "INTEGER", + "DEMAND", +] + +TariffPropertyDataType = Literal[ + TariffPropertyPrunedDataType, + TariffPropertyFormulaType, +] + +TariffPropertyCategory = Literal[ + "APPLICABILITY", + "RATE_CRITERIA", + "BENEFIT", + "DATA_REPUTATION", + "SERVICE_TERMS", +] + + +TariffPropertyPeriod = Literal[ + "ON_PEAK", + "PARTIAL_PEAK", + "OFF_PEAK", + "CRITICAL_PEAK", + "SUPER_ON_PEAK", +] + + +class TariffPropertyChoice(TypedDict): + value: str + "Machine readable option value" + displayValue: str + "Human readable value shown to end users" + dataValue: str + likelihood: NotRequired[float | None] + + +class TariffPropertyMinimalFields(TypedDict): + quantity_key: NotRequired[str | None] + quantity_unit: NotRequired[str | None] + key_name: str + display_name: str + keyspace: str + family: str + description: str + data_type: TariffPropertyDataType + property_types: TariffPropertyCategory + + +class TariffPropertyStandardFields(TypedDict): + period: NotRequired[TariffPropertyPeriod] + operator: str | None + propertyValue: NotRequired[str] + minValue: NotRequired[str | float | int] + maxValue: NotRequired[str | float | int] + choices: NotRequired[list[TariffPropertyChoice]] + formula_detail: NotRequired[str] + is_default: bool + + +class TariffPropertyMinimal(TariffPropertyMinimalFields): + __pydantic_config__ = {"alias_generator": to_camel, "extra": "allow"} # pyright: ignore[reportGeneralTypeIssues, reportUnannotatedClassAttribute] # noqa + + +class TariffPropertyStandard(TariffPropertyMinimalFields, TariffPropertyStandardFields): + __pydantic_config__ = {"alias_generator": to_camel, "extra": "allow"} # pyright: ignore[reportGeneralTypeIssues, reportUnannotatedClassAttribute] # noqa diff --git a/tariff_fetch/arcadia/schema/tariffrate.py b/tariff_fetch/arcadia/schema/tariffrate.py new file mode 100644 index 0000000..0febe1a --- /dev/null +++ b/tariff_fetch/arcadia/schema/tariffrate.py @@ -0,0 +1,174 @@ +from datetime import datetime +from typing import Annotated, NotRequired, TypedDict + +from pydantic import BeforeValidator +from pydantic.alias_generators import to_camel + +from .common import RateChargeClass, RateTransactionType, RateUnit, TariffChargePeriod, TariffChargeType +from .season import SeasonExtended, SeasonStandard +from .territory import TerritoryStandard +from .timeofuse import TimeOfUseExtended, TimeOfUseStandard +from .validators import comma_separated_str + + +class TariffRateBand(TypedDict): + tariff_rate_band_id: int + """Unique Arcadia ID (primary key) for each Band""" + tariff_rate_id: int + """ID of the rate this band belongs to (foreign key)""" + rate_sequence_number: int + """This bands position in the bands for its rate""" + has_consumption_limit: bool + """True indicates that this has banded consumption""" + consumption_upper_limit: NotRequired[float] + """indicates the upper consumption limit of this band""" + has_demand_limit: bool + """True indicates that this has banded demand""" + demand_upper_limit: NotRequired[float] + """indicates the upper demand limit of this band""" + has_property_limit: bool + """true indicates that this has a limit based on a property""" + property_upper_limit: NotRequired[float] + """indicates the upper limit of this band""" + prev_upper_limit: float | None + """The upper limit of the previous rate band for this rate""" + applicability_value: NotRequired[str] + """indicates the value of applicability property that qualifies for this rate""" + calculation_factor: NotRequired[float] + """A factor to be applied to the cost of the rate.""" + rate_amount: float + """Charge amount for this band""" + rate_unit: RateUnit + """ + Possible values are: + + COST_PER_UNIT - rate amount multiplied by the number of units + PERCENTAGE - percentage of a value (e.g. percentage of overall bill) + """ + is_credit: bool + """When true this band is a credit amount (reduces the bill)""" + applicability_formula: NotRequired[str] + + +class TariffRateMinimalFields(TypedDict): + master_tariff_rate_id: int | None + has_applicability_formula: bool + tariff_rate_id: int + """Unique Arcadia ID (primary key) for each tariff rate""" + tariff_id: int + """Associates the rate with a tariff (foreign key)""" + rider_id: NotRequired[int] + """Tariff ID of the rider attached to this tariff version.""" + tariff_sequence_number: int + """Sequence of this rate in the tariff, for display purposes only (e.g. this is the order on the bill)""" + rate_group_name: str + """Name of the group this rate belongs to""" + rate_name: str | None + """Name of this rate. If None, `rate_group_name` is used.""" + + +class TariffRateStandardFields(TypedDict): + from_date_time: datetime | None + """Indicates the rate's effective date is not the same as that of its tariff""" + to_date_time: datetime | None + """Indicates the rates end date is not the same as that of its tariff""" + charge_type: TariffChargeType | None + """ + Possible values are: + FIXED_PRICE - a fixed charge for the period + CONSUMPTION_BASED - based on quantity used (e.g. kW/h) + DEMAND_BASED - based on the peak demand (e.g. kW) + QUANTITY - a rate per number of items (e.g. $5 per street light) + FORMULA - a rate that has a specific or custom formula + MINIMUM - a minimum amount that the LSE will charge you, overriding lower pre-tax charges + MAXIMUM - a maximum amount that the LSE will charge you, overriding higher pre-tax charges + TAX - a percentage tax rate which is applied to the sum of all of the other charges on a bill + """ + charge_class: NotRequired[Annotated[list[RateChargeClass], BeforeValidator(comma_separated_str)]] + """indicates what class(es) of charges this rate is for. + + Values include: + SUPPLY - Energy-related charges. + TRANSMISSION - Transmission level delivery charges. + DISTRIBUTION - Distribution level (last mile) delivery charges of moving electricity into your home or business. + TAX - Tax surcharges that appear in the utility tariff document. + CONTRACTED - Charges that get replaced or overridden when you pass the retail (contracted) energy supply rate in the calculation. + USER_ADJUSTED - Additional or custom rates you can add to a public or private tariff. + An example would be a local tax rate which Arcadia does not model but you would want included. + AFTER_TAX - Charges which apply post utility and other taxes. + A good example would be the California Climate credit which is applied to the bill after the taxes are applied to the bill subtotal. + OTHER - Charges which cannot be classified in any of the above buckets. This is very rare. + NON_BYPASSABLE - Charges which cannot be offset by credits (usually Net Metering) + """ + charge_period: TariffChargePeriod + """ + Indicates what period this charge is calculated for. + This is usually the same as the billing period (and is usually monthly) but can be other intervals. + """ + quantity_key: NotRequired[str | None] + """ When not null, the property that defines the type of quantity this rate applies to. + (e.g. billingMeter : property which defines the number of billing meters the rate will apply to) + """ + applicability_key: NotRequired[str] + """ defines the eligibility criteria for this rate. (e.g. connectionType : property which defines how the service is connected to the grid) """ + variable_limit_key: NotRequired[str] + """defines the variable which determines the upper limit(s) of this rate + + e.g. demandMultiplierTierswithkWhTiers2416: property which uses the demand value to drive the consumption limits + """ + variable_rate_key: NotRequired[str] + variable_rate_sub_key: NotRequired[str] + """this is the name of the property that defines the variable rate + + e.g massachusettsResidentialRetailPrevailingRates : property which provides the regional prevailing residential supply rate for Massachusetts + """ + variable_factor_key: NotRequired[str] + """The name of the property that defines the variable factor to apply to this rate. + + e.g billingPeriodProrationFactor: property which defines a prorated number of billing days + """ + rate_bands: list[TariffRateBand] + + +class TariffRateExtendedFields(TypedDict): + rider_tariff_id: NotRequired[int] + """Tariff ID of the rider attached to this tariff version. """ + tariff_book_sequence_number: int | None + """Sequence of this rate in the tariff source document, if it differs from tariff_sequence_number""" + tariff_book_rate_group_name: str | None + """Name of the group this rate belongs to in the tariff source document, if it differs from rate_group_name""" + tariff_book_rate_name: str | None + """Name of this rate in the tariff source document, if it differs from rate_name""" + transaction_type: RateTransactionType + """ + Indicates whether this rate is BUY (charge when importing from the grid, no credit when exporting), + SELL (credit when exporting to the grid, no charge when importing), + or NET (charge when importing, credit when exporting) with imports and exports resolved according the the chargePeriod. + BUY_IMPORT (charge only when importing) and + SELL_EXPORT (credit only when exporting) indicate that imports and exports are resolved in real-time + (instantaneous netting). + """ + + +class TariffRateMinimal(TariffRateMinimalFields): + __pydantic_config__ = {"alias_generator": to_camel, "extra": "forbid"} # pyright: ignore[reportGeneralTypeIssues, reportUnannotatedClassAttribute] # noqa + + +class TariffRateStandard(TariffRateMinimalFields, TariffRateStandardFields): + __pydantic_config__ = {"alias_generator": to_camel, "extra": "forbid"} # pyright: ignore[reportGeneralTypeIssues, reportUnannotatedClassAttribute] # noqa + time_of_use: NotRequired[TimeOfUseStandard] + """The time period this rate applies to. Only used for TOU rates""" + territory: NotRequired[TerritoryStandard] + """Only populated when this rate applies to a different region than the whole tariff (e.g. California Baseline Regions).""" + season: NotRequired[SeasonStandard] + """The season this rate applies to. Only used for seasonal rates""" + + +class TariffRateExtended(TariffRateMinimalFields, TariffRateStandardFields, TariffRateExtendedFields): + __pydantic_config__ = {"alias_generator": to_camel, "extra": "forbid"} # pyright: ignore[reportGeneralTypeIssues, reportUnannotatedClassAttribute] # noqa + time_of_use: NotRequired[TimeOfUseExtended] + """The time period this rate applies to. Only used for TOU rates""" + territory: NotRequired[TerritoryStandard] + """Only populated when this rate applies to a different region than the whole tariff (e.g. California Baseline Regions).""" + season: NotRequired[SeasonExtended] + """The season this rate applies to. Only used for seasonal rates""" diff --git a/tariff_fetch/arcadia/schema/territory.py b/tariff_fetch/arcadia/schema/territory.py new file mode 100644 index 0000000..7300b95 --- /dev/null +++ b/tariff_fetch/arcadia/schema/territory.py @@ -0,0 +1,82 @@ +from typing import NotRequired, TypedDict + +from pydantic.alias_generators import to_camel + +from .common import TerritoryItemType, TerritoryUsageType + + +class Coordinate(TypedDict): + latitude: float + longitude: float + + +class TerritoryItem(TypedDict): + territory_item_id: int + """ Unique Arcadia ID (primary key) for each territory Item""" + territory_type: TerritoryItemType + value: str + """The name of the territory item (i.e. 'Kirkwood' or '94115').""" + exclude: bool + """If True, this territory item is not included as part of the coverage area of the parent territory.""" + partial: bool + """If True, then only a partial area of this territory item is covered by this LSE.""" + + +class TerritoryLSE(TypedDict): + territory_id: int + """ The territoryId this Territory LSE belongs to""" + lse_id: int + """The lseId of the LSE offering service in this Territory""" + lse_name: str + """The name of the LSE""" + distribution: bool + """indicates whether this LSE is a distribution LSE""" + supplier_residential: bool + """indicates whether this LSE supplies service to residential customers""" + supplier_general: bool + """indicates whether this LSE supplies service to general (commercial and industrial) customers""" + residential_coverage: float + """Percentage of residential customers this LSE supplies service to""" + general_coverage: float + """Percentage of general (commercial and industrial) customers this LSE supplies service to""" + + +class TerritoryMinimalFields(TypedDict): + territory_id: int + """Unique Arcadia ID (primary key) for each territory""" + territory_name: str + """The name of the territory (i.e. 'Service Area for CA' or 'Baseline Region H').""" + lse_id: int + """The ID of the LSE this Territory belongs to""" + lse_name: str + """The name of the LSE (utility) that this territory belongs to.""" + country_code: str + + +class TerritoryStandardFields(TypedDict): + parent_territory_id: int | None + """The ID of the parent territory + + Typically this will be on a tariff territory that ties it back to the service territory. + """ + usage_type: TerritoryUsageType + """The type of the items that define the physical area of coverage of this territory""" + item_types: TerritoryItemType + items: NotRequired[list[TerritoryItem]] + """The list of Territory Items that define the area covered by this territory.""" + territory_lses: NotRequired[list[TerritoryLSE]] + """ The list of LSEs that offer service (retail) to customers in this territory. Applies to deregulated markets.""" + dereg_res: bool + """Whether the residential electricity market in this territory is deregulated.""" + dereg_candi: bool + """ Whether the commercial and industrial electricity market in this territory is deregulated.""" + center_point: Coordinate + """ The latitude and longitude of the centerPoint of this territory.""" + + +class TerritoryMinimal(TerritoryMinimalFields): + __pydantic_config__ = {"alias_generator": to_camel, "extra": "forbid"} # pyright: ignore[reportGeneralTypeIssues, reportUnannotatedClassAttribute] # noqa + + +class TerritoryStandard(TerritoryMinimalFields, TerritoryStandardFields): + __pydantic_config__ = {"alias_generator": to_camel, "extra": "forbid"} # pyright: ignore[reportGeneralTypeIssues, reportUnannotatedClassAttribute] # noqa diff --git a/tariff_fetch/arcadia/schema/timeofuse.py b/tariff_fetch/arcadia/schema/timeofuse.py new file mode 100644 index 0000000..0742bd3 --- /dev/null +++ b/tariff_fetch/arcadia/schema/timeofuse.py @@ -0,0 +1,67 @@ +from typing import Annotated, TypedDict + +from pydantic import Field +from pydantic.alias_generators import to_camel + +from .common import TimeOfUsePrivacy, TimeOfUseType +from .season import SeasonExtended, SeasonStandard + + +class Period(TypedDict): + tou_period_id: int + """The unique Arcadia ID of this period. This is unique across all LSE's.""" + tou_id: int + """The ID of the parent time of use this period belongs to (foreign key).""" + from_day_of_week: Annotated[int, Field(ge=0, le=6)] + """The day of the week this period starts. 0-6 Monday-Sunday.""" + from_hour: Annotated[int, Field(ge=0, le=23)] + """The hour this period starts. 0-23.""" + from_minute: Annotated[int, Field(ge=0, le=59)] + """The minute this period starts. 0-59.""" + to_day_of_week: Annotated[int, Field(ge=0, le=6)] + """The day of the week this period ends. 0-6 Monday-Sunday.""" + to_hour: Annotated[int, Field(ge=0, le=23)] + """The hour this period ends. 0-23.""" + to_minute: Annotated[int, Field(ge=0, le=59)] + """The minute this period ends. 0-59.""" + calendar_id: int | None + + +class TimeOfUseStandardFields(TypedDict): + tou_id: int + """Unique Arcadia ID (primary key) for each time of use. This is unique across all LSEs.""" + tou_group_id: int + """Associates the rate with a tariff (foreign key)""" + lse_id: int + """ID of load serving entity this time of use group belongs to""" + tou_name: str + """Display name of this TOU. Example: "On Peak" """ + calendar_id: int | None + """The ID of the calendar of events and holidays that this TOU should apply to, regardless of the TOU period definitions. + + For example, a calendar could be used to specify that the entirety of Labor Day should be treated as OFF_PEAK, + even though it falls on a Summer weekday. + """ + tou_type: TimeOfUseType + is_dynamic: bool + """Indicates if the timeOfUse includes a calendar whose dates change from year to year. + + For example Critical Peak timeOfUse objects are dynamic, since the dates/hours change from year to year. + """ + tou_periods: list[Period] + """ The periods that comprise this time of use.""" + + +class TimeOfUseExtendedFields(TypedDict): + privacy: TimeOfUsePrivacy + """Indicates whether this TimeOfUse is PUBLIC or PRIVATE. Only TOU groups created by your organization will be returned as PRIVATE.""" + + +class TimeOfUseStandard(TimeOfUseStandardFields): + __pydantic_config__ = {"alias_generator": to_camel, "extra": "forbid"} # pyright: ignore[reportGeneralTypeIssues, reportUnannotatedClassAttribute] # noqa + season: SeasonStandard | None + + +class TimeOfUseExtended(TimeOfUseStandardFields, TimeOfUseExtendedFields): + __pydantic_config__ = {"alias_generator": to_camel, "extra": "forbid"} # pyright: ignore[reportGeneralTypeIssues, reportUnannotatedClassAttribute] # noqa + season: SeasonExtended | None diff --git a/tariff_fetch/arcadia/schema/validators.py b/tariff_fetch/arcadia/schema/validators.py new file mode 100644 index 0000000..d47506a --- /dev/null +++ b/tariff_fetch/arcadia/schema/validators.py @@ -0,0 +1,4 @@ +def comma_separated_str(value: str | list[str]) -> list[str]: + if isinstance(value, str): + return [_.strip() for _ in value.split(",")] + return value diff --git a/tariff_fetch/cli.py b/tariff_fetch/cli.py index 3b319a1..159785c 100644 --- a/tariff_fetch/cli.py +++ b/tariff_fetch/cli.py @@ -1,5 +1,5 @@ from pathlib import Path -from typing import Annotated +from typing import Annotated, cast import polars as pl import questionary @@ -10,13 +10,15 @@ from tariff_fetch._cli.genability import process_genability from tariff_fetch._cli.openei import process_openei from tariff_fetch._cli.rateacuity import process_rateacuity -from tariff_fetch.openeia import CORE_EIA861_Yearly_Sales from tariff_fetch.rateacuity.base import AuthorizationError from ._cli import console from ._cli.types import Provider, StateCode, Utility ENTITY_TYPES_SORTORDER = ["Investor Owned", "Cooperative", "Municipal"] +CORE_EIA861_YEARLY_SALES_HTTPS = ( + "https://s3.us-west-2.amazonaws.com/pudl.catalyst.coop/nightly/core_eia861__yearly_sales.parquet" +) def prompt_state() -> StateCode: @@ -30,19 +32,22 @@ def prompt_state() -> StateCode: def prompt_providers() -> list[Provider]: - return questionary.checkbox( - message="Select providers", - choices=[questionary.Choice(title=_.value, value=_) for _ in Provider], - validate=lambda x: True if x else "Select at least one provider", - ).ask() + return cast( + list[Provider], + questionary.checkbox( + message="Select providers", + choices=[questionary.Choice(title=_.value, value=_) for _ in Provider], + validate=lambda x: True if x else "Select at least one provider", + ).ask(), + ) def prompt_utility(state: str) -> Utility: with console.status("Fetching utilities..."): yearly_sales_df = ( - pl.read_parquet(CORE_EIA861_Yearly_Sales.https) + pl.read_parquet(CORE_EIA861_YEARLY_SALES_HTTPS) # pyright: ignore[reportUnknownMemberType] .filter(pl.col("state") == state.upper()) - .filter(pl.col("report_date") == pl.col("report_date").max().over("utility_id_eia")) + .filter(pl.col("report_date") == pl.col("report_date").max().over("utility_id_eia")) # pyright: ignore[reportUnknownMemberType] .filter(pl.col("entity_type").is_in(ENTITY_TYPES_SORTORDER)) .group_by("utility_id_eia") .agg( @@ -59,9 +64,9 @@ def prompt_utility(state: str) -> Utility: rows = list(yearly_sales_df.iter_rows(named=True)) rows.sort( key=lambda _: ( - ENTITY_TYPES_SORTORDER.index(_["entity_type"]) + ENTITY_TYPES_SORTORDER.index(_["entity_type"]) # pyright: ignore[reportAny] if _["entity_type"] in ENTITY_TYPES_SORTORDER - else abs(hash(_["entity_type"])) + 4, + else abs(hash(_["entity_type"])) + 4, # pyright: ignore[reportAny] -_["customers"], _["utility_name"], ) @@ -78,11 +83,11 @@ def fmt_number(value: float | int | None) -> str: revenue_header = "Revenue ($)" customers_header = "Customers" - largest_utility_name = max(len(utility_name_header), *(len(row["utility_name"]) for row in rows)) - largest_entity_type = max(len(entity_type_header), *(len(row["entity_type"][:18]) for row in rows)) - largest_sales_col = max(len(sales_header), *(len(fmt_number(row["sales_mwh"])) for row in rows)) - largest_revenue_col = max(len(revenue_header), *(len(fmt_number(row["sales_revenue"])) for row in rows)) - largest_customers_col = max(len(customers_header), *(len(fmt_number(row["customers"])) for row in rows)) + largest_utility_name = max(len(utility_name_header), *(len(row["utility_name"]) for row in rows)) # pyright: ignore[reportAny] + largest_entity_type = max(len(entity_type_header), *(len(row["entity_type"][:18]) for row in rows)) # pyright: ignore[reportAny] + largest_sales_col = max(len(sales_header), *(len(fmt_number(row["sales_mwh"])) for row in rows)) # pyright: ignore[reportAny] + largest_revenue_col = max(len(revenue_header), *(len(fmt_number(row["sales_revenue"])) for row in rows)) # pyright: ignore[reportAny] + largest_customers_col = max(len(customers_header), *(len(fmt_number(row["customers"])) for row in rows)) # pyright: ignore[reportAny] header_str_utility_name = utility_name_header.ljust(largest_utility_name) header_str_entity_type = entity_type_header.ljust(largest_entity_type) @@ -97,27 +102,30 @@ def fmt_number(value: float | int | None) -> str: value=0, ) - def build_choice(row: dict) -> questionary.Choice: - name_col = row["utility_name"].ljust(largest_utility_name) - entity_type = (row["entity_type"] or "-")[:18].ljust(largest_entity_type) - sales_col = fmt_number(row["sales_mwh"]).ljust(largest_sales_col) - revenue_col = fmt_number(row["sales_revenue"]).ljust(largest_revenue_col) - customers_col = fmt_number(row["customers"]).ljust(largest_customers_col) + def build_choice(row: dict[str, str | int | float | None]) -> questionary.Choice: + name_col = cast(str, row["utility_name"]).ljust(largest_utility_name) + entity_type = (cast(str, row["entity_type"]) or "-")[:18].ljust(largest_entity_type) + sales_col = fmt_number(cast(float, row["sales_mwh"])).ljust(largest_sales_col) + revenue_col = fmt_number(cast(float, row["sales_revenue"])).ljust(largest_revenue_col) + customers_col = fmt_number(cast(float, row["customers"])).ljust(largest_customers_col) title = f"{name_col} | {entity_type} | {sales_col} | {revenue_col} | {customers_col}" return questionary.Choice( title=title, - value=Utility(eia_id=row["utility_id_eia"], name=row["utility_name"]), + value=Utility(eia_id=cast(int, row["utility_id_eia"]), name=cast(str, row["utility_name"])), ) result = 0 while result == 0: - result = questionary.select( - message="Select a utility", - choices=[header, separator, *[build_choice(row) for row in rows]], - use_search_filter=True, - use_jk_keys=False, - use_shortcuts=False, - ).ask() + result = cast( + Utility, + questionary.select( + message="Select a utility", + choices=[header, separator, *[build_choice(row) for row in rows]], + use_search_filter=True, + use_jk_keys=False, + use_shortcuts=False, + ).ask(), + ) return result @@ -131,13 +139,10 @@ def main( ] = "./outputs", ): # print(pl.read_parquet(CoreEIA861_ASSN_UTILITY.https)) - if (state_ := (state or prompt_state()).value) is None: - return - if (providers_ := providers or prompt_providers()) is None: - return + state_ = state or prompt_state().value + providers_ = providers or prompt_providers() output_folder_ = Path(output_folder) - if (utility := prompt_utility(state_)) is None: - return + utility = prompt_utility(state_) if Provider.GENABILITY in providers_: console.print("Processing [blue]Genability[/]") try: diff --git a/tariff_fetch/genability/__init__.py b/tariff_fetch/genability/__init__.py deleted file mode 100644 index 6de1313..0000000 --- a/tariff_fetch/genability/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Genability API""" diff --git a/tariff_fetch/genability/base.py b/tariff_fetch/genability/base.py deleted file mode 100644 index 7ae67cc..0000000 --- a/tariff_fetch/genability/base.py +++ /dev/null @@ -1,24 +0,0 @@ -import os -from typing import Any - -import requests -from dotenv import load_dotenv -from requests.models import HTTPBasicAuth - -BASE_URL = "https://api.genability.com/rest/public" - - -def api_request_json(url: str, auth: tuple[str, str] | None = None, **params) -> dict[str, Any]: - if auth is None: - load_dotenv() - app_id = os.getenv("ARCADIA_APP_ID") - if app_id is None: - raise ValueError("ARCADIA_APP_ID variable is not set. Either set it or use auth as a parameter.") - app_key = os.getenv("ARCADIA_APP_KEY") - if app_key is None: - raise ValueError("ARCADIA_APP_KEY variable is not set. Either set it or use auth as a parameter.") - else: - app_id, app_key = auth - response = requests.get(url, params=params, auth=HTTPBasicAuth(app_id, app_key)) - response.raise_for_status() - return response.json() diff --git a/tariff_fetch/genability/converters.py b/tariff_fetch/genability/converters.py deleted file mode 100644 index fdc16e8..0000000 --- a/tariff_fetch/genability/converters.py +++ /dev/null @@ -1,9 +0,0 @@ -from collections.abc import Iterable - - -def true_or_false(value: bool) -> str: - return str(value).lower() - - -def comma_separated(value: Iterable) -> str: - return ",".join(value) diff --git a/tariff_fetch/genability/lse.py b/tariff_fetch/genability/lse.py deleted file mode 100644 index 591dcbf..0000000 --- a/tariff_fetch/genability/lse.py +++ /dev/null @@ -1,70 +0,0 @@ -import os -from typing import Literal, TypeAlias, TypedDict, Unpack - -from tariff_fetch.genability.converters import comma_separated -from tariff_fetch.genability.pagination import PagingParams, paging_params_converters -from tariff_fetch.genability.search import SearchParams, search_params_converters - -from .base import BASE_URL, api_request_json - -LSE_URL = f"{BASE_URL}/lses" - -LSEServiceType: TypeAlias = Literal["ELECTRICITY", "SOLAR_PV"] - - -class GetLSEsParams(TypedDict, total=False): - fields: str - zipCode: str - "Zip or post code where you would like to see a list of LSEs for (e.g. 5 digit ZIP code for USA)." - country: str - "ISO country code" - ownerships: list[Literal["INVESTOR", "COOP", "MUNI"]] - "Filter results by the type of ownership structure for the LSE." - serviceTypes: list[LSEServiceType] - "Filter results to LSEs that just offer this service type to a customer class" - residentialServiceTypes: list[LSEServiceType] - commercialServiceTypes: list[LSEServiceType] - industrialServiceTypes: list[LSEServiceType] - transportationServiceTypes: list[LSEServiceType] - - -_get_lses_params_converters = { - "ownerships": comma_separated, - "serviceTypes": comma_separated, - "residentialServiceTypes": comma_separated, - "commercialServiceTypes": comma_separated, - "industrialServiceTypes": comma_separated, - "transportationServiceTypes": comma_separated, -} - - -class GetLSEsPageParams(PagingParams, SearchParams, GetLSEsParams): ... - - -def ident(x): - return x - - -def get_lses_page( - auth: tuple[str, str] | None = None, - **params: Unpack[GetLSEsPageParams], -): - converters = {**_get_lses_params_converters, **paging_params_converters, **search_params_converters} - request_params = {key: converters.get(key, ident)(value) for key, value in params.items()} - return api_request_json(LSE_URL, auth=auth, **request_params) - - -def main(): - from dotenv import load_dotenv - - load_dotenv() - app_id = os.getenv("ARCADIA_APP_ID") - app_key = os.getenv("ARCADIA_APP_KEY") - if not (app_id and app_key): - raise ValueError("Provide app id and key") - result = get_lses_page((app_id, app_key), fields="min", search="4226", searchOn=["code"]) - print(result) - - -if __name__ == "__main__": - main() diff --git a/tariff_fetch/genability/pagination.py b/tariff_fetch/genability/pagination.py deleted file mode 100644 index deabf0f..0000000 --- a/tariff_fetch/genability/pagination.py +++ /dev/null @@ -1,12 +0,0 @@ -from typing import TypedDict - - -class PagingParams(TypedDict, total=False): - pageStart: int - pageCount: int - - -paging_params_converters = { - "pageStart": str, - "pageCount": str, -} diff --git a/tariff_fetch/genability/response.py b/tariff_fetch/genability/response.py deleted file mode 100644 index 8d84b86..0000000 --- a/tariff_fetch/genability/response.py +++ /dev/null @@ -1,437 +0,0 @@ -from __future__ import annotations - -from datetime import datetime -from typing import Any, Literal, NewType, TypeAlias, TypedDict - -from typing_extensions import NotRequired, Required - - -def api_datetime(value: str) -> datetime: - try: - return datetime.strptime(value, "%Y-%m-%dT%H:%M:%S.%f%z") - except ValueError: - return datetime.strptime(value, "%Y-%m-%d") - - -TariffRateExtended = NewType("TariffRateExtended", dict) -TariffRateStandard = NewType("TariffRateStandard", dict) -TariffDocumentExtended = NewType("TariffDocumentExtended", dict) -TariffDocumentStandard = NewType("TariffDocumentStandard", dict) -TariffDocumentMinimal = NewType("TariffDocumentMinimal", dict) -Territory = NewType("Territory", dict) -Season = NewType("Season", dict) -TimeOfUse = NewType("TimeOfUse", dict) - - -LoadServingOfferingType: TypeAlias = Literal["Bundle"] | Literal["Bundled"] | Literal["Delivery"] | Literal["Energy"] -LoadServingOwnership: TypeAlias = ( - Literal["INVESTOR"] - | Literal["COOP"] - | Literal["MUNI"] - | Literal["FEDERAL"] - | Literal["POLITICAL_SUBDIVISION"] - | Literal["RETAIL_ENERGY_MARKETER"] - | Literal["WHOLESALE_ENERGY_MARKETER"] - | Literal["TRANSMISSION"] - | Literal["STATE"] - | Literal["UNREGULATED"] -) -LoadServingServiceType: TypeAlias = Literal["ELECTRICITY"] | Literal["SOLAR_PV"] -BillingPeriodStyle: TypeAlias = Literal[ - "ArcadiaStyle", "InclusiveToDate", "ExclusiveFromDateAndInclusiveToDate", "Unknown" -] - -TariffType: TypeAlias = Literal["DEFAULT"] | Literal["ALTERNATIVE"] | Literal["OPTIONAL_EXTRA"] | Literal["RIDER"] -TariffServiceType: TypeAlias = LoadServingServiceType | Literal["GAS"] -TariffPrivacy: TypeAlias = Literal["PUBLIC", "UNLISTED", "PRIVATE"] -TariffEffectiveOnRule: TypeAlias = Literal["TARIFF_EFFECTIVE_DATE", "BILLING_PERIOD_START"] -TariffChargeType: TypeAlias = Literal[ - "FIXED_PRICE", - "CONSUMPTION_BASED", - "DEMAND_BASED", - "QUANTITY", - "FORMULA", - "MINIMUM", - "MAXIMUM", - "TAX", -] -TariffChargeClass: TypeAlias = Literal[ - "SUPPLY", - "TRANSMISSION", - "DISTRIBUTION", - "TAX", - "CONTRACTED", - "USER_ADJUSTED", - "AFTER_TAX", - "OTHER", - "NON_BYPASSABLE", -] -TariffChargePeriod: TypeAlias = Literal["MONTHLY", "DAILY", "QUARTERLY", "ANNUALLY"] -TariffTransactionType: TypeAlias = Literal["BUY", "SELL", "NET", "BUY_IMPORT", "SELL_EXPORT"] -TariffPropertyPeriod: TypeAlias = Literal["ON_PEAK", "PARTIAL_PEAK", "OFF_PEAK", "CRITICAL_PEAK"] -TariffPropertyDataType: TypeAlias = Literal[ - "string", - "choice", - "boolean", - "date", - "decimal", - "integer", - "formula", - "demand", - "STRING", - "CHOICE", - "BOOLEAN", - "DATE", - "DECIMAL", - "INTEGER", - "FORMULA", - "DEMAND", -] -TariffPropertyCategory: TypeAlias = Literal[ - "APPLICABILITY", "RATE_CRITERIA", "BENEFIT", "DATA_REPUTATION", "SERVICE_TERMS" -] -TariffRateBandUnit: TypeAlias = Literal["COST_PER_UNIT", "PERCENTAGE"] -CustomerClass = Literal["RESIDENTIAL"] | Literal["GENERAL"] | Literal["SPECIAL_USE "] -TariffCustomerClass: TypeAlias = CustomerClass | Literal["PROPOSED"] -TariffDocumentSectionType: TypeAlias = Literal["SERVICE", "TARIFF", "CLIMATE_ZONE", "UTILITY_CLIMATE_ZONE"] -JsonObject: TypeAlias = dict[str, Any] - - -class BillingPeriodRepresentation(TypedDict): - fromDateOffset: int - "Days to add to Arcadia-style fromDate for the billing period start" - toDateOffset: int - "Days to add to Arcadia-style toDate for the billing period end" - style: BillingPeriodStyle - "Named billing period representation style" - - -class LoadServingEntityMinimal(TypedDict): - """Fields returned in the minimal Load Serving Entity view.""" - - lseId: int - "Unique Arcadia ID (primary key) for each LSE" - name: str - "Published name of the company" - code: str - "Shortcode (US values match the EIA ID)" - websiteHome: str - "URL to the Load Serving Entity home page" - - -class LoadServingEntityExtendedFields(TypedDict): - """Fields available when requesting the extended Load Serving Entity view.""" - - offeringType: LoadServingOfferingType - "Classification of services the utility provides" - ownership: LoadServingOwnership - "Ownership structure reported by the utility" - serviceTypes: LoadServingServiceType | None - "Services offered to all customers" - totalRevenues: int - "Annual total revenue in thousands of local currency" - totalSales: int - "Annual total sales in megawatt-hours" - totalCustomers: int - "Total customer count" - residentialServiceTypes: LoadServingServiceType | None - "Services offered to residential customers" - residentialRevenues: int - "Annual residential revenue in thousands of local currency" - residentialSales: int - "Annual residential sales in megawatt-hours" - residentialCustomers: int - "Residential customer count" - commercialServiceTypes: LoadServingServiceType | None - "Services offered to commercial customers" - commercialRevenues: int - "Annual commercial revenue in thousands of local currency" - commercialSales: int - "Annual commercial sales in megawatt-hours" - commercialCustomers: int - "Commercial customer count" - industrialServiceTypes: LoadServingServiceType | None - "Services offered to industrial customers" - industrialRevenues: int - "Annual industrial revenue in thousands of local currency" - industrialSales: int - "Annual industrial sales in megawatt-hours" - industrialCustomers: int - "Industrial customer count" - transportationServiceTypes: LoadServingServiceType | None - "Services offered to transportation customers" - transportationRevenues: int - "Annual transportation revenue in thousands of local currency" - transportationSales: int - "Annual transportation sales in megawatt-hours" - transportationCustomers: int - "Transportation customer count" - billingPeriodRepresentation: BillingPeriodRepresentation | None - "Details about how billing periods are represented for this LSE" - - -class LoadServingEntityExtended(LoadServingEntityMinimal, LoadServingEntityExtendedFields): - """Convenience type combining minimal and extended Load Serving Entity fields.""" - - -class TariffPropertyChoice(TypedDict): - value: Required[str] - "Machine readable option value" - displayValue: NotRequired[str] - "Human readable value shown to end users" - dataValue: NotRequired[str] - likelihood: NotRequired[float | None] - - -class TariffMinimalFields(TypedDict): - tariffId: int - "Unique Arcadia ID (primary key) for this tariff" - masterTariffId: int - "Unique Arcadia ID that persists across all revisions of this tariff" - tariffCode: str - "Shortcode that the LSE uses as an alternate name for the tariff" - tariffName: str - "Name of the tariff as used by the LSE" - lseId: int - "ID of load serving entity this tariff belongs to" - lseName: str - "Name of the load-serving entity" - serviceType: TariffServiceType - "Type of service for the tariff" - - -class TariffStandardFields(TypedDict): - priorTariffId: int - "Unique Arcadia ID that identifies the prior revision of the tariffId above" - distributionLseId: int | None - """ - In states like Texas where the load-serving entity that sells the power is - different than the load-serving entity that distributes the power, this will - contain the ID of the distribution LSE. Otherwise, it will be None. - """ - tariffType: TariffType - """ - Possible values are: - DEFAULT - tariff that is automatically given to this service class - ALTERNATIVE - opt-in alternate tariff for this service class - OPTIONAL_EXTRA - opt-in extra, such as green power or a smart thermostat program - RIDER - charge that can apply to multiple tariffs. Often a regulatory-mandated charge - """ - customerClass: CustomerClass - """ - Possible values are: - RESIDENTIAL - homes, apartments etc. - GENERAL - commercial, industrial, and other business and organization service types - (often have additional applicability criteria) - SPECIAL_USE - examples are government, agriculture, street lighting, transportation - PROPOSED - Utility rates that have been proposed by utilities and approved by utility commissions, - but are not yet effective (requires product subscription) - """ - customerCount: int - "Number of customers that are on this master tariff" - customerLikelihood: NotRequired[float] - """ - The likelihood that a customer is on this tariff of all the tariffs in the search results. - Only populated when getting more than one tariff. - """ - territoryId: str - """ - ID of the territory that this tariff applies to. - This is typically the service area for the LSE in this regulatory region (i.e. a state in the USA) - """ - effectiveDate: datetime - "Date on which the tariff was or will be effective" - endDate: datetime - "Date on which this tariff is no longer effective. Can be null which means end date is not known or tariff is open-ended" - effectiveOnRule: TariffEffectiveOnRule - timeZone: str - "If populated (usually is), it's the timezone that this tariffs dates and times refer to" - billingPeriod: str - "How frequently bills are generated." - currency: str - "ISO Currency code that the rates for this tariff refer to (e.g. USD for USA Dollar)" - chargeTypes: TariffChargeType - "List of all the different ChargeType rates on this tariff" - chargePeriod: TariffChargePeriod - "The most fine-grained period for which charges are calculated." - hasTimeOfUseRates: bool - "Indicates whether this tariff contains one or more Time of Use Rate." - hasTieredRates: bool - "Indicates whether this tariff contains one or more Tiered Rate." - hasContractedRates: bool - """ - Indicates whether this tariff contains one or more Rate that can be contracted - (sometimes called by-passable or associated with a price to compare). - """ - hasRateApplicability: bool - """ - Indicates that one or more rates on this tariff are only applicable to customers with a particular circumstance. - When true, this will be specified in the TariffProperty collection, and also on the TariffRate or rates in question. - """ - - -class TariffExtendedFields(TypedDict): - tariffBookName: str - "Name of the tariff as it appears in the tariff document" - lseCode: str - "Abbreviated name of the load-serving entity" - customerCountSource: str - "Where we got the customer count numbers from. Typically FERC (form 1 filings) or Arcadia (our own estimates)." - closedDate: datetime | None - """ - Date on which a tariff became closed to new customers, - but still available for customers who were on it at the time. - Can be null which means that the tariff is not closed. - All versions of a particular tariff - (i.e. those that share a particular masterTariffId) - will have the same closedDate value. - """ - minMonthlyConsumption: float | None - "When applicable, the minimum monthly consumption allowed to be eligible for this tariff." - maxMonthlyConsumption: float | None - "When applicable, the maximum monthly consumption allowed to be eligible for this tariff." - minMonthlyDemand: float | None - "When applicable, the minimum monthly demand allowed to be eligible for this tariff." - maxMonthlyDemand: float | None - "When applicable, the maximum monthly demand allowed to be eligible for this tariff." - hasTariffApplicability: bool - "Indicates that this tariff has additional eligibility criteria, as specified in the TariffProperty collection" - hasNetMetering: bool - "Indicates whether this tariff contains one or more net metered rates." - privacy: TariffPrivacy - "Privacy status of the tariff." - - -class TariffMinimal(TariffMinimalFields): - properties: NotRequired[list[TariffPropertyMinimal]] - "The properties on this tariff." - rates: NotRequired[list[TariffRateMinimal]] - "The rates for this tariff." - documents: NotRequired[list[TariffDocumentMinimal]] - "The documents for this tariff." - - -class TariffStandard(TariffStandardFields): - properties: NotRequired[list[TariffPropertyStandard]] - "The properties on this tariff." - rates: NotRequired[list[TariffRateStandard]] - "The rates for this tariff." - documents: NotRequired[list[TariffDocumentStandard]] - "The documents for this tariff." - - -class TariffExtended(TariffExtendedFields): - properties: NotRequired[list[TariffPropertyStandard]] - "The properties on this tariff." - rates: NotRequired[list[TariffRateExtended]] - "The rates for this tariff." - documents: NotRequired[list[TariffDocumentExtended]] - "The documents for this tariff." - - -class TariffPropertyMinimal: - keyName: str - "Unique name for this property" - displayName: str - "The display name of this property" - keyspace: str - "Top-level categorization of the property hierarchy" - family: str - "Second level categorization of the property hierarchy, below keyspace" - description: str - "A longer description of the tariff property" - dataType: TariffPropertyDataType - "The data type of this property" - propertyTypes: TariffPropertyCategory - - -class TariffPropertyStandardFields: - period: TariffPropertyPeriod | None - "If applicable the type of time of use." - operator: str | None - "The mathematical operator associated with this property's value, where applicable." - propertyValue: str | None - "If applicable the specific value of this property." - minValue: str | None - "If applicable the minimum value of this property." - maxValue: str - "If applicable the maximum value of this property." - formulaDetail: str | None - "If this property is a FORMULA type, the formula details will be in this field." - choices: list[TariffPropertyChoice] - "The possible choices for this array" - isDefault: bool - "Whether the value of this Property is the default value." - - -class TariffPropertyStandard(TariffPropertyMinimal, TariffPropertyStandardFields): ... - - -class TariffRateMinimal: - tariffRateId: int - "Unique Arcadia ID (primary key) for each tariff rate" - tariffId: int - "Associates the rate with a tariff (foreign key)" - riderId: int - "Master Tariff ID of the rider linked to this tariff rate." - tariffSequenceNumber: int - "Sequence of this rate in the tariff, for display purposes only (e.g. this is the order on the bill)" - rateGroupName: str - "Name of the group this rate belongs to" - rateName: str | None - "Name of this rate. Use group name if None" - - -class TariffRateStandardFields: - fromDateTime: datetime | None - "If populated, this indicates the rate's effective date is not the same as that of its tariff" - toDateTime: datetime | None - "If populated, this indicates the rates end date is not the same as that of its tariff" - territory: Territory | None - "Only populated when this rate applies to a different region than the whole tariff" - season: Season | None - "The season this rate applies to. Only used for seasonal rates" - timeOfUse: TimeOfUse | None - "The time period this rate applies to. Only used for TOU rates" - chargeType: TariffChargeType - """ - Possible values are: - FIXED_PRICE - a fixed charge for the period - CONSUMPTION_BASED - based on quantity used (e.g. kW/h) - DEMAND_BASED - based on the peak demand (e.g. kW) - QUANTITY - a rate per number of items (e.g. $5 per street light) - FORMULA - a rate that has a specific or custom formula - MINIMUM - a minimum amount that the LSE will charge you, overriding lower pre-tax charges - MAXIMUM - a maximum amount that the LSE will charge you, overriding higher pre-tax charges - TAX - a percentage tax rate which is applied to the sum of all of the other charges on a bill - """ - chargeClass: TariffChargeClass - chargePeriod: TariffChargePeriod - "Indicates what period this charge is calculated for. " - quantityKey: str | None - "Defines the type of quantity this rate applies to." - applicabilityKey: str | None - "Defines the eligibility criteria for this rate" - variableLimitKey: str | None - "When populated this defines the variable which determines the upper limit(s) of this rate." - variableRateKey: str | None - "The name of the property that defines the variable rate." - variableFactorKey: str | None - "The name of the property that defines the variable factor to apply to this rate." - - -class TariffRateExtendedFields: - riderTariffId: int | None - "Tariff ID of the rider attached to this tariff version." - tariffBookSequenceNumber: int - "Sequence of this rate in the tariff source document, if it differs from tariffSequenceNumber" - tariffBookRateGroupName: str - "Name of the group this rate belongs to in the tariff source document, if it differs from rateGroupName" - tariffBookRateName: str - "Name of this rate in the tariff source document, if it differs from rateName" - transactionType: TariffTransactionType - - -class Convert: - def __init__(self, converter) -> None: - self.converter = converter diff --git a/tariff_fetch/genability/search.py b/tariff_fetch/genability/search.py deleted file mode 100644 index cb98dcd..0000000 --- a/tariff_fetch/genability/search.py +++ /dev/null @@ -1,42 +0,0 @@ -from typing import Literal, TypedDict - -from tariff_fetch.genability.converters import comma_separated - - -class SearchParams(TypedDict, total=False): - search: str - """ - The string of text to search on. - This can also be a regular expression, in which case you should - set the isRegex flag to true (see below). - """ - searchOn: list[str] - """ - Comma-separated list of fields to query on. When searchOn is specified, the text provided - in the search string field will be searched within these fields. The list of fields to search on - depends on the entity being searched for. Read the documentation for the entity for more details - """ - startsWith: bool - "When true, the search will only return results that begin with the specified search string." - endsWith: bool - "When true, the search will only return results that end with the specified search string." - isRegex: bool - """ - When true, the provided search string will be regarded as a regular expression - and the search will return results matching the regular expression. - Default is False. - """ - sortOn: list[str] - sortOrder: list[Literal["ASC", "DESC"]] - """ - Comma-separated list of ordering. Possible values are ASC and DESC. Default is ASC. - If your sortOn contains multiple fields and you would like to order fields individually, - you can pass in a comma-separated list here - """ - - -search_params_converters = { - "searchOn": comma_separated, - "sortOn": comma_separated, - "sortOrder": comma_separated, -} diff --git a/tariff_fetch/genability/tariffs.py b/tariff_fetch/genability/tariffs.py deleted file mode 100644 index af07dde..0000000 --- a/tariff_fetch/genability/tariffs.py +++ /dev/null @@ -1,126 +0,0 @@ -import argparse -import datetime -import json -import os -from collections.abc import Sequence -from pathlib import Path -from typing import Any, Literal, TypeAlias, TypedDict - -from typing_extensions import Unpack - -from .base import BASE_URL, api_request_json -from .converters import comma_separated, true_or_false -from .pagination import PagingParams, paging_params_converters - -__all__ = [ - "TARIFFS_URL", - "CustomerClass", - "TariffType", - "TariffsGetPageParams", - "TariffsParams", - "tariffs_get_page", - "tariffs_paginate", -] - -TARIFFS_URL = f"{BASE_URL}/tariffs" - - -CustomerClass: TypeAlias = Literal["RESIDENTIAL"] | Literal["GENERAL"] | Literal["SPECIAL_USE"] -TariffType: TypeAlias = Literal["DEFAULT"] | Literal["ALTERNATIVE"] | Literal["OPTIONAL_EXTRA"] | Literal["RIDER"] - - -class TariffsParams(TypedDict, total=False): - lseId: int - fields: Literal["min", "ext"] - effectiveOn: datetime.date - masterTariffId: int - customerClasses: list[CustomerClass] - tariffTypes: list[TariffType] - populateProperties: bool - populateRates: bool - populateDocuments: bool - - -_tariffs_params_converters = { - "effectiveOn": lambda _: _.strftime("%Y-%m-%d"), - "customerClasses": comma_separated, - "lseId": str, - "populateProperties": true_or_false, - "populateRates": true_or_false, - "populateDocuments": true_or_false, - "pageStart": str, - "pageCount": str, - "tariffTypes": comma_separated, -} - - -class TariffsGetPageParams(PagingParams, TariffsParams): - pass - - -def tariffs_paginate( - auth: tuple[str, str] | None = None, - start: int = 0, - page_count: int = 25, - **params: Unpack[TariffsParams], -): - offset = start - rows = page_count - while rows >= page_count: - page = tariffs_get_page(auth, **params, pageStart=offset, pageCount=page_count) - rows = len(page["results"]) - offset += rows - yield from page["results"] - - -def tariffs_get_page(auth: tuple[str, str] | None = None, **params: Unpack[TariffsGetPageParams]) -> dict[str, Any]: - converters = {**_tariffs_params_converters, **paging_params_converters} - request_params = {key: converters.get(key, lambda _: _)(value) for key, value in params.items()} - return api_request_json(TARIFFS_URL, auth, **request_params) - - -def main(argv: Sequence[str] | None = None): - parser = argparse.ArgumentParser(description="Fetch Arcadia utility rates") - parser.add_argument("lseid", help="Utility ID") - parser.add_argument( - "--appid", - default=os.getenv("ARCADIA_APP_ID"), - help="Arcadia App Id (defaults to ARCADIA_APP_ID environment variable)", - ) - parser.add_argument( - "--appkey", - default=os.getenv("ARCADIA_APP_KEY"), - help="Arcadia App Key (defaults to ARCADIA_APP_KEY environment variable)", - ) - parser.add_argument( - "-o", - "--output", - default="arcadia_utility_rates.json", - help="Path to write the fetched rates (default: %(default)s).", - ) - args = parser.parse_args(list(argv) if argv is not None else None) - - app_id = args.appid - if not app_id: - parser.error("App Id must be provided via --appid or ARCADIA_APP_ID environment variable") - app_key = args.appkey - if not app_key: - parser.error("App Key must be provided via --appkey or ARCADIA_APP_KEY environment variable") - - tariffs = list( - tariffs_paginate( - (app_id, app_key), - lseId=args.lseid, - customerClasses=["RESIDENTIAL"], - tariffTypes=["DEFAULT", "ALTERNATIVE", "OPTIONAL_EXTRA"], - effectiveOn=datetime.datetime.now(datetime.timezone.utc), - populateRates=True, - ) - ) - output_path = Path(args.output) - output_path.write_text(json.dumps(tariffs, indent=2)) - print(f"Wrote {len(tariffs)} records to {output_path}") - - -if __name__ == "__main__": - main() diff --git a/tariff_fetch/openei/base.py b/tariff_fetch/openei/base.py index 0cae391..6e58ced 100644 --- a/tariff_fetch/openei/base.py +++ b/tariff_fetch/openei/base.py @@ -7,20 +7,20 @@ BASE_URL = "https://api.openei.org" -def convert_params(input: dict[str, Any]) -> dict[str, Any]: - return {k: _convert_param(v) for k, v in input.items()} +def convert_params(input: dict[str, Any]) -> dict[str, Any]: # pyright: ignore[reportExplicitAny] + return {k: _convert_param(v) for k, v in input.items()} # pyright: ignore[reportAny] -def _convert_param(value): +def _convert_param(value: Any): # pyright: ignore[reportAny, reportExplicitAny] if isinstance(value, datetime.datetime): return int(value.timestamp()) - return value + return value # pyright:ignore[reportAny] # return {True: "true", False: "false"}.get(value, value) -def api_request_json(path: str, api_key: str, **params) -> dict | list: - params_ = {"api_key": api_key, **convert_params(params)} +def api_request_json(path: str, api_key: str, **params) -> dict | list: # pyright: ignore[reportMissingTypeArgument, reportUnknownParameterType, reportMissingParameterType] + params_ = {"api_key": api_key, **convert_params(params)} # pyright: ignore[reportUnknownArgumentType] url = urljoin(BASE_URL, path) response = requests.get(url, params=params_) response.raise_for_status() - return response.json() + return response.json() # pyright: ignore[reportAny] diff --git a/tariff_fetch/openei/utility_rates.py b/tariff_fetch/openei/utility_rates.py index ce27aec..4842141 100644 --- a/tariff_fetch/openei/utility_rates.py +++ b/tariff_fetch/openei/utility_rates.py @@ -8,7 +8,7 @@ from typing_extensions import Unpack -from .base import api_request_json +from .base import api_request_json # pyright: ignore[reportUnknownVariableType] __all__ = [ "UTILITY_RATES_API_PATH", @@ -55,7 +55,7 @@ Literal["Residential"] | Literal["Commercial"] | Literal["Industrial"] | Literal["Lighting"] ) ScheduleMatrix: TypeAlias = list[list[int]] -AttributeList: TypeAlias = list[dict[str, Any]] +AttributeList: TypeAlias = list[dict[str, Any]] # pyright: ignore[reportExplicitAny] class FlatDemandTier(TypedDict, total=False): @@ -296,7 +296,7 @@ def utility_rates( at which point version 2 database updates ceased, with all updates now appearing only in version 3 or greater. """ - return cast( + return cast( # pyright: ignore[reportInvalidCast] UtilityRatesResponse, api_request_json(path=UTILITY_RATES_API_PATH, api_key=api_key, format=format, version=version, **kwargs), ) @@ -304,16 +304,16 @@ def utility_rates( def main(argv: Sequence[str] | None = None) -> None: parser = argparse.ArgumentParser(description="Fetch OpenEI utility rates and write them to a JSON file.") - parser.add_argument( + _ = parser.add_argument( "ratesforutility", help="Utility label (see OpenEI utility companies) to request rates for.", ) - parser.add_argument( + _ = parser.add_argument( "--api-key", default=os.getenv("OPENEI_API_KEY"), help="OpenEI API key (defaults to OPENEI_API_KEY environment variable).", ) - parser.add_argument( + _ = parser.add_argument( "-o", "--output", default="openai_utility_rates.json", @@ -321,25 +321,30 @@ def main(argv: Sequence[str] | None = None) -> None: ) args = parser.parse_args(list(argv) if argv is not None else None) - api_key = args.api_key + api_key = cast(str | None, args.api_key) if not api_key: parser.error("API key must be provided via --api-key or OPENEI_API_KEY environment variable.") + rates_for_utility = cast(str | None, args.ratesforutility) + + if rates_for_utility is None: + parser.error("ratesforutility must be set") + effective_on_date = int(datetime.datetime.now(datetime.timezone.utc).timestamp()) records = list( iter_utility_rates( api_key=api_key, format="json", version="latest", - ratesforutility=args.ratesforutility, + ratesforutility=rates_for_utility, detail="minimal", sector="Residential", effective_on_date=effective_on_date, ) ) - output_path = Path(args.output) - output_path.write_text(json.dumps(records, indent=2)) + output_path = Path(cast(str, args.output)) + _ = output_path.write_text(json.dumps(records, indent=2)) print(f"Wrote {len(records)} records to {output_path}") diff --git a/tariff_fetch/openeia.py b/tariff_fetch/openeia.py deleted file mode 100644 index 7b3f942..0000000 --- a/tariff_fetch/openeia.py +++ /dev/null @@ -1,47 +0,0 @@ -class CoreEIA861_ASSN_UTILITY: - https = "https://s3.us-west-2.amazonaws.com/pudl.catalyst.coop/nightly/core_eia861__assn_utility.parquet" - s3 = "s3://pudl.catalyst.coop/nightly/core_eia861__assn_utility.parquet" - s3_region = "us-west-2" - - class Columns: - report_date = "report_date" - "date, Date reported." - state = "state" - "string, Two letter US state abbreviation." - utility_id_eia = "utility_id_eia" - "integer, The EIA Utility Identification number." - - -class Core_PUDL_ASSN_EIA_PUDL_UTILITIES: - """Association table providing connections between EIA utility IDs and manually assigned PUDL utility IDs.""" - - https = "https://s3.us-west-2.amazonaws.com/pudl.catalyst.coop/nightly/core_pudl__assn_eia_pudl_utilities.parquet" - s3 = "s3://pudl.catalyst.coop/nightly/core_pudl__assn_eia_pudl_utilities.parquet" - - class Columns: - utility_id_eia = "utility_id_eia" - "integer, primary key, The EIA Utility Identification number." - utility_id_pudl = "utility_id_pudl" - "integer, A manually assigned PUDL utility ID. May not be stable over time." - utility_name_eia = "utility_name_eia" - "string, The name of the utility." - - -class EIA_Yearly_Sales: - """Annual time series of utilities electric sales from all rate schedules in effect throughout the year. - - https://catalystcoop-pudl.readthedocs.io/en/latest/data_dictionaries/pudl_db.html#core-eia861-yearly-sales - """ - - https = "https://s3.us-west-2.amazonaws.com/pudl.catalyst.coop/nightly/out_ferc1__yearly_sales_by_rate_schedules_sched304.parquet" - s3 = "s3://pudl.catalyst.coop/nightly/out_ferc1__yearly_sales_by_rate_schedules_sched304.parquet" - - -class CORE_EIA861_Yearly_Sales: - """Annual time series of electricity sales to ultimate customers by utility, balancing authority, state, and customer class. - - https://catalystcoop-pudl.readthedocs.io/en/latest/data_dictionaries/pudl_db.html#core-eia861-yearly-sales - """ - - https = "https://s3.us-west-2.amazonaws.com/pudl.catalyst.coop/nightly/core_eia861__yearly_sales.parquet" - s3 = "s3://pudl.catalyst.coop/nightly/core_eia861__yearly_sales.parquet" diff --git a/tariff_fetch/rateacuity/base.py b/tariff_fetch/rateacuity/base.py index 9f4358e..f28db3d 100644 --- a/tariff_fetch/rateacuity/base.py +++ b/tariff_fetch/rateacuity/base.py @@ -28,11 +28,11 @@ class ScrapingContext(NamedTuple): def create_context() -> Generator[ScrapingContext]: with TemporaryDirectory() as temp_dir: driver = create_driver_(temp_dir) - driver.set_window_size(1920, 1080) + driver.set_window_size(1920, 1080) # pyright: ignore[reportUnknownMemberType] try: yield ScrapingContext(driver, temp_dir) except Exception as e: - driver.save_screenshot("selenium_error.png") + _ = driver.save_screenshot("selenium_error.png") # pyright: ignore[reportUnknownMemberType] raise e from None @@ -44,7 +44,7 @@ def create_driver_(download_path: str) -> webdriver.Chrome: options.add_argument( "user-agent=Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.0.0 Safari/537.36" ) - options.add_experimental_option( + options.add_experimental_option( # pyright: ignore[reportUnknownMemberType] "prefs", { "download.default_directory": download_path, @@ -64,4 +64,4 @@ def login(driver: webdriver.Chrome, email_address: str, password: str): WebDriverWait(driver, 10).until( EC.element_to_be_clickable((By.XPATH, "//input[@type='submit' and @value='Log in']")) ).click() - driver.execute_script("Object.defineProperty(navigator, 'webdriver', {get: () => undefined})") + driver.execute_script("Object.defineProperty(navigator, 'webdriver', {get: () => undefined})") # pyright: ignore[reportUnknownMemberType] diff --git a/tariff_fetch/rateacuity/report_tables.py b/tariff_fetch/rateacuity/report_tables.py index 0dae307..26d3bfb 100644 --- a/tariff_fetch/rateacuity/report_tables.py +++ b/tariff_fetch/rateacuity/report_tables.py @@ -1,11 +1,13 @@ from __future__ import annotations -from typing import TypedDict +from typing import TypedDict, cast from selenium.webdriver.common.by import By from selenium.webdriver.remote.webdriver import WebDriver from selenium.webdriver.remote.webelement import WebElement +from .schema import Section + class TableJson(TypedDict): title: str @@ -14,23 +16,23 @@ class TableJson(TypedDict): class SectionJson(TypedDict): - section: str + section: str | None tables: list[TableJson] def _headers_from_table(table: WebElement) -> list[str]: - ths = table.find_elements(By.CSS_SELECTOR, "thead th") - result = [] + ths = table.find_elements(By.CSS_SELECTOR, "thead th") # pyright: ignore[reportUnknownMemberType] + result: list[str] = [] for th in ths: - links = th.find_elements(By.TAG_NAME, "a") + links = th.find_elements(By.TAG_NAME, "a") # pyright: ignore[reportUnknownMemberType] result.append(links[0].text if links else th.text) return result -def _rows_from_table(table: WebElement) -> list[str]: - rows = [] - for tr in table.find_elements(By.CSS_SELECTOR, "tbody tr"): - tds = tr.find_elements(By.TAG_NAME, "td") +def _rows_from_table(table: WebElement) -> list[list[str]]: + rows: list[list[str]] = [] + for tr in table.find_elements(By.CSS_SELECTOR, "tbody tr"): # pyright: ignore[reportUnknownMemberType] + tds = tr.find_elements(By.TAG_NAME, "td") # pyright: ignore[reportUnknownMemberType] rows.append([td.text for td in tds]) return rows @@ -48,7 +50,7 @@ def _table_json(table: WebElement) -> TableJson | None: v = {} for c, r in zip(columns, row, strict=False): v[c] = r - values.append(v) + values.append(v) # pyright: ignore[reportUnknownMemberType] return TableJson( { @@ -59,11 +61,11 @@ def _table_json(table: WebElement) -> TableJson | None: ) -def sections_to_json(driver: WebDriver) -> list[SectionJson]: +def sections_to_json(driver: WebDriver) -> list[Section]: seq = driver.find_elements(By.CSS_SELECTOR, "h3, table.eamwebgrid-table") - sections = [] - current = {"section": None, "tables": []} + sections: list[SectionJson] = [] + current: SectionJson = {"section": None, "tables": []} for el in seq: tag = el.tag_name.lower() if tag == "h3": @@ -78,4 +80,8 @@ def sections_to_json(driver: WebDriver) -> list[SectionJson]: if current["section"] is not None or current["tables"]: sections.append(current) - return sections + # Schema is not yet complete, ignore validation + return cast(list[Section], sections) + # ta = TypeAdapter(list[Section]) + + # return ta.validate_python(sections, by_alias=True) diff --git a/tariff_fetch/rateacuity/schema.py b/tariff_fetch/rateacuity/schema.py new file mode 100644 index 0000000..a4728b9 --- /dev/null +++ b/tariff_fetch/rateacuity/schema.py @@ -0,0 +1,218 @@ +import re +from datetime import date +from typing import Annotated, Any, Generic, Literal, NamedTuple, TypedDict, TypeVar + +from pydantic import BeforeValidator, Field +from pydantic.alias_generators import to_camel + +USStateCode = Literal[ + "AL", + "AK", + "AZ", + "AR", + "CA", + "CO", + "CT", + "DE", + "FL", + "GA", + "HI", + "ID", + "IL", + "IN", + "IA", + "KS", + "KY", + "LA", + "ME", + "MD", + "MA", + "MI", + "MN", + "MS", + "MO", + "MT", + "NE", + "NV", + "NH", + "NJ", + "NM", + "NY", + "NC", + "ND", + "OH", + "OK", + "OR", + "PA", + "RI", + "SC", + "SD", + "TN", + "TX", + "UT", + "VT", + "VA", + "WA", + "WV", + "WI", + "WY", +] + +UseType = Literal["C", "R", "I", "A", " "] +""" +Indicator of what type of customer accounts the schedule can be used for. +C=Commercial, R=Residential, I=Industrial, A=Agricultural. Will be blank if used for +more than one type of account. +""" + +SCRateDeterminant = Literal["per month"] +OCRateDeterminant = Literal["per month"] +CORateDeterminant = Literal["per therm"] + + +class MonthAndDay(NamedTuple): + month: int + day: int + + +def month_and_day(input_: str | MonthAndDay | None) -> MonthAndDay | None: + if input_ is None: + return None + if isinstance(input_, tuple): + return input_ + daystr, monthstr = input_.split("-") + return MonthAndDay( + month=("JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV", "DEC").index( + monthstr.upper() + ), + day=int(daystr), + ) + + +def empty_is_none(input_: Any) -> str | None: # pyright: ignore[reportAny, reportExplicitAny] + if not isinstance(input_, str): + return input_ # pyright: ignore[reportAny] + if not input_: + return None + return input_ + + +empty_or_none_validator = BeforeValidator(empty_is_none) +FloatOrNone = Annotated[float | None, empty_or_none_validator] +DateOrNone = Annotated[date | None, empty_or_none_validator] +StrOrNone = Annotated[str | None, empty_or_none_validator] + +MonthAndDayAnnotated = Annotated[MonthAndDay, BeforeValidator(month_and_day)] +MonthAndDayOrNone = Annotated[MonthAndDay | None, BeforeValidator(month_and_day), empty_or_none_validator] + + +def to_spaced_camel(snake_case: str) -> str: + camel = to_camel(snake_case) # snake_case -> lowerCamelCase + spaced = re.sub(r"([A-Z])", r" \1", camel) + return spaced.strip().title() + + +class Schedule(TypedDict): + __pydantic_config__ = {"alias_generator": to_spaced_camel, "extra": "forbid"} # pyright: ignore[reportGeneralTypeIssues, reportUnannotatedClassAttribute] # noqa + state: USStateCode + utility: str + schedule_name: str + schedule_description: str + use_type: UseType + min_peak: FloatOrNone + """ Start of specific peak range for which the schedule should be used """ + max_peak: FloatOrNone + """End of specific peak range for which the schedule should be used""" + peak_determinant: str + """Used in conjunction with MinPeak and MaxPeak to define the unit of measure for peak values""" + min_usage: FloatOrNone + """Indicator of minimum usage of account served by the schedule""" + max_usage: FloatOrNone + """Indicator of maximum usage of account served by the schedule""" + usage_determinant: str + """Used in conjunction with MinUsage and MaxUsage to define the min and max measurement""" + option_description: str + """Text description of specific option from tariff for this data set""" + load_season: str + """ + If the schedule is applicable based on load during a specific season the season is shown here + """ + load_comp: str + """ + If the schedule is applicable on load during a specific season compared to + other months, the months to compare are shown here + """ + load_percent: FloatOrNone + """ + If the schedule is applicable on load during a specific season compared to + other months, the comparison value is shown here + """ + season_start: DateOrNone + """First date of season""" + season_end: DateOrNone + """Last date of season""" + effective_date: date + + +class ServiceCharge(TypedDict): + __pydantic_config__ = {"alias_generator": to_spaced_camel, "extra": "forbid"} # pyright: ignore[reportGeneralTypeIssues, reportUnannotatedClassAttribute] # noqa + service_charges: str + season_start: DateOrNone + season_end: DateOrNone + start: FloatOrNone + """The minimum value for which this record should be used""" + end: FloatOrNone + """The maximum value for which this record should be used""" + charge_determinant: str + """Used with Start and End to show the measurement of the values of + these fields""" + rate: float + rate_determinant: SCRateDeterminant + """Measure of rate charge""" + effective_date: date + + +class OtherCharge(TypedDict): + __pydantic_config__ = {"alias_generator": to_spaced_camel, "extra": "forbid"} # pyright: ignore[reportGeneralTypeIssues, reportUnannotatedClassAttribute] # noqa + other_charges: str + rate: float + rate_determinant: OCRateDeterminant + min_therms: FloatOrNone + max_therms: FloatOrNone + effective_date: date + + +class Consumption(TypedDict): + __pydantic_config__ = {"alias_generator": to_spaced_camel, "extra": "forbid"} # pyright: ignore[reportGeneralTypeIssues, reportUnannotatedClassAttribute] # noqa + consumption: str + season_start: MonthAndDayOrNone + season_end: MonthAndDayOrNone + rate: float + rate_determinant: CORateDeterminant + effective_date: date + + +T = TypeVar("T") + + +class Table(TypedDict, Generic[T]): + title: str + values: Annotated[list[T], Field(fail_fast=True)] + + +ScheduleTable = Table[Schedule] +ServiceChargesTable = Table[ServiceCharge] +OtherChargesTable = Table[OtherCharge] +ConsumptionTable = Table[Consumption] + +TableUnion = ScheduleTable | ServiceChargesTable | OtherChargesTable | ConsumptionTable + + +class Section(TypedDict): + section: str | None + tables: Annotated[list[TableUnion], Field(fail_fast=True)] + + +class Tariff(TypedDict): + schedule: str + sections: list[Section] diff --git a/tariff_fetch/rateacuity/state.py b/tariff_fetch/rateacuity/state.py index f73180c..2c8ef5c 100644 --- a/tariff_fetch/rateacuity/state.py +++ b/tariff_fetch/rateacuity/state.py @@ -45,9 +45,10 @@ from selenium.webdriver.support import expected_conditions as EC from selenium.webdriver.support.ui import Select, WebDriverWait -from tariff_fetch.rateacuity.report_tables import SectionJson, sections_to_json +from tariff_fetch.rateacuity.report_tables import sections_to_json from .base import AuthorizationError, ScrapingContext, create_context, login +from .schema import Section logger = logging.getLogger(__name__) @@ -314,7 +315,7 @@ def download_excel(self, timeout: int = 20) -> Path: filename = next(iter(_get_xlsx(download_path) ^ initial_state)) return Path(download_path, filename) - def as_sections(self) -> list[SectionJson]: + def as_sections(self) -> list[Section]: return sections_to_json(self._context.driver) def as_dataframe(self, timeout: int = 20) -> pl.DataFrame: @@ -501,7 +502,8 @@ def main(argv: Sequence[str] | None = None): state = state.select_schedule(schedule) df = state.as_dataframe() df = df.with_columns(pl.lit(schedule).alias("Schedule")) # pyright: ignore[reportUnknownMemberType] - df = df.select(["Schedule", *[name for name in df.columns if name != "Schedule"]]) # pyright: ignore[reportUnknownMemberType] + cols: list[str] = [name for name in df.columns if name != "Schedule"] + df = cast(pl.DataFrame, df.select(["Schedule", *cols])) frames.append(df) state = state.back_to_selections() diff --git a/tariff_fetch/urdb/rateacuity_history_gas/exceptions.py b/tariff_fetch/urdb/rateacuity_history_gas/exceptions.py index bf46ba8..29c0a02 100644 --- a/tariff_fetch/urdb/rateacuity_history_gas/exceptions.py +++ b/tariff_fetch/urdb/rateacuity_history_gas/exceptions.py @@ -1,4 +1,4 @@ -from typing import final, override +from typing_extensions import final, override @final diff --git a/tariff_fetch/urdb/rateacuity_history_gas/history_data.py b/tariff_fetch/urdb/rateacuity_history_gas/history_data.py index 7a39a11..ed11c7d 100644 --- a/tariff_fetch/urdb/rateacuity_history_gas/history_data.py +++ b/tariff_fetch/urdb/rateacuity_history_gas/history_data.py @@ -2,10 +2,11 @@ from collections.abc import Iterator from datetime import datetime from math import inf -from typing import Any, cast, final, override +from typing import Any, cast, final import polars as pl from pydantic import BaseModel, TypeAdapter, ValidationError +from typing_extensions import override from .exceptions import IncorrectDataframeSchemaMonths, IncorrectDataframeSchemaMultipleYears from .shared import is_date_column_name, kwh_multiplier diff --git a/tariff_fetch/urdb/schema.py b/tariff_fetch/urdb/schema.py index fe9b396..472bea6 100644 --- a/tariff_fetch/urdb/schema.py +++ b/tariff_fetch/urdb/schema.py @@ -97,7 +97,7 @@ class EnergyTier(TypedDict, total=False): CoincidentRateStructure = list[list[CoincidentTier]] EnergyRateStructure = list[list[EnergyTier]] -Attrs = list[dict[str, Any]] # docs say “attribute/value pairs” :contentReference[oaicite:3]{index=3} +Attrs = list[dict[str, Any]] # pyright: ignore[reportExplicitAny] class URDBRate(TypedDict, total=False): diff --git a/uv.lock b/uv.lock index 28e69d4..23a4d36 100644 --- a/uv.lock +++ b/uv.lock @@ -52,6 +52,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/41/ff/392bff89415399a979be4a65357a41d92729ae8580a66073d8ec8d810f98/backrefs-5.9-py39-none-any.whl", hash = "sha256:f48ee18f6252b8f5777a22a00a09a85de0ca931658f1dd96d4406a34f3748c60", size = 380265, upload-time = "2025-06-22T19:34:12.405Z" }, ] +[[package]] +name = "basedpyright" +version = "1.38.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nodejs-wheel-binaries" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/17/ea/4d45e3c66c609496f3069a7c9e5fbd1f9ba54097c41b89048af0d8021ea6/basedpyright-1.38.1.tar.gz", hash = "sha256:e4876aa3ef7c76569ffdcd908d4e260b8d1a1deaa8838f2486f91a10b60d68d6", size = 25267403, upload-time = "2026-02-18T09:20:45.563Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/28/92/42f4dc30a28c052a70c939d8dbb34102674b48c89369010442038d3c888b/basedpyright-1.38.1-py3-none-any.whl", hash = "sha256:24f21661d2754687b64f3bc35efcc78781e11b08c8b2310312ed92bf178ea627", size = 12311610, upload-time = "2026-02-18T09:20:50.09Z" }, +] + [[package]] name = "build" version = "1.3.0" @@ -800,6 +812,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/34/67/d5e07efd38194f52b59b8af25a029b46c0643e9af68204ee263022924c27/nh3-0.3.1-cp38-abi3-win_arm64.whl", hash = "sha256:a3e810a92fb192373204456cac2834694440af73d749565b4348e30235da7f0b", size = 586369, upload-time = "2025-10-07T03:27:57.234Z" }, ] +[[package]] +name = "nodejs-wheel-binaries" +version = "24.13.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e5/d0/81d98b8fddc45332f79d6ad5749b1c7409fb18723545eae75d9b7e0048fb/nodejs_wheel_binaries-24.13.1.tar.gz", hash = "sha256:512659a67449a038231e2e972d49e77049d2cf789ae27db39eff4ab1ca52ac57", size = 8056, upload-time = "2026-02-12T17:31:04.368Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/aa/04/1ffe1838306654fcb50bcf46172567d50c8e27a76f4b9e55a1971fab5c4f/nodejs_wheel_binaries-24.13.1-py2.py3-none-macosx_13_0_arm64.whl", hash = "sha256:360ac9382c651de294c23c4933a02358c4e11331294983f3cf50ca1ac32666b1", size = 54757440, upload-time = "2026-02-12T17:30:35.748Z" }, + { url = "https://files.pythonhosted.org/packages/66/f6/81ad81bc3bd919a20b110130c4fd318c7b6a5abb37eb53daa353ad908012/nodejs_wheel_binaries-24.13.1-py2.py3-none-macosx_13_0_x86_64.whl", hash = "sha256:035b718946793986762cdd50deee7f5f1a8f1b0bad0f0cfd57cad5492f5ea018", size = 54932957, upload-time = "2026-02-12T17:30:40.114Z" }, + { url = "https://files.pythonhosted.org/packages/14/be/8e8a2bd50953c4c5b7e0fca07368d287917b84054dc3c93dd26a2940f0f9/nodejs_wheel_binaries-24.13.1-py2.py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:f795e9238438c4225f76fbd01e2b8e1a322116bbd0dc15a7dbd585a3ad97961e", size = 59287257, upload-time = "2026-02-12T17:30:43.781Z" }, + { url = "https://files.pythonhosted.org/packages/58/57/92f6dfa40647702a9fa6d32393ce4595d0fc03c1daa9b245df66cc60e959/nodejs_wheel_binaries-24.13.1-py2.py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:978328e3ad522571eb163b042dfbd7518187a13968fe372738f90fdfe8a46afc", size = 59781783, upload-time = "2026-02-12T17:30:47.387Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a5/457b984cf675cf86ace7903204b9c36edf7a2d1b4325ddf71eaf8d1027c7/nodejs_wheel_binaries-24.13.1-py2.py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e1dc893df85299420cd2a5feea0c3f8482a719b5f7f82d5977d58718b8b78b5f", size = 61287166, upload-time = "2026-02-12T17:30:50.646Z" }, + { url = "https://files.pythonhosted.org/packages/3c/99/da515f7bc3bce35cfa6005f0e0c4e3c4042a466782b143112eb393b663be/nodejs_wheel_binaries-24.13.1-py2.py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:0e581ae219a39073dcadd398a2eb648f0707b0f5d68c565586139f919c91cbe9", size = 61870142, upload-time = "2026-02-12T17:30:54.563Z" }, + { url = "https://files.pythonhosted.org/packages/cc/c0/22001d2c96d8200834af7d1de5e72daa3266c7270330275104c3d9ddd143/nodejs_wheel_binaries-24.13.1-py2.py3-none-win_amd64.whl", hash = "sha256:d4c969ea0bcb8c8b20bc6a7b4ad2796146d820278f17d4dc20229b088c833e22", size = 41185473, upload-time = "2026-02-12T17:30:57.524Z" }, + { url = "https://files.pythonhosted.org/packages/ab/c4/7532325f968ecfc078e8a028e69a52e4c3f95fb800906bf6931ac1e89e2b/nodejs_wheel_binaries-24.13.1-py2.py3-none-win_arm64.whl", hash = "sha256:caec398cb9e94c560bacdcba56b3828df22a355749eb291f47431af88cbf26dc", size = 38881194, upload-time = "2026-02-12T17:31:00.214Z" }, +] + [[package]] name = "outcome" version = "1.3.0.post0" @@ -1474,6 +1502,7 @@ dependencies = [ [package.dev-dependencies] dev = [ + { name = "basedpyright" }, { name = "build" }, { name = "deptry" }, { name = "mkdocs" }, @@ -1482,7 +1511,6 @@ dev = [ { name = "pytest" }, { name = "ruff" }, { name = "twine" }, - { name = "ty" }, ] [package.metadata] @@ -1506,6 +1534,7 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ + { name = "basedpyright", specifier = ">=1.38.1" }, { name = "build", specifier = ">=1.0.0" }, { name = "deptry", specifier = ">=0.20.0" }, { name = "mkdocs", specifier = ">=1.6.0" }, @@ -1514,7 +1543,6 @@ dev = [ { name = "pytest", specifier = ">=7.2.0" }, { name = "ruff", specifier = ">=0.11.5" }, { name = "twine", specifier = ">=4.0.0" }, - { name = "ty", specifier = ">=0.0.1a21" }, ] [[package]] @@ -1577,31 +1605,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3a/7a/882d99539b19b1490cac5d77c67338d126e4122c8276bf640e411650c830/twine-6.2.0-py3-none-any.whl", hash = "sha256:418ebf08ccda9a8caaebe414433b0ba5e25eb5e4a927667122fbe8f829f985d8", size = 42727, upload-time = "2025-09-04T15:43:15.994Z" }, ] -[[package]] -name = "ty" -version = "0.0.1a22" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/bb/87/eab73cdc990d1141b60237379975efc0e913bfa0d19083daab0f497444a6/ty-0.0.1a22.tar.gz", hash = "sha256:b20ec5362830a1e9e05654c15e88607fdbb45325ec130a9a364c6dd412ecbf55", size = 4312182, upload-time = "2025-10-10T13:07:15.88Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/4d/30/83e2dbfbc70de8a1932b19daf05ce803d7d76cdc6251de1519a49cf1c27d/ty-0.0.1a22-py3-none-linux_armv6l.whl", hash = "sha256:6efba0c777881d2d072fa7375a64ad20357e825eff2a0b6ff9ec80399a04253b", size = 8581795, upload-time = "2025-10-10T13:06:44.396Z" }, - { url = "https://files.pythonhosted.org/packages/d7/8c/5193534fc4a3569f517408828d077b26d6280fe8c2dd0bdc63db4403dcdb/ty-0.0.1a22-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:2ada020eebe1b44403affdf45cd5c8d3fb8312c3e80469d795690093c0921f55", size = 8682602, upload-time = "2025-10-10T13:06:46.44Z" }, - { url = "https://files.pythonhosted.org/packages/22/4a/7ba53493bf37b61d3e0dfe6df910e6bc74c40d16c3effd84e15c0863d34e/ty-0.0.1a22-py3-none-macosx_11_0_arm64.whl", hash = "sha256:ed4f11f1a5824ea10d3e46b1990d092c3f341b1d492c357d23bed2ac347fd253", size = 8278839, upload-time = "2025-10-10T13:06:48.688Z" }, - { url = "https://files.pythonhosted.org/packages/52/0a/d9862c41b9615de56d2158bfbb5177dbf5a65e94922d3dd13855f48cb91b/ty-0.0.1a22-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:56f48d8f94292909d596dbeb56ff7f9f070bd316aa628b45c02ca2b2f5797f31", size = 8421483, upload-time = "2025-10-10T13:06:50.75Z" }, - { url = "https://files.pythonhosted.org/packages/a5/cb/3ebe0e45b80724d4c2f849fdf304179727fd06df7fee7cd12fe6c3efe49d/ty-0.0.1a22-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:733e9ac22885b6574de26bdbae439c960a06acc825a938d3780c9d498bb65339", size = 8419225, upload-time = "2025-10-10T13:06:52.533Z" }, - { url = "https://files.pythonhosted.org/packages/4f/b5/da65f3f8ad31d881ca9987a3f6f26069a0cc649c9354adb7453ca62116bb/ty-0.0.1a22-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5135d662484e56809c77b3343614005585caadaa5c1cf643ed6a09303497652b", size = 9352336, upload-time = "2025-10-10T13:06:54.476Z" }, - { url = "https://files.pythonhosted.org/packages/a3/24/9c46f2eb16734ab0fcf3291486b1c5c528a1569f94541dc1f19f97dd2a5b/ty-0.0.1a22-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:87f297f99a98154d33a3f21991979418c65d8bf480f6a1bad1e54d46d2dc7df7", size = 9857840, upload-time = "2025-10-10T13:06:56.514Z" }, - { url = "https://files.pythonhosted.org/packages/d8/ae/930c94bbbe5c049eae5355a197c39522844f55c7ab7fccd0ba061f618541/ty-0.0.1a22-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3310217eaa4dccf20b7336fcbeb072097addc6fde0c9d3f791dea437af0aa6dc", size = 9452611, upload-time = "2025-10-10T13:06:58.154Z" }, - { url = "https://files.pythonhosted.org/packages/a2/80/d8f594438465c352cf0ebd4072f5ca3be2871153a3cd279ed2f35ecd487c/ty-0.0.1a22-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:12b032e81012bf5228fd65f01b50e29eb409534b6aac28ee5c48ee3b7b860ddf", size = 9214875, upload-time = "2025-10-10T13:06:59.861Z" }, - { url = "https://files.pythonhosted.org/packages/fd/07/f852fb20ac27707de495c39a02aeb056e3368833b7e12888d43b1f61594d/ty-0.0.1a22-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a3ffda8149cab0000a21e7a078142073e27a1a9ac03b9a0837aa2f53d1fbebcb", size = 8906715, upload-time = "2025-10-10T13:07:01.926Z" }, - { url = "https://files.pythonhosted.org/packages/40/4d/0e0b85b4179891cc3067a6e717f5161921c07873a4f545963fdf1dd3619c/ty-0.0.1a22-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:afa512e7dc78f0cf0b55f87394968ba59c46993c67bc0ef295962144fea85b12", size = 8350873, upload-time = "2025-10-10T13:07:03.999Z" }, - { url = "https://files.pythonhosted.org/packages/a1/1f/e70c63e12b4a0d97d4fd6f872dd199113666ad1b236e18838fa5e5d5502d/ty-0.0.1a22-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:069cdbbea6025f7ebbb5e9043c8d0daf760358df46df8304ef5ca5bb3e320aef", size = 8442568, upload-time = "2025-10-10T13:07:05.745Z" }, - { url = "https://files.pythonhosted.org/packages/de/3b/55518906cb3598f2b99ff1e86c838d77d006cab70cdd2a0a625d02ccb52c/ty-0.0.1a22-py3-none-musllinux_1_2_i686.whl", hash = "sha256:67d31d902e6fd67a4b3523604f635e71d2ec55acfb9118f984600584bfe0ff2a", size = 8896775, upload-time = "2025-10-10T13:07:08.02Z" }, - { url = "https://files.pythonhosted.org/packages/c3/ea/60c654c27931bf84fa9cb463a4c4c49e8869c052fa607a6e930be717b619/ty-0.0.1a22-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:f9e154f262162e6f76b01f318e469ac6c22ffce22b010c396ed34e81d8369821", size = 9054544, upload-time = "2025-10-10T13:07:09.675Z" }, - { url = "https://files.pythonhosted.org/packages/6c/60/9a6d5530d6829ccf656e6ae0fb13d70a4e2514f4fb8910266ebd54286620/ty-0.0.1a22-py3-none-win32.whl", hash = "sha256:37525433ca7b02a8fca4b8fa9dcde818bf3a413b539b9dbc8f7b39d124eb7c49", size = 8165703, upload-time = "2025-10-10T13:07:11.378Z" }, - { url = "https://files.pythonhosted.org/packages/14/9c/ac08c832643850d4e18cbc959abc69cd51d531fe11bdb691098b3cf2f562/ty-0.0.1a22-py3-none-win_amd64.whl", hash = "sha256:75d21cdeba8bcef247af89518d7ce98079cac4a55c4160cb76682ea40a18b92c", size = 8828319, upload-time = "2025-10-10T13:07:12.815Z" }, - { url = "https://files.pythonhosted.org/packages/22/df/38068fc44e3cfb455aeb41d0ff1850a4d3c9988010466d4a8d19860b8b9a/ty-0.0.1a22-py3-none-win_arm64.whl", hash = "sha256:1c7f040fe311e9696917417434c2a0e58402235be842c508002c6a2eff1398b0", size = 8367136, upload-time = "2025-10-10T13:07:14.518Z" }, -] - [[package]] name = "typedload" version = "2.39"