diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..2435602 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,16 @@ +# EditorConfig is awesome: https://EditorConfig.org + +# top-most EditorConfig file +root = true + +[*] +indent_style = space +indent_size = 4 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true +max_line_length = 132 + +[*.yml] +indent_size = 2 diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..844f8f8 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,37 @@ +name: Build +on: + push: + branches: [main] + workflow_dispatch: +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: 3.11 + - name: Install poetry + run: pip install -q poetry==1.5.0 + - name: Setup local virtual environment + run: | + poetry config virtualenvs.create true --local + poetry config virtualenvs.in-project true --local + - uses: actions/cache@v4 + with: + path: ./.venv + key: venv-${{ hashFiles('poetry.lock') }} + - name: Install the project dependencies + run: poetry install -q + - name: Run the automated tests + env: + ADJUST_EMAIL: ${{ secrets.ADJUST_EMAIL }} + ADJUST_PASSWORD: ${{ secrets.ADJUST_PASSWORD }} + run: | + echo email=$ADJUST_EMAIL + echo password=$ADJUST_PASSWORD + poetry run pytest --cov=adjust --cov-report xml:cov.xml -vv + - name: Upload coverage reports to Codecov + uses: codecov/codecov-action@v4 + with: + token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8a74e11 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +.DS_Store +__pycache__/ +.mypy_cache/ +.pytest_cache/ +*.pyc +.env +.coverage* +cov.xml diff --git a/.mypy.ini b/.mypy.ini new file mode 100644 index 0000000..d532a52 --- /dev/null +++ b/.mypy.ini @@ -0,0 +1,9 @@ +[mypy] +files=**/*.py +disallow_untyped_defs = True +warn_return_any = True +warn_unused_configs = True + +[mypy-tests.*] +disallow_untyped_defs = True +no_implicit_optional = True diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..c3f52f2 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,27 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "CLI: add placeholders", + "type": "debugpy", + "request": "launch", + "module": "adjust.cli", + "args": [ + "callbacks", + "snapshot", + "placeholders", + "add", + "-u", + "invenio.sgn.com/v1/adjust", + "-a", + "!Cookie,!Bingo", + "-n", + "jamcity_ext", + ], + "console": "integratedTerminal", + }, + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..8759f85 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,11 @@ +{ + "python.testing.pytestArgs": [ + "tests", + "--cov=adjust", + "--cov-report", + "xml:cov.xml", + "-vv" + ], + "python.testing.unittestEnabled": false, + "python.testing.pytestEnabled": true +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..dc961b1 --- /dev/null +++ b/README.md @@ -0,0 +1,42 @@ +# Adjust CLI + +## Description + +Adjust CLI is a Python package that provides a command-line interface (CLI) to manage Adjust callbacks. It allows you to easily configure and handle callbacks for your Adjust integration. + +## Features + +- Backup and restore callbacks configuration through snapshots +- Add and remove placeholders to/from a subset of callbacks in a snapshot + +## Installation + +To install Adjust Callback Manager, you can use pip: + +``` +pip install "adjust-cli@git+https://github.com/mindjolt/adjust-cli" +``` + +## Usage + +This CLI provides several commands for managing Adjust callbacks and snapshots. + +### Snapshot + +Create a local snapshot of all Adjust callbacks: + +```bash +adjust snapshot create --snapshot SNAPSHOT_PATH +``` + +Restore Adjust callbacks from a local snapshot: + +```bash +adjust snapshot restore --snapshot SNAPSHOT_PATH +``` + +Add placeholders to a snapshot: + +```bash +adjust snapshot modify --snapshot SNAPSHOT_PATH --having-app MyApp -a PLACEHOLDER +``` diff --git a/adjust/__init__.py b/adjust/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/adjust/api/__init__.py b/adjust/api/__init__.py new file mode 100644 index 0000000..19453ab --- /dev/null +++ b/adjust/api/__init__.py @@ -0,0 +1,3 @@ +__all__ = ["AdjustAPI"] + +from .api import AdjustAPI diff --git a/adjust/api/api.py b/adjust/api/api.py new file mode 100755 index 0000000..38f906a --- /dev/null +++ b/adjust/api/api.py @@ -0,0 +1,180 @@ +#!/usr/bin/env python +from __future__ import annotations +from functools import cached_property +import json +import os +from types import NoneType +from bs4 import BeautifulSoup +from pydantic import parse_obj_as +from typing import Any, Optional, Type, TypeVar + +import requests + +from .model import RawCallback, App, AppsResponse, Callback, Event, EventsResponse, Placeholder, User + + +T = TypeVar("T") + + +class AdjustAPI(object): + """A class to interact with the Adjust API + + Attributes: + user : User + The currently logged in user + placeholders : list[Placeholder] + A list of all the available Adjust placeholders + apps : list[App] + The list of all the registered apps on the Adjust dashboard + The app object contains attributes such as the adjust token or the platform + appIds + + Methods: + events(app: App | str) : list[Event] + Get the list of all the events registered for an App or app token + callbacks(app: App | str) : list[Callback] + Get the list of all the available callbacks for an App or app token. + The returned Callback objects contain the endpoint URL if one is set. + set_callback(app: App | str, type: str, callback_url: str) + Updates the callback of a given type to the new callback_url. + + """ + + def __init__(self, email: str = None, password: str = None): + """Creates an object that can be used to issue calls to the Adjust API. + Valid credentials must be supplied in order for the API calls to be + authenticated and authorized. + + Args: + email (str): the authentication email + password (str): the authentication password + """ + email = email or os.getenv("ADJUST_EMAIL") + if not email: + raise ValueError("Email not provided") + password = password or os.getenv("ADJUST_PASSWORD") + if not password: + raise ValueError("Password not provided") + self._session = requests.Session() + self._user: Optional[User] = None + self._log_in = lambda: self._sign_in(email, password) + self._logged_in = False + + def _log_in_if_needed(self) -> None: + """Internal method used to log in upon first use""" + if not self._logged_in: + self._logged_in = True + self._log_in() + + def _api(self, type: Type[T], path: str, method: str = "GET", **data: Any) -> T: + """Internal method used to emit low-level API calls. + + Args: + path (str): the API path to call + method (str, optional): The HTTP method to use. Defaults to "GET". + + Returns: + Any: the API response + """ + self._log_in_if_needed() + url = "https://api.adjust.com/" + path + headers = dict(Accept="application/json") + if not data: + r = self._session.get(url, headers=headers) + elif method == "PUT": + r = self._session.put(url, headers=headers, json=data) + else: + r = self._session.post(url, headers=headers, json=data) + r.raise_for_status() + return parse_obj_as(type, None if r.status_code == 204 else r.json()) + + def _sign_in(self, email: str, password: str) -> None: + """Internal method to authenticate with the Adjust API + + Args: + email (str): The login email + password (str): The login password + + Returns: + User: The logged in user + """ + user = dict(email=email, password=password, remember_me=True) + self._user = self._api(User, "accounts/users/sign_in", user=user) + + def user(self) -> Optional[User]: + """Returns the currently logged in user + + Returns: + User + """ + self._log_in_if_needed() + return self._user + + @cached_property + def placeholders(self) -> list[Placeholder]: + """Returns a list of all available Adjust placeholders + + Returns: + list[Placeholder] + """ + url = "https://help.adjust.com/en/partner/placeholders" + html = self._session.get(url).content + soup = BeautifulSoup(html, "lxml") + script = soup.find(id="__NEXT_DATA__") + assert script, "Could not find placeholders data" + data = json.loads(script.text) + placeholders = data["props"]["pageProps"]["placeholdersData"] + return [Placeholder.parse_obj(p) for p in placeholders] + + @cached_property + def apps(self) -> list[App]: + """Returns a list of all the registered apps on the Adjust dashboard. + The returned list contains one object per application, with properties + such as the app token or platform appIds. + + Returns: + list[App] + """ + response = self._api(AppsResponse, "dashboard/api/apps") + return response.apps + + def callbacks(self, app: App | str) -> list[Callback]: + """Returns the list of callbacks available for an app or app token. + + Args: + app (App | str): the app or app token + + Returns: + list[Callback]: the callback mapping + """ + token = app if isinstance(app, str) else app.token + cbs = self._api(list[RawCallback], f"dashboard/api/apps/{token}/callbacks") + return [c.to_callback() for c in cbs] + + def events(self, app: App | str, include_archived: bool = False) -> dict[str, Event]: + """Returns a mapping of all the events for an app or app token. + Events are mapped by their event token. + + Args: + app (App | str): The app or app token + include_archived (bool, optional): True to include archived events. + Defaults to False. + + Returns: + list[Event]: the list of events + """ + token = app if isinstance(app, str) else app.token + template = f"dashboard/api/apps/{token}/event_types?include_archived={include_archived}" # noqa: E501 + data = self._api(EventsResponse, template) + return {e.token: e for e in data.events} + + def update_callback(self, app: App | str, callback: Callback) -> None: + """Updates an app callback. + + Args: + app (App | str): The app or app token + callback (Callback): The modified callback to be updated + """ + token = app if isinstance(app, str) else app.token + path = f"dashboard/api/apps/{token}/event_types/{callback.id}/callback" + self._api(NoneType, path, method="PUT", callback_url=callback.url) diff --git a/adjust/api/model.py b/adjust/api/model.py new file mode 100644 index 0000000..5bed450 --- /dev/null +++ b/adjust/api/model.py @@ -0,0 +1,333 @@ +from __future__ import annotations + +import bisect +from datetime import date, datetime +from typing import Literal, Type, TypeVar +from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit +from pydantic import BaseModel, Extra, Field + + +class PlaceholderType(BaseModel): + label: str + value: str + + +class Placeholder(BaseModel): + category: str + placeholder: str + content: str + example: str + dataType: str + impression: bool # Impression + click: bool # Click + install: bool # Install + session: bool # Session + reattribution: bool # reattribution + event: bool # In-app event + uninstall: bool # Uninstall + reinstall: bool # Reinstall + reattributionReinstall: bool # Reattribution reinstall + adSpend: bool # Ad spend + gdpr: bool # Erased user (GDPR) + clients: bool + dynamicPartners: bool + modulePartners: bool + sanClick: bool # SAN click + sanImpression: bool # SAN impression + adRevenue: bool # Ad revenue + subscription: bool # Subscription + attUpdate: bool # ATT status update (iOS) + + attributionUpdate: bool # Attribution update + skCvUpdate: bool # SkAdNetwork CV update + skEvent: bool # SkAdNetwork event + skInstall: bool # SkAdNetwork install + skInstallDirect: bool # SkAdNetwork direct install + skQualifier: bool # SKAdNetwork qualifier + availableAs: list[PlaceholderType] + unavailableAs: list[PlaceholderType] + + +class App(BaseModel): + id: int + name: str + token: str + app_token: str # wv12z93syz35 + currency: Currency + default_store_app_id: str | None # com.acme.game + integration_dates: IntegrationDates + permissions: Permissions + platforms: Platforms + start_date: date + default_attribution_platform: str # mobile_app + is_ctv: bool + is_child_directed: bool + fraud_prevention_settings: FraudPreventionSettings + + def __hash__(self) -> int: + return hash(self.token) + + def __str__(self) -> str: + return f"{self.name} ({self.token})" + + +class Currency(BaseModel): + name: str # United States Dollar + symbol: str # $ + iso_code: str # USD + + +class IntegrationDates(BaseModel): + android: date | None + android_tv: date | None = Field(alias="android-tv") + ios: date | None + linux: date | None + unknown: date | None + windows: date | None + windows_phone: date | None = Field(alias="windows-phone") + + +class Permissions(BaseModel): + generate_report: bool = False + read_statistics: bool = False + create_tracker: bool = False + update_settings: bool = False + update_custom_twitter_permissions: bool = False + + +class Platforms(BaseModel): + android: Platform + ios: iOSPlatform + windows: Platform + windows_phone: Platform = Field(alias="windows-phone") + + class Config: + allow_population_by_field_name = True + + +class Platform(BaseModel): + configured: bool + store: Literal["amazon", "google", "itunes", "windows"] | None + app_id: str | None + + +class iOSPlatform(Platform): + app_state: Literal["not_verified", "verified"] | None + ios_bundle_id: str | None + + +class FraudPreventionSettings(BaseModel): + distribution_filter: Literal["advanced", "standard"] | None + show_invalid_signature: bool = False + filter_anonymous_traffic: bool = False + filter_engagement_injection: bool = False + filter_too_many_engagements: bool = False + + +class AppsResponse(BaseModel): + apps: list[App] + urls: Urls + page: Page + + class Config: + extra = Extra.forbid + + +class Urls(BaseModel): + class Config: + extra = Extra.forbid + + +class Page(BaseModel): + position: int + + class Config: + extra = Extra.forbid + + +App.update_forward_refs() +AppsResponse.update_forward_refs() +Platforms.update_forward_refs() + + +CallbackType = Literal[ + "rejected_reattribution", + "reattribution", + "att_consent", + "click", + "gdpr_forget_device", + "subscription_renewal", + "cost_update", + "install", + "sk_event", + "reattribution_reinstall", + "subscription_renewal_from_billing_retry", + "subscription_entered_billing_retry", + "subscription", + "session", + "sk_cv_update", + "subscription_cancellation", + "san_click", + "subscription_activation", + "impression", + "subscription_reactivation", + "ad_revenue", + "global", + "sk_install_direct", + "uninstall", + "sk_install", + "reinstall", + "rejected_install", + "attribution_update", + "san_impression", + "sk_qualifier", + "subscription_first_conversion", +] + + +class RawCallback(BaseModel): + id: CallbackType | int + name: str + url: str | None + custom: bool + token: str | None + + @property + def index(self) -> str: + return self.id if not isinstance(self.id, int) else self.name + + def to_callback(self) -> Callback: + urls = [CallbackURL.from_url(u) for u in self.url.split()] if self.url else [] + return Callback( + id=self.id, + type="event" if isinstance(self.id, int) else self.id, + name=self.name, + urls=urls, + custom=self.custom, + token=self.token, + ) + + +class Callback(BaseModel): + id: CallbackType | int + type: str + name: str + urls: list[CallbackURL] + custom: bool + token: str | None + + @property + def url(self) -> str | None: + return " ".join(u.url for u in self.urls) if self.urls else None + + @property + def unique_name(self) -> str: + """Returns the callback type for regular callbacks or the event name + for custom callbacks. + + + Returns: + str: the callback type or the event name if this is a custom callback + """ + return self.id if not isinstance(self.id, int) else self.name + + +S = TypeVar("S", bound="CallbackURL") + + +class CallbackURL(BaseModel): + scheme: str + netloc: str + path: str + query: dict[str, str] + fragment: str = "" + placeholders: list[str] + + @classmethod + def from_url(cls: Type[S], url_string: str) -> S: + def is_placeholder(e: tuple[str, str]) -> bool: + return e[1] == f"{{{e[0]}}}" + + url = urlsplit(url_string) + assert "://" not in url.query, f"Invalid URL: {url_string}" + qsl = parse_qsl(url.query, keep_blank_values=True) + query = sorted((e for e in qsl if not is_placeholder(e)), key=lambda e: e[0]) + placeholders = sorted(e[0] for e in qsl if is_placeholder(e)) + return cls( + scheme=url.scheme, + netloc=url.netloc, + path=url.path, + query=query, + fragment=url.fragment, + placeholders=placeholders, + ) + + @property + def url(self) -> str: + def to_tuple(placeholder: str) -> tuple[str, str]: + return (placeholder, f"{{{placeholder}}}") + + qsl1 = [(k, v) for k, v in self.query.items()] + qsl2 = [to_tuple(p) for p in sorted(self.placeholders)] + query = urlencode(qsl1 + qsl2, safe="/{}") + return urlunsplit((self.scheme, self.netloc, self.path, query, self.fragment)) + + def add_placeholder(self, placeholder: str) -> None: + index = bisect.bisect_left(self.placeholders, placeholder) + if index == len(self.placeholders) or self.placeholders[index] != placeholder: + self.placeholders.insert(index, placeholder) + + +Callback.update_forward_refs() + + +class Event(BaseModel): + id: int + name: str + token: str + callback_url: str | None + unique: bool | None + urls: EventURLs + autogenerated: bool + archived: bool + first_skad_data: date | None + + +class EventURLs(BaseModel): + archive: str | None = None + unarchive: str | None = None + + +Event.update_forward_refs() + + +class EventsResponse(BaseModel): + events: list[Event] + + +class User(BaseModel): + id: int + email: str + name: str | None + main_account_id: int + main_account_type: str + created_by: str | None + created_at: datetime + updated_at: datetime + authentication_token: str + locale: str # 'en' + uses_next: bool + api_access: None + first_name: str + last_name: str + super_admin: bool + salesforce_sync_failed: bool + ct_role: None + timezone_id: int + uses_dash: bool + sso: bool + direct_otp: None + direct_otp_sent_at: None + encrypted_otp_secret_key: None + encrypted_otp_secret_key_iv: None + encrypted_otp_secret_key_salt: None diff --git a/adjust/cli/__init__.py b/adjust/cli/__init__.py new file mode 100644 index 0000000..8390fac --- /dev/null +++ b/adjust/cli/__init__.py @@ -0,0 +1,4 @@ +from .cli import main, cli # noqa: F401 + +if __name__ == "__main__": + main() diff --git a/adjust/cli/cli.py b/adjust/cli/cli.py new file mode 100644 index 0000000..c41f0ea --- /dev/null +++ b/adjust/cli/cli.py @@ -0,0 +1,173 @@ +import re +import click +import dotenv +import os +import shutil + +from click_help_colors import HelpColorsGroup + +from adjust.snapshot import ( + snapshot_callback_count, + snapshot_diff, + snapshot_fetch, + snapshot_load, + snapshot_restore, + snapshot_save, + snapshot_write_callback, +) + +from ..api import AdjustAPI +from ..utils import AddCounters +from .types import REGEX + + +pass_api = click.make_pass_decorator(AdjustAPI) + + +@click.group(cls=HelpColorsGroup, help_headers_color="yellow", help_options_color="green", help="Adjust API CLI") +@click.pass_context +def cli(ctx: click.Context) -> None: + dotenv.load_dotenv() + ctx.obj = AdjustAPI() + + +@cli.group(help="Manage Adjust callback snapshots") +def snapshot() -> None: + pass + + +@snapshot.command(help="Create a local snapshot of all Adjust callbacks") +@click.option( + "--snapshot", + "-s", + "snapshot_path", + type=click.Path(exists=False, file_okay=False, dir_okay=True, writable=True), + default="snapshot", + show_default=True, + help="Snapshot path", +) +@click.option("--non-interactive", is_flag=True, help="Allow interaction with user") +@click.option("--force", "-f", is_flag=True, help="Overwrite existing snapshot") +@pass_api +def create( + api: AdjustAPI, + snapshot_path: str, + non_interactive: bool, + force: bool, +) -> None: + non_interactive = non_interactive or not os.isatty(0) + if os.path.exists(snapshot_path): + if not force: + if non_interactive: + raise click.ClickException(f"⛔️ Snapshot path `{snapshot_path}` already exists. Use --force to overwrite.") + click.confirm(f"⚠️ Snapshot path `{snapshot_path}` already exists. Overwrite?", abort=True) + shutil.rmtree(snapshot_path) + snapshot = snapshot_fetch(api, progress_desc="⬇️ Creating Snapshot") + snapshot_save(snapshot_path, snapshot) + callback_count = snapshot_callback_count(snapshot) + click.echo(f"✅ Done. Retrieved {callback_count} callbacks.") + + +@snapshot.command(help="Restore Adjust callbacks from local snapshot") +@click.option( + "--snapshot", + "-s", + "snapshot_path", + type=click.Path(exists=True, file_okay=False, dir_okay=True, readable=True), + default="snapshot", + show_default=True, + help="Snapshot path", +) +@click.option("--dry-run", "-n", is_flag=True, help="Do not invoke the Adjust API, only simulate what would be done.") +@pass_api +def restore(api: AdjustAPI, snapshot_path: str, dry_run: bool) -> None: + snapshot = snapshot_load(snapshot_path) + current_snapshot = snapshot_fetch(api, progress_desc="⬇️ Fetching Current Snapshot") + diff = snapshot_diff(current_snapshot, snapshot) + if not dry_run: + snapshot_restore(api, diff, progress_desc="⬆️ Restoring Snapshot") + num_callbacks = snapshot_callback_count(diff) + click.echo(f"✅ Done. {'Would have updated' if dry_run else 'Updated'} {num_callbacks} callbacks.") + + +@snapshot.command(help="Modify local snapshot") +@click.option( + "--snapshot", + "-s", + "snapshot_path", + type=click.Path(exists=True, file_okay=False, dir_okay=True, readable=True), + default="snapshot", + show_default=True, + help="Snapshot path", +) +@click.option("--having-placeholder", multiple=True, metavar="PH", help="Only modify callbacks having placeholder PH") +@click.option("--having-app", multiple=True, metavar="NAME", help="Only modify apps named NAME") +@click.option("--having-app-token", multiple=True, metavar="TOKEN", help="Only modify apps with token TOKEN") +@click.option("--having-domain", multiple=True, metavar="DOMAIN", help="Only modify callbacks whose URL domain is DOMAIN") +@click.option("--having-path", multiple=True, metavar="PATH", help="Only modify callbacks whose URL is PATH") +@click.option("--matching-placeholder", multiple=True, type=REGEX, help="Only modify callbacks having placeholders matching REGEX") +@click.option("--matching-app", multiple=True, type=REGEX, help="Only modify apps whose name match REGEX") +@click.option("--matching-domain", multiple=True, type=REGEX, help="Only modify callbacks whose URL domain matches REGEX") +@click.option("--matching-path", multiple=True, type=REGEX, help="Only modify callbacks whose URL matches REGEX") +@click.option("--add-placeholder", "-a", multiple=True, metavar="PH", help="Add placeholder PH to all matching callbacks") +@click.option("--dry-run", "-n", is_flag=True, help="Do not update the snapshot, only simulate what would be done.") +@pass_api +def modify( + api: AdjustAPI, + snapshot_path: str, + having_placeholder: list[str], + having_app: list[str], + having_app_token: list[str], + having_domain: list[str], + having_path: list[str], + matching_placeholder: list[re.Pattern], + matching_app: list[re.Pattern], + matching_domain: list[re.Pattern], + matching_path: list[re.Pattern], + add_placeholder: list[str], + dry_run: bool, +) -> None: + counters = AddCounters() + snapshot = snapshot_load(snapshot_path) + apps = {a.token: a for a in api.apps} if having_app or matching_app else {} + for app_token, callbacks in snapshot.items(): + if having_app_token and not any(app_token == t for t in having_app_token): + continue + app_name = apps[app_token].name if app_token in apps else "" + if having_app and not any(app_name == n for n in having_app): + continue + if matching_app and not any(regex.match(app_name) for regex in matching_app): + continue + for callback in callbacks: + modified = False + for url in callback.urls: + if having_placeholder and not any(ph in url.placeholders for ph in having_placeholder): + continue + if matching_placeholder and not any(regex.match(ph) for regex in matching_placeholder for ph in url.placeholders): + continue + if having_domain and not any(domain == url.netloc for domain in having_domain): + continue + if matching_domain and not any(domain.match(url.netloc) for domain in matching_domain): + continue + if having_path and not any(path == url.path for path in having_path): + continue + if matching_path and not any(path.match(url.path) for path in matching_path): + continue + for ph in add_placeholder: + url.add_placeholder(ph) + counters.urls += 1 + modified = True + if modified: + counters.apps_seen.add(app_token) + counters.callbacks += 1 + if not dry_run: + snapshot_write_callback(snapshot_path, app_token, callback) + if counters.urls == 0: + click.echo("⚠️ No URLs matched the pattern.") + else: + label = "Would have updated" if dry_run else "Updated" + click.echo(f"✅ Done. {label} {counters.urls} URLs in {counters.callbacks} callbacks across {counters.apps} apps.") + + +def main() -> None: + cli(max_content_width=120) diff --git a/adjust/cli/types.py b/adjust/cli/types.py new file mode 100644 index 0000000..569f8da --- /dev/null +++ b/adjust/cli/types.py @@ -0,0 +1,20 @@ +import click +import re + +from typing import Optional + + +class RegexType(click.ParamType): + name = "regex" + + def convert(self, arg: str, param: Optional[click.Parameter], ctx: Optional[click.Context]) -> re.Pattern: + try: + return re.compile(arg) + except Exception as e: + self.fail(f"Invalid regex: {e}", param, ctx) + + def get_metavar(self, param: click.Parameter) -> str | None: + return "REGEX" + + +REGEX = RegexType() diff --git a/adjust/snapshot.py b/adjust/snapshot.py new file mode 100644 index 0000000..7edb34b --- /dev/null +++ b/adjust/snapshot.py @@ -0,0 +1,143 @@ +import os +from typing import Optional +import yaml + +from .utils import progress_bar + +from .api.api import AdjustAPI +from .api.model import Callback + +Snapshot = dict[str, list[Callback]] + + +def snapshot_save(path: str, snapshot: Snapshot) -> None: + """ + Save the snapshot data to YAML files. + + Args: + path (str): The directory path where the snapshot files will be saved. + snapshot (Snapshot): The snapshot data to be saved. + + Returns: + None + """ + for app_token, callbacks in snapshot.items(): + for callback in callbacks: + snapshot_write_callback(path, app_token, callback) + + +def snapshot_write_callback(path: str, app_token: str, callback: Callback) -> None: + """ + Write a single callback to a YAML file. + + Args: + path (str): The snapshot path + app_token (str): The token of the app owning the callback. + callback (Callback): The callback object containing the data to be written. + + Returns: + None + """ + if callback.urls: + app_path = os.path.join(path, app_token) + os.makedirs(app_path, exist_ok=True) + filename = os.path.join(app_path, f"{callback.unique_name}.yaml") + with open(filename, "w") as file: + yaml.dump(callback.dict(), file, sort_keys=False) + + +def snapshot_load(path: str) -> Snapshot: + """ + Load a snapshot from the given path. + + Args: + path (str): The path to the directory containing the snapshot files. + + Returns: + Snapshot: The loaded snapshot as a dictionary. + + """ + snapshot: Snapshot = {} + for app_token in os.listdir(path): + app_path = os.path.join(path, app_token) + if os.path.isdir(app_path): + for filename in os.listdir(app_path): + file_path = os.path.join(app_path, filename) + if os.path.isfile(file_path): + with open(file_path) as file: + data = yaml.safe_load(file) + callback = Callback.parse_obj(data) + snapshot.setdefault(app_token, []).append(callback) + return normalize_snapshot(snapshot) + + +def snapshot_fetch(api: AdjustAPI, progress_desc: Optional[str] = None) -> Snapshot: + """ + Fetches a snapshot of all callbacks from all apps using the Adjust API. + + Args: + api (AdjustAPI): An instance of the AdjustAPI class. + progress_desc (Optional[str]): A description for the progress bar (default: None). + + Returns: + Snapshot: The fetched snapshot as a dictionary. + """ + snapshot: Snapshot = {} + for app in progress_bar(sorted(api.apps, key=lambda app: app.token), desc=progress_desc): + snapshot[app.token] = api.callbacks(app) + return normalize_snapshot(snapshot) + + +def snapshot_restore(api: AdjustAPI, snapshot: Snapshot, progress_desc: Optional[str] = None) -> None: + """ + Restores the callbacks snapshot by updating them using the Adjust API. + + Args: + api (AdjustAPI): An instance of the AdjustAPI class used to update the callbacks. + snapshot (Snapshot): The snapshot to restore. + progress_desc (Optional[str]): A description for the progress bar (default: None). + + Returns: + None + """ + ops = [(app_token, callback) for app_token, callbacks in snapshot.items() for callback in callbacks] + for app_token, callback in progress_bar(ops, progress_desc): + api.update_callback(app_token, callback) + + +def snapshot_diff(snapshot1: Snapshot, snapshot2: Snapshot) -> Snapshot: + """ + Compares two snapshots and returns the difference between them. + + Args: + snapshot1 (Snapshot): The first snapshot to compare. + snapshot2 (Snapshot): The second snapshot to compare. + + Returns: + Snapshot: A dictionary representing the difference between the two snapshots. + The keys of the dictionary are the app tokens, and the values are lists + of callbacks that are present in snapshot2 but not in snapshot1. + """ + diff: Snapshot = {} + for app_token in snapshot1.keys() | snapshot2.keys(): + callbacks1 = snapshot1.get(app_token, []) + callbacks2 = snapshot2.get(app_token, []) + diff[app_token] = [callback for callback in callbacks2 if callback not in callbacks1] + return normalize_snapshot(diff) + + +def snapshot_callback_count(snapshot: Snapshot) -> int: + """ + Calculates the total number of callbacks in the given snapshot. + + Args: + snapshot (Snapshot): The snapshot containing the callbacks. + + Returns: + int: The total number of callbacks in the snapshot. + """ + return sum(len(callbacks) for callbacks in snapshot.values()) + + +def normalize_snapshot(snapshot: Snapshot) -> Snapshot: + return {app_token: sorted(callbacks, key=lambda c: c.unique_name) for app_token, callbacks in snapshot.items() if callbacks} diff --git a/adjust/utils.py b/adjust/utils.py new file mode 100644 index 0000000..77365e6 --- /dev/null +++ b/adjust/utils.py @@ -0,0 +1,24 @@ +import os + +from pydantic import BaseModel +from tqdm import tqdm +from typing import Iterable, Optional, TypeVar + + +class AddCounters(BaseModel): + apps_seen: set[str] = set() + callbacks: int = 0 + urls: int = 0 + + @property + def apps(self) -> int: + return len(self.apps_seen) + + +T = TypeVar("T") + + +def progress_bar(iterable: Iterable[T], desc: Optional[str] = None) -> Iterable[T]: + if desc is None or not os.isatty(1): + return iterable + return tqdm(iterable, desc=desc) diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 0000000..786b620 --- /dev/null +++ b/poetry.lock @@ -0,0 +1,731 @@ +# This file is automatically @generated by Poetry 1.5.0 and should not be changed by hand. + +[[package]] +name = "beautifulsoup4" +version = "4.12.3" +description = "Screen-scraping library" +optional = false +python-versions = ">=3.6.0" +files = [ + {file = "beautifulsoup4-4.12.3-py3-none-any.whl", hash = "sha256:b80878c9f40111313e55da8ba20bdba06d8fa3969fc68304167741bbf9e082ed"}, + {file = "beautifulsoup4-4.12.3.tar.gz", hash = "sha256:74e3d1928edc070d21748185c46e3fb33490f22f52a3addee9aee0f4f7781051"}, +] + +[package.dependencies] +soupsieve = ">1.2" + +[package.extras] +cchardet = ["cchardet"] +chardet = ["chardet"] +charset-normalizer = ["charset-normalizer"] +html5lib = ["html5lib"] +lxml = ["lxml"] + +[[package]] +name = "certifi" +version = "2024.2.2" +description = "Python package for providing Mozilla's CA Bundle." +optional = false +python-versions = ">=3.6" +files = [ + {file = "certifi-2024.2.2-py3-none-any.whl", hash = "sha256:dc383c07b76109f368f6106eee2b593b04a011ea4d55f652c6ca24a754d1cdd1"}, + {file = "certifi-2024.2.2.tar.gz", hash = "sha256:0569859f95fc761b18b45ef421b1290a0f65f147e92a1e5eb3e635f9a5e4e66f"}, +] + +[[package]] +name = "charset-normalizer" +version = "3.3.2" +description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." +optional = false +python-versions = ">=3.7.0" +files = [ + {file = "charset-normalizer-3.3.2.tar.gz", hash = "sha256:f30c3cb33b24454a82faecaf01b19c18562b1e89558fb6c56de4d9118a032fd5"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:25baf083bf6f6b341f4121c2f3c548875ee6f5339300e08be3f2b2ba1721cdd3"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:06435b539f889b1f6f4ac1758871aae42dc3a8c0e24ac9e60c2384973ad73027"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9063e24fdb1e498ab71cb7419e24622516c4a04476b17a2dab57e8baa30d6e03"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6897af51655e3691ff853668779c7bad41579facacf5fd7253b0133308cf000d"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1d3193f4a680c64b4b6a9115943538edb896edc190f0b222e73761716519268e"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd70574b12bb8a4d2aaa0094515df2463cb429d8536cfb6c7ce983246983e5a6"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8465322196c8b4d7ab6d1e049e4c5cb460d0394da4a27d23cc242fbf0034b6b5"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a9a8e9031d613fd2009c182b69c7b2c1ef8239a0efb1df3f7c8da66d5dd3d537"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:beb58fe5cdb101e3a055192ac291b7a21e3b7ef4f67fa1d74e331a7f2124341c"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:e06ed3eb3218bc64786f7db41917d4e686cc4856944f53d5bdf83a6884432e12"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:2e81c7b9c8979ce92ed306c249d46894776a909505d8f5a4ba55b14206e3222f"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:572c3763a264ba47b3cf708a44ce965d98555f618ca42c926a9c1616d8f34269"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fd1abc0d89e30cc4e02e4064dc67fcc51bd941eb395c502aac3ec19fab46b519"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-win32.whl", hash = "sha256:3d47fa203a7bd9c5b6cee4736ee84ca03b8ef23193c0d1ca99b5089f72645c73"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:10955842570876604d404661fbccbc9c7e684caf432c09c715ec38fbae45ae09"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:802fe99cca7457642125a8a88a084cef28ff0cf9407060f7b93dca5aa25480db"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:573f6eac48f4769d667c4442081b1794f52919e7edada77495aaed9236d13a96"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:549a3a73da901d5bc3ce8d24e0600d1fa85524c10287f6004fbab87672bf3e1e"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f27273b60488abe721a075bcca6d7f3964f9f6f067c8c4c605743023d7d3944f"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ceae2f17a9c33cb48e3263960dc5fc8005351ee19db217e9b1bb15d28c02574"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:65f6f63034100ead094b8744b3b97965785388f308a64cf8d7c34f2f2e5be0c4"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:753f10e867343b4511128c6ed8c82f7bec3bd026875576dfd88483c5c73b2fd8"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4a78b2b446bd7c934f5dcedc588903fb2f5eec172f3d29e52a9096a43722adfc"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e537484df0d8f426ce2afb2d0f8e1c3d0b114b83f8850e5f2fbea0e797bd82ae"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:eb6904c354526e758fda7167b33005998fb68c46fbc10e013ca97f21ca5c8887"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:deb6be0ac38ece9ba87dea880e438f25ca3eddfac8b002a2ec3d9183a454e8ae"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:4ab2fe47fae9e0f9dee8c04187ce5d09f48eabe611be8259444906793ab7cbce"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:80402cd6ee291dcb72644d6eac93785fe2c8b9cb30893c1af5b8fdd753b9d40f"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-win32.whl", hash = "sha256:7cd13a2e3ddeed6913a65e66e94b51d80a041145a026c27e6bb76c31a853c6ab"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:663946639d296df6a2bb2aa51b60a2454ca1cb29835324c640dafb5ff2131a77"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:0b2b64d2bb6d3fb9112bafa732def486049e63de9618b5843bcdd081d8144cd8"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:ddbb2551d7e0102e7252db79ba445cdab71b26640817ab1e3e3648dad515003b"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:55086ee1064215781fff39a1af09518bc9255b50d6333f2e4c74ca09fac6a8f6"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f4a014bc36d3c57402e2977dada34f9c12300af536839dc38c0beab8878f38a"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a10af20b82360ab00827f916a6058451b723b4e65030c5a18577c8b2de5b3389"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8d756e44e94489e49571086ef83b2bb8ce311e730092d2c34ca8f7d925cb20aa"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90d558489962fd4918143277a773316e56c72da56ec7aa3dc3dbbe20fdfed15b"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6ac7ffc7ad6d040517be39eb591cac5ff87416c2537df6ba3cba3bae290c0fed"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:7ed9e526742851e8d5cc9e6cf41427dfc6068d4f5a3bb03659444b4cabf6bc26"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:8bdb58ff7ba23002a4c5808d608e4e6c687175724f54a5dade5fa8c67b604e4d"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:6b3251890fff30ee142c44144871185dbe13b11bab478a88887a639655be1068"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:b4a23f61ce87adf89be746c8a8974fe1c823c891d8f86eb218bb957c924bb143"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:efcb3f6676480691518c177e3b465bcddf57cea040302f9f4e6e191af91174d4"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-win32.whl", hash = "sha256:d965bba47ddeec8cd560687584e88cf699fd28f192ceb452d1d7ee807c5597b7"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:96b02a3dc4381e5494fad39be677abcb5e6634bf7b4fa83a6dd3112607547001"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:95f2a5796329323b8f0512e09dbb7a1860c46a39da62ecb2324f116fa8fdc85c"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c002b4ffc0be611f0d9da932eb0f704fe2602a9a949d1f738e4c34c75b0863d5"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a981a536974bbc7a512cf44ed14938cf01030a99e9b3a06dd59578882f06f985"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3287761bc4ee9e33561a7e058c72ac0938c4f57fe49a09eae428fd88aafe7bb6"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:42cb296636fcc8b0644486d15c12376cb9fa75443e00fb25de0b8602e64c1714"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a55554a2fa0d408816b3b5cedf0045f4b8e1a6065aec45849de2d6f3f8e9786"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:c083af607d2515612056a31f0a8d9e0fcb5876b7bfc0abad3ecd275bc4ebc2d5"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:87d1351268731db79e0f8e745d92493ee2841c974128ef629dc518b937d9194c"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:bd8f7df7d12c2db9fab40bdd87a7c09b1530128315d047a086fa3ae3435cb3a8"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:c180f51afb394e165eafe4ac2936a14bee3eb10debc9d9e4db8958fe36afe711"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:8c622a5fe39a48f78944a87d4fb8a53ee07344641b0562c540d840748571b811"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-win32.whl", hash = "sha256:db364eca23f876da6f9e16c9da0df51aa4f104a972735574842618b8c6d999d4"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-win_amd64.whl", hash = "sha256:86216b5cee4b06df986d214f664305142d9c76df9b6512be2738aa72a2048f99"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:6463effa3186ea09411d50efc7d85360b38d5f09b870c48e4600f63af490e56a"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6c4caeef8fa63d06bd437cd4bdcf3ffefe6738fb1b25951440d80dc7df8c03ac"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:37e55c8e51c236f95b033f6fb391d7d7970ba5fe7ff453dad675e88cf303377a"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fb69256e180cb6c8a894fee62b3afebae785babc1ee98b81cdf68bbca1987f33"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ae5f4161f18c61806f411a13b0310bea87f987c7d2ecdbdaad0e94eb2e404238"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b2b0a0c0517616b6869869f8c581d4eb2dd83a4d79e0ebcb7d373ef9956aeb0a"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:45485e01ff4d3630ec0d9617310448a8702f70e9c01906b0d0118bdf9d124cf2"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eb00ed941194665c332bf8e078baf037d6c35d7c4f3102ea2d4f16ca94a26dc8"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:2127566c664442652f024c837091890cb1942c30937add288223dc895793f898"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:a50aebfa173e157099939b17f18600f72f84eed3049e743b68ad15bd69b6bf99"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:4d0d1650369165a14e14e1e47b372cfcb31d6ab44e6e33cb2d4e57265290044d"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:923c0c831b7cfcb071580d3f46c4baf50f174be571576556269530f4bbd79d04"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:06a81e93cd441c56a9b65d8e1d043daeb97a3d0856d177d5c90ba85acb3db087"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-win32.whl", hash = "sha256:6ef1d82a3af9d3eecdba2321dc1b3c238245d890843e040e41e470ffa64c3e25"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-win_amd64.whl", hash = "sha256:eb8821e09e916165e160797a6c17edda0679379a4be5c716c260e836e122f54b"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:c235ebd9baae02f1b77bcea61bce332cb4331dc3617d254df3323aa01ab47bd4"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5b4c145409bef602a690e7cfad0a15a55c13320ff7a3ad7ca59c13bb8ba4d45d"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:68d1f8a9e9e37c1223b656399be5d6b448dea850bed7d0f87a8311f1ff3dabb0"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22afcb9f253dac0696b5a4be4a1c0f8762f8239e21b99680099abd9b2b1b2269"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e27ad930a842b4c5eb8ac0016b0a54f5aebbe679340c26101df33424142c143c"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1f79682fbe303db92bc2b1136016a38a42e835d932bab5b3b1bfcfbf0640e519"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b261ccdec7821281dade748d088bb6e9b69e6d15b30652b74cbbac25e280b796"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:122c7fa62b130ed55f8f285bfd56d5f4b4a5b503609d181f9ad85e55c89f4185"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:d0eccceffcb53201b5bfebb52600a5fb483a20b61da9dbc885f8b103cbe7598c"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:9f96df6923e21816da7e0ad3fd47dd8f94b2a5ce594e00677c0013018b813458"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:7f04c839ed0b6b98b1a7501a002144b76c18fb1c1850c8b98d458ac269e26ed2"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:34d1c8da1e78d2e001f363791c98a272bb734000fcef47a491c1e3b0505657a8"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ff8fa367d09b717b2a17a052544193ad76cd49979c805768879cb63d9ca50561"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-win32.whl", hash = "sha256:aed38f6e4fb3f5d6bf81bfa990a07806be9d83cf7bacef998ab1a9bd660a581f"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-win_amd64.whl", hash = "sha256:b01b88d45a6fcb69667cd6d2f7a9aeb4bf53760d7fc536bf679ec94fe9f3ff3d"}, + {file = "charset_normalizer-3.3.2-py3-none-any.whl", hash = "sha256:3e4d1f6587322d2788836a99c69062fbb091331ec940e02d12d179c1d53e25fc"}, +] + +[[package]] +name = "click" +version = "8.1.7" +description = "Composable command line interface toolkit" +optional = false +python-versions = ">=3.7" +files = [ + {file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"}, + {file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + +[[package]] +name = "click-help-colors" +version = "0.9.4" +description = "Colorization of help messages in Click" +optional = false +python-versions = "*" +files = [ + {file = "click-help-colors-0.9.4.tar.gz", hash = "sha256:f4cabe52cf550299b8888f4f2ee4c5f359ac27e33bcfe4d61db47785a5cc936c"}, + {file = "click_help_colors-0.9.4-py3-none-any.whl", hash = "sha256:b33c5803eeaeb084393b1ab5899dc5476c7196b87a18713045afe76f840b42db"}, +] + +[package.dependencies] +click = ">=7.0,<9" + +[package.extras] +dev = ["mypy", "pytest"] + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] + +[[package]] +name = "coverage" +version = "7.5.1" +description = "Code coverage measurement for Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "coverage-7.5.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c0884920835a033b78d1c73b6d3bbcda8161a900f38a488829a83982925f6c2e"}, + {file = "coverage-7.5.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:39afcd3d4339329c5f58de48a52f6e4e50f6578dd6099961cf22228feb25f38f"}, + {file = "coverage-7.5.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4a7b0ceee8147444347da6a66be737c9d78f3353b0681715b668b72e79203e4a"}, + {file = "coverage-7.5.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4a9ca3f2fae0088c3c71d743d85404cec8df9be818a005ea065495bedc33da35"}, + {file = "coverage-7.5.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5fd215c0c7d7aab005221608a3c2b46f58c0285a819565887ee0b718c052aa4e"}, + {file = "coverage-7.5.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:4bf0655ab60d754491004a5efd7f9cccefcc1081a74c9ef2da4735d6ee4a6223"}, + {file = "coverage-7.5.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:61c4bf1ba021817de12b813338c9be9f0ad5b1e781b9b340a6d29fc13e7c1b5e"}, + {file = "coverage-7.5.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:db66fc317a046556a96b453a58eced5024af4582a8dbdc0c23ca4dbc0d5b3146"}, + {file = "coverage-7.5.1-cp310-cp310-win32.whl", hash = "sha256:b016ea6b959d3b9556cb401c55a37547135a587db0115635a443b2ce8f1c7228"}, + {file = "coverage-7.5.1-cp310-cp310-win_amd64.whl", hash = "sha256:df4e745a81c110e7446b1cc8131bf986157770fa405fe90e15e850aaf7619bc8"}, + {file = "coverage-7.5.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:796a79f63eca8814ca3317a1ea443645c9ff0d18b188de470ed7ccd45ae79428"}, + {file = "coverage-7.5.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4fc84a37bfd98db31beae3c2748811a3fa72bf2007ff7902f68746d9757f3746"}, + {file = "coverage-7.5.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6175d1a0559986c6ee3f7fccfc4a90ecd12ba0a383dcc2da30c2b9918d67d8a3"}, + {file = "coverage-7.5.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1fc81d5878cd6274ce971e0a3a18a8803c3fe25457165314271cf78e3aae3aa2"}, + {file = "coverage-7.5.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:556cf1a7cbc8028cb60e1ff0be806be2eded2daf8129b8811c63e2b9a6c43bca"}, + {file = "coverage-7.5.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:9981706d300c18d8b220995ad22627647be11a4276721c10911e0e9fa44c83e8"}, + {file = "coverage-7.5.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:d7fed867ee50edf1a0b4a11e8e5d0895150e572af1cd6d315d557758bfa9c057"}, + {file = "coverage-7.5.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:ef48e2707fb320c8f139424a596f5b69955a85b178f15af261bab871873bb987"}, + {file = "coverage-7.5.1-cp311-cp311-win32.whl", hash = "sha256:9314d5678dcc665330df5b69c1e726a0e49b27df0461c08ca12674bcc19ef136"}, + {file = "coverage-7.5.1-cp311-cp311-win_amd64.whl", hash = "sha256:5fa567e99765fe98f4e7d7394ce623e794d7cabb170f2ca2ac5a4174437e90dd"}, + {file = "coverage-7.5.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:b6cf3764c030e5338e7f61f95bd21147963cf6aa16e09d2f74f1fa52013c1206"}, + {file = "coverage-7.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2ec92012fefebee89a6b9c79bc39051a6cb3891d562b9270ab10ecfdadbc0c34"}, + {file = "coverage-7.5.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:16db7f26000a07efcf6aea00316f6ac57e7d9a96501e990a36f40c965ec7a95d"}, + {file = "coverage-7.5.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:beccf7b8a10b09c4ae543582c1319c6df47d78fd732f854ac68d518ee1fb97fa"}, + {file = "coverage-7.5.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8748731ad392d736cc9ccac03c9845b13bb07d020a33423fa5b3a36521ac6e4e"}, + {file = "coverage-7.5.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:7352b9161b33fd0b643ccd1f21f3a3908daaddf414f1c6cb9d3a2fd618bf2572"}, + {file = "coverage-7.5.1-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:7a588d39e0925f6a2bff87154752481273cdb1736270642aeb3635cb9b4cad07"}, + {file = "coverage-7.5.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:68f962d9b72ce69ea8621f57551b2fa9c70509af757ee3b8105d4f51b92b41a7"}, + {file = "coverage-7.5.1-cp312-cp312-win32.whl", hash = "sha256:f152cbf5b88aaeb836127d920dd0f5e7edff5a66f10c079157306c4343d86c19"}, + {file = "coverage-7.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:5a5740d1fb60ddf268a3811bcd353de34eb56dc24e8f52a7f05ee513b2d4f596"}, + {file = "coverage-7.5.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:e2213def81a50519d7cc56ed643c9e93e0247f5bbe0d1247d15fa520814a7cd7"}, + {file = "coverage-7.5.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:5037f8fcc2a95b1f0e80585bd9d1ec31068a9bcb157d9750a172836e98bc7a90"}, + {file = "coverage-7.5.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5c3721c2c9e4c4953a41a26c14f4cef64330392a6d2d675c8b1db3b645e31f0e"}, + {file = "coverage-7.5.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ca498687ca46a62ae590253fba634a1fe9836bc56f626852fb2720f334c9e4e5"}, + {file = "coverage-7.5.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0cdcbc320b14c3e5877ee79e649677cb7d89ef588852e9583e6b24c2e5072661"}, + {file = "coverage-7.5.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:57e0204b5b745594e5bc14b9b50006da722827f0b8c776949f1135677e88d0b8"}, + {file = "coverage-7.5.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:8fe7502616b67b234482c3ce276ff26f39ffe88adca2acf0261df4b8454668b4"}, + {file = "coverage-7.5.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:9e78295f4144f9dacfed4f92935fbe1780021247c2fabf73a819b17f0ccfff8d"}, + {file = "coverage-7.5.1-cp38-cp38-win32.whl", hash = "sha256:1434e088b41594baa71188a17533083eabf5609e8e72f16ce8c186001e6b8c41"}, + {file = "coverage-7.5.1-cp38-cp38-win_amd64.whl", hash = "sha256:0646599e9b139988b63704d704af8e8df7fa4cbc4a1f33df69d97f36cb0a38de"}, + {file = "coverage-7.5.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:4cc37def103a2725bc672f84bd939a6fe4522310503207aae4d56351644682f1"}, + {file = "coverage-7.5.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:fc0b4d8bfeabd25ea75e94632f5b6e047eef8adaed0c2161ada1e922e7f7cece"}, + {file = "coverage-7.5.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d0a0f5e06881ecedfe6f3dd2f56dcb057b6dbeb3327fd32d4b12854df36bf26"}, + {file = "coverage-7.5.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9735317685ba6ec7e3754798c8871c2f49aa5e687cc794a0b1d284b2389d1bd5"}, + {file = "coverage-7.5.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d21918e9ef11edf36764b93101e2ae8cc82aa5efdc7c5a4e9c6c35a48496d601"}, + {file = "coverage-7.5.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:c3e757949f268364b96ca894b4c342b41dc6f8f8b66c37878aacef5930db61be"}, + {file = "coverage-7.5.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:79afb6197e2f7f60c4824dd4b2d4c2ec5801ceb6ba9ce5d2c3080e5660d51a4f"}, + {file = "coverage-7.5.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:d1d0d98d95dd18fe29dc66808e1accf59f037d5716f86a501fc0256455219668"}, + {file = "coverage-7.5.1-cp39-cp39-win32.whl", hash = "sha256:1cc0fe9b0b3a8364093c53b0b4c0c2dd4bb23acbec4c9240b5f284095ccf7981"}, + {file = "coverage-7.5.1-cp39-cp39-win_amd64.whl", hash = "sha256:dde0070c40ea8bb3641e811c1cfbf18e265d024deff6de52c5950677a8fb1e0f"}, + {file = "coverage-7.5.1-pp38.pp39.pp310-none-any.whl", hash = "sha256:6537e7c10cc47c595828b8a8be04c72144725c383c4702703ff4e42e44577312"}, + {file = "coverage-7.5.1.tar.gz", hash = "sha256:54de9ef3a9da981f7af93eafde4ede199e0846cd819eb27c88e2b712aae9708c"}, +] + +[package.extras] +toml = ["tomli"] + +[[package]] +name = "gitignore-parser" +version = "0.1.11" +description = "A spec-compliant gitignore parser for Python 3.5+" +optional = false +python-versions = "*" +files = [ + {file = "gitignore_parser-0.1.11.tar.gz", hash = "sha256:fa10fde48b44888eeefac096f53bcdad9b87a4ffd7db788558dbdf71ff3bc9db"}, +] + +[[package]] +name = "idna" +version = "3.7" +description = "Internationalized Domain Names in Applications (IDNA)" +optional = false +python-versions = ">=3.5" +files = [ + {file = "idna-3.7-py3-none-any.whl", hash = "sha256:82fee1fc78add43492d3a1898bfa6d8a904cc97d8427f683ed8e798d07761aa0"}, + {file = "idna-3.7.tar.gz", hash = "sha256:028ff3aadf0609c1fd278d8ea3089299412a7a8b9bd005dd08b9f8285bcb5cfc"}, +] + +[[package]] +name = "iniconfig" +version = "2.0.0" +description = "brain-dead simple config-ini parsing" +optional = false +python-versions = ">=3.7" +files = [ + {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, + {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, +] + +[[package]] +name = "lxml" +version = "4.9.4" +description = "Powerful and Pythonic XML processing library combining libxml2/libxslt with the ElementTree API." +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, != 3.4.*" +files = [ + {file = "lxml-4.9.4-cp27-cp27m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e214025e23db238805a600f1f37bf9f9a15413c7bf5f9d6ae194f84980c78722"}, + {file = "lxml-4.9.4-cp27-cp27m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:ec53a09aee61d45e7dbe7e91252ff0491b6b5fee3d85b2d45b173d8ab453efc1"}, + {file = "lxml-4.9.4-cp27-cp27m-win32.whl", hash = "sha256:7d1d6c9e74c70ddf524e3c09d9dc0522aba9370708c2cb58680ea40174800013"}, + {file = "lxml-4.9.4-cp27-cp27m-win_amd64.whl", hash = "sha256:cb53669442895763e61df5c995f0e8361b61662f26c1b04ee82899c2789c8f69"}, + {file = "lxml-4.9.4-cp27-cp27mu-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:647bfe88b1997d7ae8d45dabc7c868d8cb0c8412a6e730a7651050b8c7289cf2"}, + {file = "lxml-4.9.4-cp27-cp27mu-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:4d973729ce04784906a19108054e1fd476bc85279a403ea1a72fdb051c76fa48"}, + {file = "lxml-4.9.4-cp310-cp310-macosx_11_0_x86_64.whl", hash = "sha256:056a17eaaf3da87a05523472ae84246f87ac2f29a53306466c22e60282e54ff8"}, + {file = "lxml-4.9.4-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:aaa5c173a26960fe67daa69aa93d6d6a1cd714a6eb13802d4e4bd1d24a530644"}, + {file = "lxml-4.9.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:647459b23594f370c1c01768edaa0ba0959afc39caeeb793b43158bb9bb6a663"}, + {file = "lxml-4.9.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:bdd9abccd0927673cffe601d2c6cdad1c9321bf3437a2f507d6b037ef91ea307"}, + {file = "lxml-4.9.4-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:00e91573183ad273e242db5585b52670eddf92bacad095ce25c1e682da14ed91"}, + {file = "lxml-4.9.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:a602ed9bd2c7d85bd58592c28e101bd9ff9c718fbde06545a70945ffd5d11868"}, + {file = "lxml-4.9.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:de362ac8bc962408ad8fae28f3967ce1a262b5d63ab8cefb42662566737f1dc7"}, + {file = "lxml-4.9.4-cp310-cp310-win32.whl", hash = "sha256:33714fcf5af4ff7e70a49731a7cc8fd9ce910b9ac194f66eaa18c3cc0a4c02be"}, + {file = "lxml-4.9.4-cp310-cp310-win_amd64.whl", hash = "sha256:d3caa09e613ece43ac292fbed513a4bce170681a447d25ffcbc1b647d45a39c5"}, + {file = "lxml-4.9.4-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:359a8b09d712df27849e0bcb62c6a3404e780b274b0b7e4c39a88826d1926c28"}, + {file = "lxml-4.9.4-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:43498ea734ccdfb92e1886dfedaebeb81178a241d39a79d5351ba2b671bff2b2"}, + {file = "lxml-4.9.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:4855161013dfb2b762e02b3f4d4a21cc7c6aec13c69e3bffbf5022b3e708dd97"}, + {file = "lxml-4.9.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:c71b5b860c5215fdbaa56f715bc218e45a98477f816b46cfde4a84d25b13274e"}, + {file = "lxml-4.9.4-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:9a2b5915c333e4364367140443b59f09feae42184459b913f0f41b9fed55794a"}, + {file = "lxml-4.9.4-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:d82411dbf4d3127b6cde7da0f9373e37ad3a43e89ef374965465928f01c2b979"}, + {file = "lxml-4.9.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:273473d34462ae6e97c0f4e517bd1bf9588aa67a1d47d93f760a1282640e24ac"}, + {file = "lxml-4.9.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:389d2b2e543b27962990ab529ac6720c3dded588cc6d0f6557eec153305a3622"}, + {file = "lxml-4.9.4-cp311-cp311-win32.whl", hash = "sha256:8aecb5a7f6f7f8fe9cac0bcadd39efaca8bbf8d1bf242e9f175cbe4c925116c3"}, + {file = "lxml-4.9.4-cp311-cp311-win_amd64.whl", hash = "sha256:c7721a3ef41591341388bb2265395ce522aba52f969d33dacd822da8f018aff8"}, + {file = "lxml-4.9.4-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:dbcb2dc07308453db428a95a4d03259bd8caea97d7f0776842299f2d00c72fc8"}, + {file = "lxml-4.9.4-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:01bf1df1db327e748dcb152d17389cf6d0a8c5d533ef9bab781e9d5037619229"}, + {file = "lxml-4.9.4-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:e8f9f93a23634cfafbad6e46ad7d09e0f4a25a2400e4a64b1b7b7c0fbaa06d9d"}, + {file = "lxml-4.9.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:3f3f00a9061605725df1816f5713d10cd94636347ed651abdbc75828df302b20"}, + {file = "lxml-4.9.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:953dd5481bd6252bd480d6ec431f61d7d87fdcbbb71b0d2bdcfc6ae00bb6fb10"}, + {file = "lxml-4.9.4-cp312-cp312-win32.whl", hash = "sha256:266f655d1baff9c47b52f529b5f6bec33f66042f65f7c56adde3fcf2ed62ae8b"}, + {file = "lxml-4.9.4-cp312-cp312-win_amd64.whl", hash = "sha256:f1faee2a831fe249e1bae9cbc68d3cd8a30f7e37851deee4d7962b17c410dd56"}, + {file = "lxml-4.9.4-cp35-cp35m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:23d891e5bdc12e2e506e7d225d6aa929e0a0368c9916c1fddefab88166e98b20"}, + {file = "lxml-4.9.4-cp35-cp35m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:e96a1788f24d03e8d61679f9881a883ecdf9c445a38f9ae3f3f193ab6c591c66"}, + {file = "lxml-4.9.4-cp36-cp36m-macosx_11_0_x86_64.whl", hash = "sha256:5557461f83bb7cc718bc9ee1f7156d50e31747e5b38d79cf40f79ab1447afd2d"}, + {file = "lxml-4.9.4-cp36-cp36m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:fdb325b7fba1e2c40b9b1db407f85642e32404131c08480dd652110fc908561b"}, + {file = "lxml-4.9.4-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3d74d4a3c4b8f7a1f676cedf8e84bcc57705a6d7925e6daef7a1e54ae543a197"}, + {file = "lxml-4.9.4-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:ac7674d1638df129d9cb4503d20ffc3922bd463c865ef3cb412f2c926108e9a4"}, + {file = "lxml-4.9.4-cp36-cp36m-manylinux_2_28_x86_64.whl", hash = "sha256:ddd92e18b783aeb86ad2132d84a4b795fc5ec612e3545c1b687e7747e66e2b53"}, + {file = "lxml-4.9.4-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2bd9ac6e44f2db368ef8986f3989a4cad3de4cd55dbdda536e253000c801bcc7"}, + {file = "lxml-4.9.4-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:bc354b1393dce46026ab13075f77b30e40b61b1a53e852e99d3cc5dd1af4bc85"}, + {file = "lxml-4.9.4-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:f836f39678cb47c9541f04d8ed4545719dc31ad850bf1832d6b4171e30d65d23"}, + {file = "lxml-4.9.4-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:9c131447768ed7bc05a02553d939e7f0e807e533441901dd504e217b76307745"}, + {file = "lxml-4.9.4-cp36-cp36m-win32.whl", hash = "sha256:bafa65e3acae612a7799ada439bd202403414ebe23f52e5b17f6ffc2eb98c2be"}, + {file = "lxml-4.9.4-cp36-cp36m-win_amd64.whl", hash = "sha256:6197c3f3c0b960ad033b9b7d611db11285bb461fc6b802c1dd50d04ad715c225"}, + {file = "lxml-4.9.4-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:7b378847a09d6bd46047f5f3599cdc64fcb4cc5a5a2dd0a2af610361fbe77b16"}, + {file = "lxml-4.9.4-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:1343df4e2e6e51182aad12162b23b0a4b3fd77f17527a78c53f0f23573663545"}, + {file = "lxml-4.9.4-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:6dbdacf5752fbd78ccdb434698230c4f0f95df7dd956d5f205b5ed6911a1367c"}, + {file = "lxml-4.9.4-cp37-cp37m-manylinux_2_28_x86_64.whl", hash = "sha256:506becdf2ecaebaf7f7995f776394fcc8bd8a78022772de66677c84fb02dd33d"}, + {file = "lxml-4.9.4-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ca8e44b5ba3edb682ea4e6185b49661fc22b230cf811b9c13963c9f982d1d964"}, + {file = "lxml-4.9.4-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:9d9d5726474cbbef279fd709008f91a49c4f758bec9c062dfbba88eab00e3ff9"}, + {file = "lxml-4.9.4-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:bbdd69e20fe2943b51e2841fc1e6a3c1de460d630f65bde12452d8c97209464d"}, + {file = "lxml-4.9.4-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:8671622256a0859f5089cbe0ce4693c2af407bc053dcc99aadff7f5310b4aa02"}, + {file = "lxml-4.9.4-cp37-cp37m-win32.whl", hash = "sha256:dd4fda67f5faaef4f9ee5383435048ee3e11ad996901225ad7615bc92245bc8e"}, + {file = "lxml-4.9.4-cp37-cp37m-win_amd64.whl", hash = "sha256:6bee9c2e501d835f91460b2c904bc359f8433e96799f5c2ff20feebd9bb1e590"}, + {file = "lxml-4.9.4-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:1f10f250430a4caf84115b1e0f23f3615566ca2369d1962f82bef40dd99cd81a"}, + {file = "lxml-4.9.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:3b505f2bbff50d261176e67be24e8909e54b5d9d08b12d4946344066d66b3e43"}, + {file = "lxml-4.9.4-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:1449f9451cd53e0fd0a7ec2ff5ede4686add13ac7a7bfa6988ff6d75cff3ebe2"}, + {file = "lxml-4.9.4-cp38-cp38-manylinux_2_28_x86_64.whl", hash = "sha256:4ece9cca4cd1c8ba889bfa67eae7f21d0d1a2e715b4d5045395113361e8c533d"}, + {file = "lxml-4.9.4-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:59bb5979f9941c61e907ee571732219fa4774d5a18f3fa5ff2df963f5dfaa6bc"}, + {file = "lxml-4.9.4-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:b1980dbcaad634fe78e710c8587383e6e3f61dbe146bcbfd13a9c8ab2d7b1192"}, + {file = "lxml-4.9.4-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:9ae6c3363261021144121427b1552b29e7b59de9d6a75bf51e03bc072efb3c37"}, + {file = "lxml-4.9.4-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:bcee502c649fa6351b44bb014b98c09cb00982a475a1912a9881ca28ab4f9cd9"}, + {file = "lxml-4.9.4-cp38-cp38-win32.whl", hash = "sha256:a8edae5253efa75c2fc79a90068fe540b197d1c7ab5803b800fccfe240eed33c"}, + {file = "lxml-4.9.4-cp38-cp38-win_amd64.whl", hash = "sha256:701847a7aaefef121c5c0d855b2affa5f9bd45196ef00266724a80e439220e46"}, + {file = "lxml-4.9.4-cp39-cp39-macosx_11_0_x86_64.whl", hash = "sha256:f610d980e3fccf4394ab3806de6065682982f3d27c12d4ce3ee46a8183d64a6a"}, + {file = "lxml-4.9.4-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:aa9b5abd07f71b081a33115d9758ef6077924082055005808f68feccb27616bd"}, + {file = "lxml-4.9.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:365005e8b0718ea6d64b374423e870648ab47c3a905356ab6e5a5ff03962b9a9"}, + {file = "lxml-4.9.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:16b9ec51cc2feab009e800f2c6327338d6ee4e752c76e95a35c4465e80390ccd"}, + {file = "lxml-4.9.4-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:a905affe76f1802edcac554e3ccf68188bea16546071d7583fb1b693f9cf756b"}, + {file = "lxml-4.9.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fd814847901df6e8de13ce69b84c31fc9b3fb591224d6762d0b256d510cbf382"}, + {file = "lxml-4.9.4-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:91bbf398ac8bb7d65a5a52127407c05f75a18d7015a270fdd94bbcb04e65d573"}, + {file = "lxml-4.9.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:f99768232f036b4776ce419d3244a04fe83784bce871b16d2c2e984c7fcea847"}, + {file = "lxml-4.9.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:bb5bd6212eb0edfd1e8f254585290ea1dadc3687dd8fd5e2fd9a87c31915cdab"}, + {file = "lxml-4.9.4-cp39-cp39-win32.whl", hash = "sha256:88f7c383071981c74ec1998ba9b437659e4fd02a3c4a4d3efc16774eb108d0ec"}, + {file = "lxml-4.9.4-cp39-cp39-win_amd64.whl", hash = "sha256:936e8880cc00f839aa4173f94466a8406a96ddce814651075f95837316369899"}, + {file = "lxml-4.9.4-pp310-pypy310_pp73-macosx_11_0_x86_64.whl", hash = "sha256:f6c35b2f87c004270fa2e703b872fcc984d714d430b305145c39d53074e1ffe0"}, + {file = "lxml-4.9.4-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:606d445feeb0856c2b424405236a01c71af7c97e5fe42fbc778634faef2b47e4"}, + {file = "lxml-4.9.4-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:a1bdcbebd4e13446a14de4dd1825f1e778e099f17f79718b4aeaf2403624b0f7"}, + {file = "lxml-4.9.4-pp37-pypy37_pp73-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:0a08c89b23117049ba171bf51d2f9c5f3abf507d65d016d6e0fa2f37e18c0fc5"}, + {file = "lxml-4.9.4-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:232fd30903d3123be4c435fb5159938c6225ee8607b635a4d3fca847003134ba"}, + {file = "lxml-4.9.4-pp37-pypy37_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:231142459d32779b209aa4b4d460b175cadd604fed856f25c1571a9d78114771"}, + {file = "lxml-4.9.4-pp38-pypy38_pp73-macosx_11_0_x86_64.whl", hash = "sha256:520486f27f1d4ce9654154b4494cf9307b495527f3a2908ad4cb48e4f7ed7ef7"}, + {file = "lxml-4.9.4-pp38-pypy38_pp73-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:562778586949be7e0d7435fcb24aca4810913771f845d99145a6cee64d5b67ca"}, + {file = "lxml-4.9.4-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:a9e7c6d89c77bb2770c9491d988f26a4b161d05c8ca58f63fb1f1b6b9a74be45"}, + {file = "lxml-4.9.4-pp38-pypy38_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:786d6b57026e7e04d184313c1359ac3d68002c33e4b1042ca58c362f1d09ff58"}, + {file = "lxml-4.9.4-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:95ae6c5a196e2f239150aa4a479967351df7f44800c93e5a975ec726fef005e2"}, + {file = "lxml-4.9.4-pp39-pypy39_pp73-macosx_11_0_x86_64.whl", hash = "sha256:9b556596c49fa1232b0fff4b0e69b9d4083a502e60e404b44341e2f8fb7187f5"}, + {file = "lxml-4.9.4-pp39-pypy39_pp73-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:cc02c06e9e320869d7d1bd323df6dd4281e78ac2e7f8526835d3d48c69060683"}, + {file = "lxml-4.9.4-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:857d6565f9aa3464764c2cb6a2e3c2e75e1970e877c188f4aeae45954a314e0c"}, + {file = "lxml-4.9.4-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:c42ae7e010d7d6bc51875d768110c10e8a59494855c3d4c348b068f5fb81fdcd"}, + {file = "lxml-4.9.4-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:f10250bb190fb0742e3e1958dd5c100524c2cc5096c67c8da51233f7448dc137"}, + {file = "lxml-4.9.4.tar.gz", hash = "sha256:b1541e50b78e15fa06a2670157a1962ef06591d4c998b998047fff5e3236880e"}, +] + +[package.extras] +cssselect = ["cssselect (>=0.7)"] +html5 = ["html5lib"] +htmlsoup = ["BeautifulSoup4"] +source = ["Cython (==0.29.37)"] + +[[package]] +name = "packaging" +version = "24.0" +description = "Core utilities for Python packages" +optional = false +python-versions = ">=3.7" +files = [ + {file = "packaging-24.0-py3-none-any.whl", hash = "sha256:2ddfb553fdf02fb784c234c7ba6ccc288296ceabec964ad2eae3777778130bc5"}, + {file = "packaging-24.0.tar.gz", hash = "sha256:eb82c5e3e56209074766e6885bb04b8c38a0c015d0a30036ebe7ece34c9989e9"}, +] + +[[package]] +name = "pluggy" +version = "1.5.0" +description = "plugin and hook calling mechanisms for python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"}, + {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"}, +] + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["pytest", "pytest-benchmark"] + +[[package]] +name = "pydantic" +version = "1.10.15" +description = "Data validation and settings management using python type hints" +optional = false +python-versions = ">=3.7" +files = [ + {file = "pydantic-1.10.15-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:22ed12ee588b1df028a2aa5d66f07bf8f8b4c8579c2e96d5a9c1f96b77f3bb55"}, + {file = "pydantic-1.10.15-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:75279d3cac98186b6ebc2597b06bcbc7244744f6b0b44a23e4ef01e5683cc0d2"}, + {file = "pydantic-1.10.15-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:50f1666a9940d3d68683c9d96e39640f709d7a72ff8702987dab1761036206bb"}, + {file = "pydantic-1.10.15-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:82790d4753ee5d00739d6cb5cf56bceb186d9d6ce134aca3ba7befb1eedbc2c8"}, + {file = "pydantic-1.10.15-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:d207d5b87f6cbefbdb1198154292faee8017d7495a54ae58db06762004500d00"}, + {file = "pydantic-1.10.15-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:e49db944fad339b2ccb80128ffd3f8af076f9f287197a480bf1e4ca053a866f0"}, + {file = "pydantic-1.10.15-cp310-cp310-win_amd64.whl", hash = "sha256:d3b5c4cbd0c9cb61bbbb19ce335e1f8ab87a811f6d589ed52b0254cf585d709c"}, + {file = "pydantic-1.10.15-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c3d5731a120752248844676bf92f25a12f6e45425e63ce22e0849297a093b5b0"}, + {file = "pydantic-1.10.15-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c365ad9c394f9eeffcb30a82f4246c0006417f03a7c0f8315d6211f25f7cb654"}, + {file = "pydantic-1.10.15-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3287e1614393119c67bd4404f46e33ae3be3ed4cd10360b48d0a4459f420c6a3"}, + {file = "pydantic-1.10.15-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:be51dd2c8596b25fe43c0a4a59c2bee4f18d88efb8031188f9e7ddc6b469cf44"}, + {file = "pydantic-1.10.15-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:6a51a1dd4aa7b3f1317f65493a182d3cff708385327c1c82c81e4a9d6d65b2e4"}, + {file = "pydantic-1.10.15-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:4e316e54b5775d1eb59187f9290aeb38acf620e10f7fd2f776d97bb788199e53"}, + {file = "pydantic-1.10.15-cp311-cp311-win_amd64.whl", hash = "sha256:0d142fa1b8f2f0ae11ddd5e3e317dcac060b951d605fda26ca9b234b92214986"}, + {file = "pydantic-1.10.15-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:7ea210336b891f5ea334f8fc9f8f862b87acd5d4a0cbc9e3e208e7aa1775dabf"}, + {file = "pydantic-1.10.15-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3453685ccd7140715e05f2193d64030101eaad26076fad4e246c1cc97e1bb30d"}, + {file = "pydantic-1.10.15-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9bea1f03b8d4e8e86702c918ccfd5d947ac268f0f0cc6ed71782e4b09353b26f"}, + {file = "pydantic-1.10.15-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:005655cabc29081de8243126e036f2065bd7ea5b9dff95fde6d2c642d39755de"}, + {file = "pydantic-1.10.15-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:af9850d98fc21e5bc24ea9e35dd80a29faf6462c608728a110c0a30b595e58b7"}, + {file = "pydantic-1.10.15-cp37-cp37m-win_amd64.whl", hash = "sha256:d31ee5b14a82c9afe2bd26aaa405293d4237d0591527d9129ce36e58f19f95c1"}, + {file = "pydantic-1.10.15-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:5e09c19df304b8123938dc3c53d3d3be6ec74b9d7d0d80f4f4b5432ae16c2022"}, + {file = "pydantic-1.10.15-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7ac9237cd62947db00a0d16acf2f3e00d1ae9d3bd602b9c415f93e7a9fc10528"}, + {file = "pydantic-1.10.15-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:584f2d4c98ffec420e02305cf675857bae03c9d617fcfdc34946b1160213a948"}, + {file = "pydantic-1.10.15-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bbc6989fad0c030bd70a0b6f626f98a862224bc2b1e36bfc531ea2facc0a340c"}, + {file = "pydantic-1.10.15-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:d573082c6ef99336f2cb5b667b781d2f776d4af311574fb53d908517ba523c22"}, + {file = "pydantic-1.10.15-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:6bd7030c9abc80134087d8b6e7aa957e43d35714daa116aced57269a445b8f7b"}, + {file = "pydantic-1.10.15-cp38-cp38-win_amd64.whl", hash = "sha256:3350f527bb04138f8aff932dc828f154847fbdc7a1a44c240fbfff1b57f49a12"}, + {file = "pydantic-1.10.15-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:51d405b42f1b86703555797270e4970a9f9bd7953f3990142e69d1037f9d9e51"}, + {file = "pydantic-1.10.15-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a980a77c52723b0dc56640ced396b73a024d4b74f02bcb2d21dbbac1debbe9d0"}, + {file = "pydantic-1.10.15-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:67f1a1fb467d3f49e1708a3f632b11c69fccb4e748a325d5a491ddc7b5d22383"}, + {file = "pydantic-1.10.15-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:676ed48f2c5bbad835f1a8ed8a6d44c1cd5a21121116d2ac40bd1cd3619746ed"}, + {file = "pydantic-1.10.15-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:92229f73400b80c13afcd050687f4d7e88de9234d74b27e6728aa689abcf58cc"}, + {file = "pydantic-1.10.15-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:2746189100c646682eff0bce95efa7d2e203420d8e1c613dc0c6b4c1d9c1fde4"}, + {file = "pydantic-1.10.15-cp39-cp39-win_amd64.whl", hash = "sha256:394f08750bd8eaad714718812e7fab615f873b3cdd0b9d84e76e51ef3b50b6b7"}, + {file = "pydantic-1.10.15-py3-none-any.whl", hash = "sha256:28e552a060ba2740d0d2aabe35162652c1459a0b9069fe0db7f4ee0e18e74d58"}, + {file = "pydantic-1.10.15.tar.gz", hash = "sha256:ca832e124eda231a60a041da4f013e3ff24949d94a01154b137fc2f2a43c3ffb"}, +] + +[package.dependencies] +typing-extensions = ">=4.2.0" + +[package.extras] +dotenv = ["python-dotenv (>=0.10.4)"] +email = ["email-validator (>=1.0.3)"] + +[[package]] +name = "pytest" +version = "8.2.0" +description = "pytest: simple powerful testing with Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pytest-8.2.0-py3-none-any.whl", hash = "sha256:1733f0620f6cda4095bbf0d9ff8022486e91892245bb9e7d5542c018f612f233"}, + {file = "pytest-8.2.0.tar.gz", hash = "sha256:d507d4482197eac0ba2bae2e9babf0672eb333017bcedaa5fb1a3d42c1174b3f"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "sys_platform == \"win32\""} +iniconfig = "*" +packaging = "*" +pluggy = ">=1.5,<2.0" + +[package.extras] +dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] + +[[package]] +name = "pytest-cov" +version = "5.0.0" +description = "Pytest plugin for measuring coverage." +optional = false +python-versions = ">=3.8" +files = [ + {file = "pytest-cov-5.0.0.tar.gz", hash = "sha256:5837b58e9f6ebd335b0f8060eecce69b662415b16dc503883a02f45dfeb14857"}, + {file = "pytest_cov-5.0.0-py3-none-any.whl", hash = "sha256:4f0764a1219df53214206bf1feea4633c3b558a2925c8b59f144f682861ce652"}, +] + +[package.dependencies] +coverage = {version = ">=5.2.1", extras = ["toml"]} +pytest = ">=4.6" + +[package.extras] +testing = ["fields", "hunter", "process-tests", "pytest-xdist", "virtualenv"] + +[[package]] +name = "python-dotenv" +version = "1.0.1" +description = "Read key-value pairs from a .env file and set them as environment variables" +optional = false +python-versions = ">=3.8" +files = [ + {file = "python-dotenv-1.0.1.tar.gz", hash = "sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca"}, + {file = "python_dotenv-1.0.1-py3-none-any.whl", hash = "sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a"}, +] + +[package.extras] +cli = ["click (>=5.0)"] + +[[package]] +name = "pyyaml" +version = "6.0.1" +description = "YAML parser and emitter for Python" +optional = false +python-versions = ">=3.6" +files = [ + {file = "PyYAML-6.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d858aa552c999bc8a8d57426ed01e40bef403cd8ccdd0fc5f6f04a00414cac2a"}, + {file = "PyYAML-6.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fd66fc5d0da6d9815ba2cebeb4205f95818ff4b79c3ebe268e75d961704af52f"}, + {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938"}, + {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d"}, + {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515"}, + {file = "PyYAML-6.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:326c013efe8048858a6d312ddd31d56e468118ad4cdeda36c719bf5bb6192290"}, + {file = "PyYAML-6.0.1-cp310-cp310-win32.whl", hash = "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924"}, + {file = "PyYAML-6.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d"}, + {file = "PyYAML-6.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007"}, + {file = "PyYAML-6.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f003ed9ad21d6a4713f0a9b5a7a0a79e08dd0f221aff4525a2be4c346ee60aab"}, + {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d"}, + {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc"}, + {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673"}, + {file = "PyYAML-6.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e7d73685e87afe9f3b36c799222440d6cf362062f78be1013661b00c5c6f678b"}, + {file = "PyYAML-6.0.1-cp311-cp311-win32.whl", hash = "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741"}, + {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, + {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"}, + {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"}, + {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a08c6f0fe150303c1c6b71ebcd7213c2858041a7e01975da3a99aed1e7a378ef"}, + {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"}, + {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"}, + {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"}, + {file = "PyYAML-6.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:0d3304d8c0adc42be59c5f8a4d9e3d7379e6955ad754aa9d6ab7a398b59dd1df"}, + {file = "PyYAML-6.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47"}, + {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98"}, + {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c"}, + {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:afd7e57eddb1a54f0f1a974bc4391af8bcce0b444685d936840f125cf046d5bd"}, + {file = "PyYAML-6.0.1-cp36-cp36m-win32.whl", hash = "sha256:fca0e3a251908a499833aa292323f32437106001d436eca0e6e7833256674585"}, + {file = "PyYAML-6.0.1-cp36-cp36m-win_amd64.whl", hash = "sha256:f22ac1c3cac4dbc50079e965eba2c1058622631e526bd9afd45fedd49ba781fa"}, + {file = "PyYAML-6.0.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:b1275ad35a5d18c62a7220633c913e1b42d44b46ee12554e5fd39c70a243d6a3"}, + {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:18aeb1bf9a78867dc38b259769503436b7c72f7a1f1f4c93ff9a17de54319b27"}, + {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:596106435fa6ad000c2991a98fa58eeb8656ef2325d7e158344fb33864ed87e3"}, + {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:baa90d3f661d43131ca170712d903e6295d1f7a0f595074f151c0aed377c9b9c"}, + {file = "PyYAML-6.0.1-cp37-cp37m-win32.whl", hash = "sha256:9046c58c4395dff28dd494285c82ba00b546adfc7ef001486fbf0324bc174fba"}, + {file = "PyYAML-6.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:4fb147e7a67ef577a588a0e2c17b6db51dda102c71de36f8549b6816a96e1867"}, + {file = "PyYAML-6.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1d4c7e777c441b20e32f52bd377e0c409713e8bb1386e1099c2415f26e479595"}, + {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5"}, + {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696"}, + {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735"}, + {file = "PyYAML-6.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:49a183be227561de579b4a36efbb21b3eab9651dd81b1858589f796549873dd6"}, + {file = "PyYAML-6.0.1-cp38-cp38-win32.whl", hash = "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206"}, + {file = "PyYAML-6.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62"}, + {file = "PyYAML-6.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8"}, + {file = "PyYAML-6.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c8098ddcc2a85b61647b2590f825f3db38891662cfc2fc776415143f599bb859"}, + {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6"}, + {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0"}, + {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c"}, + {file = "PyYAML-6.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:04ac92ad1925b2cff1db0cfebffb6ffc43457495c9b3c39d3fcae417d7125dc5"}, + {file = "PyYAML-6.0.1-cp39-cp39-win32.whl", hash = "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c"}, + {file = "PyYAML-6.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486"}, + {file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"}, +] + +[[package]] +name = "requests" +version = "2.31.0" +description = "Python HTTP for Humans." +optional = false +python-versions = ">=3.7" +files = [ + {file = "requests-2.31.0-py3-none-any.whl", hash = "sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f"}, + {file = "requests-2.31.0.tar.gz", hash = "sha256:942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1"}, +] + +[package.dependencies] +certifi = ">=2017.4.17" +charset-normalizer = ">=2,<4" +idna = ">=2.5,<4" +urllib3 = ">=1.21.1,<3" + +[package.extras] +socks = ["PySocks (>=1.5.6,!=1.5.7)"] +use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] + +[[package]] +name = "requests-mock" +version = "1.12.1" +description = "Mock out responses from the requests package" +optional = false +python-versions = ">=3.5" +files = [ + {file = "requests-mock-1.12.1.tar.gz", hash = "sha256:e9e12e333b525156e82a3c852f22016b9158220d2f47454de9cae8a77d371401"}, + {file = "requests_mock-1.12.1-py2.py3-none-any.whl", hash = "sha256:b1e37054004cdd5e56c84454cc7df12b25f90f382159087f4b6915aaeef39563"}, +] + +[package.dependencies] +requests = ">=2.22,<3" + +[package.extras] +fixture = ["fixtures"] + +[[package]] +name = "soupsieve" +version = "2.5" +description = "A modern CSS selector implementation for Beautiful Soup." +optional = false +python-versions = ">=3.8" +files = [ + {file = "soupsieve-2.5-py3-none-any.whl", hash = "sha256:eaa337ff55a1579b6549dc679565eac1e3d000563bcb1c8ab0d0fefbc0c2cdc7"}, + {file = "soupsieve-2.5.tar.gz", hash = "sha256:5663d5a7b3bfaeee0bc4372e7fc48f9cff4940b3eec54a6451cc5299f1097690"}, +] + +[[package]] +name = "tqdm" +version = "4.66.4" +description = "Fast, Extensible Progress Meter" +optional = false +python-versions = ">=3.7" +files = [ + {file = "tqdm-4.66.4-py3-none-any.whl", hash = "sha256:b75ca56b413b030bc3f00af51fd2c1a1a5eac6a0c1cca83cbb37a5c52abce644"}, + {file = "tqdm-4.66.4.tar.gz", hash = "sha256:e4d936c9de8727928f3be6079590e97d9abfe8d39a590be678eb5919ffc186bb"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + +[package.extras] +dev = ["pytest (>=6)", "pytest-cov", "pytest-timeout", "pytest-xdist"] +notebook = ["ipywidgets (>=6)"] +slack = ["slack-sdk"] +telegram = ["requests"] + +[[package]] +name = "types-beautifulsoup4" +version = "4.12.0.20240511" +description = "Typing stubs for beautifulsoup4" +optional = false +python-versions = ">=3.8" +files = [ + {file = "types-beautifulsoup4-4.12.0.20240511.tar.gz", hash = "sha256:004f6096fdd83b19cdbf6cb10e4eae57b10205eccc365d0a69d77da836012e28"}, + {file = "types_beautifulsoup4-4.12.0.20240511-py3-none-any.whl", hash = "sha256:7ceda66a93ba28d759d5046d7fec9f4cad2f563a77b3a789efc90bcadafeefd1"}, +] + +[package.dependencies] +types-html5lib = "*" + +[[package]] +name = "types-html5lib" +version = "1.1.11.20240228" +description = "Typing stubs for html5lib" +optional = false +python-versions = ">=3.8" +files = [ + {file = "types-html5lib-1.1.11.20240228.tar.gz", hash = "sha256:22736b7299e605ec4ba539d48691e905fd0c61c3ea610acc59922232dc84cede"}, + {file = "types_html5lib-1.1.11.20240228-py3-none-any.whl", hash = "sha256:af5de0125cb0fe5667543b158db83849b22e25c0e36c9149836b095548bf1020"}, +] + +[[package]] +name = "types-pyyaml" +version = "6.0.12.20240311" +description = "Typing stubs for PyYAML" +optional = false +python-versions = ">=3.8" +files = [ + {file = "types-PyYAML-6.0.12.20240311.tar.gz", hash = "sha256:a9e0f0f88dc835739b0c1ca51ee90d04ca2a897a71af79de9aec5f38cb0a5342"}, + {file = "types_PyYAML-6.0.12.20240311-py3-none-any.whl", hash = "sha256:b845b06a1c7e54b8e5b4c683043de0d9caf205e7434b3edc678ff2411979b8f6"}, +] + +[[package]] +name = "types-requests" +version = "2.31.0.20240406" +description = "Typing stubs for requests" +optional = false +python-versions = ">=3.8" +files = [ + {file = "types-requests-2.31.0.20240406.tar.gz", hash = "sha256:4428df33c5503945c74b3f42e82b181e86ec7b724620419a2966e2de604ce1a1"}, + {file = "types_requests-2.31.0.20240406-py3-none-any.whl", hash = "sha256:6216cdac377c6b9a040ac1c0404f7284bd13199c0e1bb235f4324627e8898cf5"}, +] + +[package.dependencies] +urllib3 = ">=2" + +[[package]] +name = "types-tqdm" +version = "4.66.0.20240417" +description = "Typing stubs for tqdm" +optional = false +python-versions = ">=3.8" +files = [ + {file = "types-tqdm-4.66.0.20240417.tar.gz", hash = "sha256:16dce9ef522ea8d40e4f5b8d84dd8a1166eefc13ceee7a7e158bf0f1a1421a31"}, + {file = "types_tqdm-4.66.0.20240417-py3-none-any.whl", hash = "sha256:248aef1f9986b7b8c2c12b3cb4399fc17dba0a29e7e3f3f9cd704babb879383d"}, +] + +[[package]] +name = "typing-extensions" +version = "4.11.0" +description = "Backported and Experimental Type Hints for Python 3.8+" +optional = false +python-versions = ">=3.8" +files = [ + {file = "typing_extensions-4.11.0-py3-none-any.whl", hash = "sha256:c1f94d72897edaf4ce775bb7558d5b79d8126906a14ea5ed1635921406c0387a"}, + {file = "typing_extensions-4.11.0.tar.gz", hash = "sha256:83f085bd5ca59c80295fc2a82ab5dac679cbe02b9f33f7d83af68e241bea51b0"}, +] + +[[package]] +name = "urllib3" +version = "2.2.1" +description = "HTTP library with thread-safe connection pooling, file post, and more." +optional = false +python-versions = ">=3.8" +files = [ + {file = "urllib3-2.2.1-py3-none-any.whl", hash = "sha256:450b20ec296a467077128bff42b73080516e71b56ff59a60a02bef2232c4fa9d"}, + {file = "urllib3-2.2.1.tar.gz", hash = "sha256:d0570876c61ab9e520d776c38acbbb5b05a776d3f9ff98a5c8fd5162a444cf19"}, +] + +[package.extras] +brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] +h2 = ["h2 (>=4,<5)"] +socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] +zstd = ["zstandard (>=0.18.0)"] + +[metadata] +lock-version = "2.0" +python-versions = "^3.11" +content-hash = "b36229cf3e17bab4abe910aab717bac99885a3b2116c4f2071b143d0e240d572" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..a996ee0 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,48 @@ +[tool.poetry] +name = "adjust" +version = "0.1.0" +description = "" +authors = ["Jesus Lopez "] +readme = "README.md" + +[tool.poetry.scripts] +adjust = "adjust.cli:main" + +[tool.poetry.dependencies] +python = "^3.11" +PyYAML = "^6.0" +requests = "^2.31.0" +pydantic = "^1.10.8" +gitignore-parser = "^0.1.3" +lxml = "^4.9.2" +beautifulsoup4 = "^4.12.2" +click = "^8.1.7" +python-dotenv = "^1.0.1" +tqdm = "^4.66.4" +click-help-colors = "^0.9.4" + + +[tool.poetry.group.dev.dependencies] +types-pyyaml = "^6.0.12.10" +types-requests = "^2.31.0.0" +types-beautifulsoup4 = "^4.12.0.20240511" +types-tqdm = "^4.66.0.20240417" + + +[tool.poetry.group.test.dependencies] +pytest = "^8.2.0" +requests-mock = "^1.12.1" +pytest-cov = "^5.0.0" + +[tool.pytest.ini_options] +requests_mock_case_sensitive = true + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" + +[tool.black] +line-length = 132 + +[tool.ruff] +line-length = 132 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..8c56635 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,172 @@ +import json +import os +from typing import Iterator +import pytest +import random + +from click.testing import CliRunner +from requests_mock import Mocker + +from adjust.api.api import AdjustAPI +from adjust.api.model import App, AppsResponse, Callback, RawCallback, User +from adjust.snapshot import Snapshot, normalize_snapshot +from .utils import ( + HelpCommand, + HelpOption, + HelpOutput, + random_app, + random_callback, + random_callbacks, + random_string, + random_user, +) + + +@pytest.fixture +def simulate_tty(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr("os.isatty", lambda n: True) + + +@pytest.fixture +def password() -> str: + return random_string(8) + + +@pytest.fixture +def user() -> User: + return random_user() + + +@pytest.fixture +def apps(requests_mock: Mocker) -> dict[str, App]: + apps = [random_app() for _ in range(random.randint(8, 16))] + response = AppsResponse.parse_obj({"apps": apps, "urls": {}, "page": {"position": 0}}) + requests_mock.get( + "https://api.adjust.com/dashboard/api/apps", + text=response.json(), + ) + return {app.token: app for app in apps} + + +@pytest.fixture +def app() -> App: + return random_app() + + +@pytest.fixture +def raw_callback() -> RawCallback: + return random_callback(None) + + +@pytest.fixture +def callback(raw_callback: RawCallback) -> Callback: + return raw_callback.to_callback() + + +@pytest.fixture +def apps_with_callbacks(requests_mock: Mocker, apps: dict[str, App]) -> dict[App, list[RawCallback]]: + rv = {app: random_callbacks(random.randint(0, 4)) for app in apps.values()} + for app, callbacks in rv.items(): + requests_mock.get( + f"https://api.adjust.com/dashboard/api/apps/{app.token}/callbacks", + text=json.dumps([cb.dict() for cb in callbacks]), + ) + return rv + + +@pytest.fixture +def api(requests_mock: Mocker, user: User, password: str) -> AdjustAPI: + requests_mock.post( + "https://api.adjust.com/accounts/users/sign_in", + text=user.json(), + ) + return AdjustAPI(email=user.email, password=password) + + +@pytest.fixture +def snapshot(apps_with_callbacks: dict[App, list[RawCallback]]) -> Snapshot: + rv = {app.token: [cb.to_callback() for cb in callbacks] for app, callbacks in apps_with_callbacks.items()} + return normalize_snapshot(rv) + + +@pytest.fixture +def runner(requests_mock: Mocker, user: User) -> CliRunner: + requests_mock.post( + "https://api.adjust.com/accounts/users/sign_in", + text=user.json(), + ) + return CliRunner() + + +@pytest.fixture +def cli_help() -> HelpOutput: + return HelpOutput( + help="Adjust API CLI", + options=[ + HelpOption(names=["--help"], help="Show this message and exit."), + ], + commands=[ + HelpCommand(name="snapshot", help="Manage Adjust callback snapshots"), + ], + ) + + +@pytest.fixture +def callbacks_snapshot_help() -> HelpOutput: + return HelpOutput( + help="Manage Adjust callback snapshots", + options=[HelpOption(names=["--help"], help="Show this message and exit.")], + commands=[ + HelpCommand(name="create", help="Create a local snapshot of all Adjust callbacks"), + HelpCommand(name="modify", help="Modify local snapshot"), + HelpCommand(name="restore", help="Restore Adjust callbacks from local snapshot"), + ], + ) + + +@pytest.fixture +def snapshot_create_help() -> HelpOutput: + return HelpOutput( + help="Create a local snapshot of all Adjust callbacks", + options=[ + HelpOption(names=["-s", "--snapshot DIRECTORY"], help="Snapshot path [default: snapshot]"), + HelpOption(names=["--non-interactive"], help="Allow interaction with user"), + HelpOption(names=["-f", "--force"], help="Overwrite existing snapshot"), + HelpOption(names=["--help"], help="Show this message and exit."), + ], + ) + + +@pytest.fixture +def snapshot_restore_help() -> HelpOutput: + return HelpOutput( + help="Restore Adjust callbacks from local snapshot", + options=[ + HelpOption(names=["-s", "--snapshot DIRECTORY"], help="Snapshot path [default: snapshot]"), + HelpOption(names=["-n", "--dry-run"], help="Do not invoke the Adjust API, only simulate what"), + HelpOption(names=["--help"], help="Show this message and exit."), + ], + ) + + +@pytest.fixture +def snapshot_modify_help() -> HelpOutput: + return HelpOutput( + help="Modify local snapshot", + options=[ + HelpOption(names=["-s", "--snapshot DIRECTORY"], help="Snapshot path [default: snapshot]"), + HelpOption(names=["--having-placeholder PH"], help="Only modify callbacks having placeholder PH"), + HelpOption(names=["--having-app NAME"], help="Only modify apps named NAME"), + HelpOption(names=["--having-app-token TOKEN"], help="Only modify apps with token TOKEN"), + HelpOption(names=["--having-domain DOMAIN"], help="Only modify callbacks whose URL domain is DOMAIN"), + HelpOption(names=["--having-path PATH"], help="Only modify callbacks whose URL is PATH"), + HelpOption(names=["--matching-placeholder REGEX"], help="Only modify callbacks having placeholders"), + HelpOption(names=["--matching-app REGEX"], help="Only modify apps whose name match REGEX"), + HelpOption(names=["--matching-domain REGEX"], help="Only modify callbacks whose URL domain matches"), + HelpOption(names=["--matching-path REGEX"], help="Only modify callbacks whose URL matches REGEX"), + HelpOption(names=["-a", "--add-placeholder PH"], help="Add placeholder PH to all matching callbacks"), + HelpOption(names=["-n", "--dry-run"], help="Do not update the snapshot, only simulate what"), + HelpOption(names=["--help"], help="Show this message and exit."), + ], + commands=[], + ) diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..eb0f74a --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,60 @@ +import os +import pytest + +from requests_mock.mocker import Mocker +from adjust.api import AdjustAPI +from adjust.api.model import App, Callback, User +from adjust.snapshot import Snapshot + + +def test_email_required(requests_mock: Mocker) -> None: + os.environ.pop("ADJUST_EMAIL", None) + with pytest.raises(ValueError, match="Email not provided"): + AdjustAPI() + + +def test_password_required(requests_mock: Mocker) -> None: + os.environ.pop("ADJUST_PASSWORD", None) + with pytest.raises(ValueError, match="Password not provided"): + AdjustAPI(email="name@email.com") + + +def test_sign_in(requests_mock: Mocker, user: User, password: str) -> None: + requests_mock.post( + "https://api.adjust.com/accounts/users/sign_in", + text=user.json(), + ) + api = AdjustAPI(email=user.email, password=password) + assert user == api.user() + assert requests_mock.request_history[0].json() == dict(user=dict(email=user.email, password=password, remember_me=True)) + + +def test_sign_in_from_env(requests_mock: Mocker, user: User, password: str) -> None: + os.environ["ADJUST_EMAIL"] = user.email + os.environ["ADJUST_PASSWORD"] = password + requests_mock.post( + "https://api.adjust.com/accounts/users/sign_in", + text=user.json(), + ) + api = AdjustAPI() + assert user == api.user() + assert requests_mock.request_history[0].json() == dict(user=dict(email=user.email, password=password, remember_me=True)) + + +def test_apps(api: AdjustAPI, apps: dict[str, App]) -> None: + assert list(apps.values()) == api.apps + + +def test_callbacks(api: AdjustAPI, snapshot: Snapshot) -> None: + for app_token, callbacks in snapshot.items(): + assert callbacks == api.callbacks(app_token) + + +def test_update_callbacks(requests_mock: Mocker, api: AdjustAPI, app: App, callback: Callback) -> None: + requests_mock.put( + f"https://api.adjust.com/dashboard/api/apps/{app.token}/event_types/{callback.id}/callback", + status_code=204, + ) + api.update_callback(app, callback) + request = next(r for r in requests_mock.request_history if r.method == "PUT") + assert request.json() == dict(callback_url=callback.url) diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..f112afc --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,373 @@ +import os +import pytest +import random +import re + +from click.testing import CliRunner, Result +from contextlib import contextmanager +from requests_mock.mocker import Mocker +from typing import Callable, Iterator + +from adjust.api.model import App, Callback, CallbackURL +from adjust.cli import cli +from adjust.snapshot import Snapshot, snapshot_callback_count, snapshot_load, snapshot_save + +from .utils import HelpOutput, parse_help, random_string, recursive_directory_listing + + +def test_help(requests_mock: Mocker, runner: CliRunner, cli_help: HelpOutput) -> None: + result = runner.invoke(cli, ["--help"]) + assert result.exit_code == 0 + assert not requests_mock.request_history + assert cli_help == parse_help(result.output) + + +def test_argless(requests_mock: Mocker, runner: CliRunner, cli_help: HelpOutput) -> None: + result = runner.invoke(cli) + assert result.exit_code == 0 + assert not requests_mock.request_history + assert cli_help == parse_help(result.output) + + +def test_callbacks_snapshot_help(requests_mock: Mocker, runner: CliRunner, callbacks_snapshot_help: HelpOutput) -> None: + result = runner.invoke(cli, ["snapshot", "--help"]) + assert result.exit_code == 0 + assert not requests_mock.request_history + assert callbacks_snapshot_help == parse_help(result.output) + + +def test_snapshot_create_help(requests_mock: Mocker, runner: CliRunner, snapshot_create_help: HelpOutput) -> None: + result = runner.invoke(cli, ["snapshot", "create", "--help"]) + assert result.exit_code == 0 + assert not requests_mock.request_history + assert snapshot_create_help == parse_help(result.output) + + +def test_snapshot_restore_help(requests_mock: Mocker, runner: CliRunner, snapshot_restore_help: HelpOutput) -> None: + result = runner.invoke(cli, ["snapshot", "restore", "--help"]) + assert result.exit_code == 0 + assert not requests_mock.request_history + assert snapshot_restore_help == parse_help(result.output) + + +def test_snapshot_modify_help(requests_mock: Mocker, runner: CliRunner, snapshot_modify_help: HelpOutput) -> None: + result = runner.invoke(cli, ["snapshot", "modify", "--help"]) + assert result.exit_code == 0 + assert not requests_mock.request_history + assert snapshot_modify_help == parse_help(result.output) + + +def test_snapshot_create( + runner: CliRunner, + snapshot: Snapshot, + tmp_path: str, +) -> None: + with runner.isolated_filesystem(temp_dir=tmp_path): + result = runner.invoke(cli, ["snapshot", "create", "-s", "output"]) + num_callbacks = snapshot_callback_count(snapshot) + assert f"✅ Done. Retrieved {num_callbacks} callbacks." in result.output + assert result.exit_code == 0 + assert os.path.exists("output") + created_snapshot = snapshot_load("output") + assert created_snapshot == snapshot + + +def test_snapshot_create_already_exists( + runner: CliRunner, + tmp_path: str, +) -> None: + with runner.isolated_filesystem(temp_dir=tmp_path): + os.makedirs("snapshot") + result = runner.invoke(cli, ["snapshot", "create"]) + assert result.exit_code == 1 + assert "⛔️ Snapshot path `snapshot` already exists. Use --force to overwrite." in result.output + + +def test_snapshot_create_already_exists_reply_no( + runner: CliRunner, + tmp_path: str, + simulate_tty: None, +) -> None: + with runner.isolated_filesystem(temp_dir=tmp_path): + os.makedirs("snapshot") + result = runner.invoke(cli, ["snapshot", "create"], input="n\n") + assert result.exit_code == 1 + assert "⚠️ Snapshot path `snapshot` already exists. Overwrite?" in result.output + + +def test_snapshot_create_already_exists_reply_yes( + runner: CliRunner, + snapshot: Snapshot, + tmp_path: str, + simulate_tty: None, +) -> None: + with runner.isolated_filesystem(temp_dir=tmp_path): + num_callbacks = snapshot_callback_count(snapshot) + os.makedirs("snapshot") + result = runner.invoke(cli, ["snapshot", "create"], input="y\n") + assert result.exit_code == 0 + assert "⚠️ Snapshot path `snapshot` already exists. Overwrite?" in result.output + assert "⬇️ Creating Snapshot: 100%" in repr(result.output) + assert f"✅ Done. Retrieved {num_callbacks} callbacks." in result.output + saved_snapshot = snapshot_load("snapshot") + assert saved_snapshot == snapshot + + +def test_snapshot_create_already_exists_forced( + runner: CliRunner, + snapshot: Snapshot, + tmp_path: str, +) -> None: + with runner.isolated_filesystem(temp_dir=tmp_path): + snapshot_save("snapshot2", snapshot) + os.makedirs("snapshot") + result = runner.invoke(cli, ["snapshot", "create", "-f"]) + num_callbacks = snapshot_callback_count(snapshot) + assert result.exit_code == 0 + assert f"✅ Done. Retrieved {num_callbacks} callbacks." in result.output + listing1 = recursive_directory_listing("snapshot") + listing2 = recursive_directory_listing("snapshot2") + assert listing1 == listing2 + + +def test_snapshot_restore( + requests_mock: Mocker, + runner: CliRunner, + snapshot: Snapshot, + tmp_path: str, +) -> None: + with runner.isolated_filesystem(temp_dir=tmp_path): + for app_token, callbacks in snapshot.items(): + for callback in callbacks: + for url in callback.urls: + url.add_placeholder("new_placeholder") + requests_mock.put( + f"https://api.adjust.com/dashboard/api/apps/{app_token}/event_types/{callback.id}/callback", + status_code=204, + ) + snapshot_save("snapshot", snapshot) + result = runner.invoke(cli, ["snapshot", "restore"]) + num_callbacks = snapshot_callback_count(snapshot) + assert result.exit_code == 0 + assert f"✅ Done. Updated {num_callbacks} callbacks." in result.output + assert sum(1 for h in requests_mock.request_history if h.method == "PUT") == num_callbacks + for request in requests_mock.request_history: + if request.method == "PUT": + match = re.match(r"/dashboard/api/apps/(.+)/event_types/(.+)/callback", request.path) + assert match, request.path + app_token, callback_id = match.groups() + callback = next(cb for cb in snapshot[app_token] if str(cb.id) == callback_id) + assert request.json() == dict(callback_url=callback.url) + + +def test_snapshot_restore_no_changes( + requests_mock: Mocker, + runner: CliRunner, + snapshot: Snapshot, + tmp_path: str, +) -> None: + with runner.isolated_filesystem(temp_dir=tmp_path): + snapshot_save("snapshot", snapshot) + result = runner.invoke(cli, ["snapshot", "restore"]) + assert result.exit_code == 0 + assert "✅ Done. Updated 0 callbacks." in result.output + assert sum(1 for h in requests_mock.request_history if h.method == "PUT") == 0 + + +def test_snapshot_restore_doesnt_exist( + requests_mock: Mocker, + runner: CliRunner, + tmp_path: str, +) -> None: + with runner.isolated_filesystem(temp_dir=tmp_path): + result = runner.invoke(cli, ["snapshot", "restore"]) + assert result.exit_code == 2 + assert "Directory 'snapshot' does not exist." in result.output + assert sum(1 for h in requests_mock.request_history if h.method == "PUT") == 0 + + +class Counters: + def __init__(self) -> None: + self.seen_apps: set[str] = set() + self.seen_callbacks: set[str] = set() + self.urls = 0 + + def update(self, app_token: str, callback: Callback) -> None: + self.seen_apps.add(app_token) + self.seen_callbacks.add(f"{app_token}/{callback.id}") + self.urls += 1 + + @property + def apps(self) -> int: + return len(self.seen_apps) + + @property + def callbacks(self) -> int: + return len(self.seen_callbacks) + + @property + def expected_message(self) -> str: + return f"Updated {self.urls} URLs in {self.callbacks} callbacks across {self.apps} apps" + + +class ModifyTester: + def __init__(self, runner: CliRunner, tmp_path: str, apps: dict[str, App], snapshot: Snapshot) -> None: + self.runner = runner + self.tmp_path = tmp_path + self.apps = apps + self.snapshot = snapshot + self.placeholder = random_string(16) + + @contextmanager + def isolated_filesystem(self) -> Iterator[None]: + with self.runner.isolated_filesystem(temp_dir=self.tmp_path): + snapshot_save("snapshot", self.snapshot) + yield + + def gather_all(self, key: Callable[[App, Callback, CallbackURL], str]) -> list[str]: + def gather() -> Iterator[str]: + for app_token, callbacks in self.snapshot.items(): + app = self.apps[app_token] + for callback in callbacks: + for url in callback.urls: + yield key(app, callback, url) + + return list(gather()) + + def pick_one(self, key: Callable[[App, Callback, CallbackURL], str]) -> str: + return random.choice(self.gather_all(key)) + + def run(self, args: list[str]) -> Result: + full_args = ["snapshot", "modify"] + args + ["--add-placeholder", self.placeholder] + self.result = self.runner.invoke(cli, full_args) + assert self.result.exit_code == 0 + return self.result + + def ensure_snapshot_satisfies( + self, + filter_predicate: Callable[[App, Callback, CallbackURL], bool], + message: str, + ) -> None: + updated_snapshot = snapshot_load("snapshot") + assert updated_snapshot != self.snapshot + counters = Counters() + for app_token, callbacks in updated_snapshot.items(): + app = self.apps[app_token] + for callback in callbacks: + for url in callback.urls: + if filter_predicate(app, callback, url): + message = f"{app_token}/{callback.unique_name}: {message} has placeholder {self.placeholder}\n{self.result.output}" + assert self.placeholder in url.placeholders, message + counters.update(app_token, callback) + assert counters.expected_message in self.result.output + + +@pytest.fixture +def modify_tester(runner: CliRunner, tmp_path: str, apps: dict[str, App], snapshot: Snapshot) -> ModifyTester: + return ModifyTester(runner, tmp_path, apps, snapshot) + + +def test_snapshot_modify_app_token(modify_tester: ModifyTester) -> None: + pick = modify_tester.pick_one(lambda a, c, u: a.token) + with modify_tester.isolated_filesystem(): + modify_tester.run(["--having-app-token", pick]) + modify_tester.ensure_snapshot_satisfies( + lambda a, c, u: a.token == pick, + f"App with token {pick}", + ) + + +def test_snapshot_modify_having_placeholder(modify_tester: ModifyTester) -> None: + pick = modify_tester.pick_one(lambda a, c, u: random.choice(u.placeholders)) + with modify_tester.isolated_filesystem(): + modify_tester.run(["--having-placeholder", pick]) + modify_tester.ensure_snapshot_satisfies( + lambda a, c, u: pick in u.placeholders, + f"URL with placeholder {pick}", + ) + + +def test_snapshot_modify_matching_placeholder(modify_tester: ModifyTester) -> None: + pick = modify_tester.pick_one(lambda a, c, u: random.choice(u.placeholders)) + with modify_tester.isolated_filesystem(): + modify_tester.run(["--matching-placeholder", f"^{pick}$"]) + modify_tester.ensure_snapshot_satisfies( + lambda a, c, u: pick in u.placeholders, + f"URL with placeholder matching ^{pick}$", + ) + + +def test_snapshot_modify_having_app_name(modify_tester: ModifyTester) -> None: + pick = modify_tester.pick_one(lambda a, c, u: a.name) + with modify_tester.isolated_filesystem(): + modify_tester.run(["--having-app", pick]) + modify_tester.ensure_snapshot_satisfies( + lambda a, c, u: a.name == pick, + f"App with name {pick}", + ) + + +def test_snapshot_modify_matching_app_name(modify_tester: ModifyTester) -> None: + pick = modify_tester.pick_one(lambda a, c, u: a.name) + with modify_tester.isolated_filesystem(): + modify_tester.run(["--matching-app", f"^{pick}$"]) + modify_tester.ensure_snapshot_satisfies( + lambda a, c, u: re.match(f"^{pick}$", a.name) is not None, + f"App with name matching ^{pick}$", + ) + + +def test_snapshot_modify_having_domain(modify_tester: ModifyTester) -> None: + pick = modify_tester.pick_one(lambda a, c, u: u.netloc) + with modify_tester.isolated_filesystem(): + modify_tester.run(["--having-domain", pick]) + modify_tester.ensure_snapshot_satisfies( + lambda a, c, u: u.netloc == pick, + f"URL with domain {pick}", + ) + + +def test_snapshot_modify_matching_domain(modify_tester: ModifyTester) -> None: + pick = modify_tester.pick_one(lambda a, c, u: u.netloc) + with modify_tester.isolated_filesystem(): + modify_tester.run(["--matching-domain", f"^{pick}$"]) + modify_tester.ensure_snapshot_satisfies( + lambda a, c, u: u.netloc == pick, + f"URL with domain matching ^{pick}$", + ) + + +def test_snapshot_modify_having_path(modify_tester: ModifyTester) -> None: + pick = modify_tester.pick_one(lambda a, c, u: u.path) + with modify_tester.isolated_filesystem(): + modify_tester.run(["--having-path", pick]) + modify_tester.ensure_snapshot_satisfies( + lambda a, c, u: u.path == pick, + f"URL with path {pick}", + ) + + +def test_snapshot_modify_matching_path(modify_tester: ModifyTester) -> None: + pick = modify_tester.pick_one(lambda a, c, u: u.path) + with modify_tester.isolated_filesystem(): + modify_tester.run(["--matching-path", f"^{pick}$"]) + modify_tester.ensure_snapshot_satisfies( + lambda a, c, u: u.path == pick, + f"URL with path matching ^{pick}$", + ) + + +def test_snapshot_modify_none(modify_tester: ModifyTester) -> None: + pick = random_string(16) + with modify_tester.isolated_filesystem(): + result = modify_tester.run(["--matching-path", pick]) + assert result.exit_code == 0 + updated_snapshot = snapshot_load("snapshot") + assert updated_snapshot == modify_tester.snapshot + assert "⚠️ No URLs matched the pattern." in modify_tester.result.output + + +def test_snapshot_modify_bad_regex(runner: CliRunner) -> None: + result = runner.invoke(cli, ["snapshot", "modify", "--matching-path", "("]) + assert result.exit_code == 2 + assert "Invalid regex:" in result.output diff --git a/tests/test_snapshot.py b/tests/test_snapshot.py new file mode 100644 index 0000000..5d0c016 --- /dev/null +++ b/tests/test_snapshot.py @@ -0,0 +1,39 @@ +from requests_mock import Mocker +import yaml +from adjust.api.api import AdjustAPI +from adjust.api.model import Callback +from adjust.snapshot import Snapshot, snapshot_fetch, snapshot_load, snapshot_restore, snapshot_save + + +def test_snapshot_fetch(api: AdjustAPI, snapshot: Snapshot) -> None: + assert snapshot_fetch(api) == snapshot + + +def test_snapshot_restore(requests_mock: Mocker, api: AdjustAPI, snapshot: Snapshot) -> None: + for app_token, callbacks in snapshot.items(): + for callback in callbacks: + requests_mock.put( + f"https://api.adjust.com/dashboard/api/apps/{app_token}/event_types/{callback.id}/callback", + status_code=204, + ) + snapshot_restore(api, snapshot) + requests = {r.path: r for r in requests_mock.request_history if r.method == "PUT"} + for app_token, callbacks in snapshot.items(): + for callback in callbacks: + request = requests[f"/dashboard/api/apps/{app_token}/event_types/{callback.id}/callback"] + assert request.json() == dict(callback_url=callback.url) + + +def test_snapshot_save(snapshot: Snapshot, tmp_path: str) -> None: + snapshot_save(tmp_path, snapshot) + for app_token, callbacks in snapshot.items(): + for callback in callbacks: + with open(f"{tmp_path}/{app_token}/{callback.unique_name}.yaml") as file: + data = yaml.safe_load(file) + assert Callback.parse_obj(data) == callback + + +def test_snapshot_load(snapshot: Snapshot, tmp_path: str) -> None: + snapshot_save(tmp_path, snapshot) + loaded = snapshot_load(tmp_path) + assert loaded == snapshot diff --git a/tests/utils.py b/tests/utils.py new file mode 100644 index 0000000..d56e887 --- /dev/null +++ b/tests/utils.py @@ -0,0 +1,410 @@ +import datetime +import os +import random +import string + +from pydantic import BaseModel +from typing import Optional +from urllib.parse import urlunsplit + +from adjust.api.model import App, RawCallback, User + + +def random_string(length: int) -> str: + letters = string.ascii_letters + return "".join(random.choice(letters) for i in range(length)) + + +def random_domain() -> str: + return f"{random_string(10)}.{random.choice(['com', 'org', 'net', 'io'])}" + + +def random_email() -> str: + return f"{random_string(8)}@{random_domain()}" + + +def random_timestamp(a: int, b: int) -> datetime.datetime: + return datetime.datetime.now() + datetime.timedelta(seconds=random.randint(a, b)) + + +def random_app() -> App: + token = random_string(12) + app_id = str(random.randint(0, 10_000_000_000)) + return App.parse_obj( + { + "id": random.randint(1, 100_000), + "name": random_string(10), + "token": token, + "start_date": random_timestamp(-365 * 86400, -30 * 86400).date().isoformat(), + "default_store_app_id": app_id, + "integration_dates": { + "ios": random_timestamp(-30 * 86400, -10 * 86400).date().isoformat(), + "android": random_timestamp(-30 * 86400, -10 * 86400).date().isoformat(), + "windows": random_timestamp(-30 * 86400, -10 * 86400).date().isoformat(), + }, + "default_attribution_platform": "mobile_app", + "is_ctv": False, + "is_console": False, + "is_pc": False, + "is_mobile": True, + "is_web": False, + "is_child_directed": False, + "no_eea_users": False, + "fraud_prevention_settings": { + "distribution_filter": "standard", + "show_invalid_signature": True, + "filter_anonymous_traffic": True, + "filter_engagement_injection": True, + "filter_too_many_engagements": True, + }, + "ad_revenue_max_amount": None, + "app_token": token, + "platforms": { + "android": {"configured": True, "store": "google", "app_id": random_domain()}, + "ios": { + "configured": True, + "store": "itunes", + "app_id": app_id, + "app_state": "not_verified", + "ios_bundle_id": random_domain(), + }, + "windows": {"configured": False}, + "windows-phone": {"configured": False}, + "web": {"configured": False}, + }, + "permissions": { + "generate_report": True, + "read_statistics": True, + "create_tracker": True, + "update_settings": True, + "update_custom_twitter_permissions": True, + }, + "currency": {"name": "United States Dollar", "symbol": "$", "iso_code": "USD"}, + }, + ) + + +def random_user() -> User: + return User.parse_obj( + { + "id": random.randint(1, 1000), + "email": random_email(), + "name": None, + "main_account_id": random.randint(1, 1000), + "main_account_type": "Account", + "created_by": None, + "created_at": random_timestamp(-30 * 86400, -10 * 86400).isoformat(), + "updated_at": random_timestamp(-10 * 86400, -1 * 86400).isoformat(), + "authentication_token": random_string(20), + "locale": "en", + "uses_next": False, + "api_access": None, + "first_name": "Reader", + "last_name": "Account", + "super_admin": False, + "salesforce_sync_failed": False, + "ct_role": None, + "timezone_id": 172, + "uses_dash": True, + "sso": False, + "direct_otp": None, + "direct_otp_sent_at": None, + "encrypted_otp_secret_key": None, + "encrypted_otp_secret_key_iv": None, + "encrypted_otp_secret_key_salt": None, + "agency": False, + } + ) + + +def random_callbacks(callback_count: int, custom_count: int = 0) -> list[RawCallback]: + sampled_names = random.sample(list(callback_names.keys()), callback_count) + sampled_custom = [random.randint(0, 10_000_000) for _ in range(custom_count)] + generated = [random_callback(name) for name in sampled_names + sampled_custom] + return sorted(generated, key=lambda c: c.index) + + +def random_callback_url(placeholder_count: int) -> str: + placeholders = random.sample(placeholder_names, placeholder_count) + scheme = random.choice(["http", "https"]) + netloc = random_domain() + path = "/".join(random_string(10) for _ in range(random.randint(1, 4))) + query = "&".join(f"{p}={{{p}}}" for p in placeholders) + return urlunsplit((scheme, netloc, path, query, "")) + + +def random_callback(id: Optional[str | int]) -> RawCallback: + id = id or random.choice(list(callback_names.keys())) + name = callback_names.get(id) if isinstance(id, str) else random_string(10) + return RawCallback.parse_obj( + { + "id": id, + "name": name, + "url": random_callback_url(random.randint(2, 4)), + "custom": isinstance(id, int), + } + ) + + +callback_names = { + "install": "Install", + "reattribution": "Reattribution", + "click": "Click", + "session": "Session", + "global": "Global", + "rejected_install": "Rejected Install", + "impression": "Impression", + "attribution_update": "Attribution Update", + "rejected_reattribution": "Rejected Reattribution", + "uninstall": "Uninstall", + "reinstall": "Reinstall", + "reattribution_reinstall": "Reattribution Reinstall", + "cost_update": "Cost Update", + "att_consent": "Att Consent", + "gdpr_forget_device": "Gdpr Forget Device", + "san_impression": "San Impression", + "san_click": "San Click", + "ad_revenue": "Ad Revenue", + "sk_install": "Sk Install", + "sk_event": "Sk Event", + "sk_qualifier": "Sk Qualifier", + "sk_cv_update": "Sk Cv Update", + "sk_install_direct": "Sk Install Direct", + "subscription": "Subscription", + "subscription_activation": "Subscription Activation", + "subscription_first_conversion": "Subscription First Conversion", + "subscription_cancellation": "Subscription Cancellation", + "subscription_renewal": "Subscription Renewal", + "subscription_reactivation": "Subscription Reactivation", + "subscription_entered_billing_retry": "Subscription Entered Billing Retry", + "subscription_renewal_from_billing_retry": "Subscription Renewal From Billing Retry", + "256328375": "S2SRevenue", + "1549856936": "Sale", + "103329361": "Signup", + "1621060568": "Signup2", + "282965313": "Social", +} + +placeholder_names = [ + "activity_kind", + "adgroup_name", + "adid", + "android_id", + "api_level", + "app_id", + "app_name", + "app_version", + "app_version_raw", + "app_version_short", + "attribution_expires_at", + "attribution_ttl", + "callback_ttl", + "campaign_name", + "city", + "click_attribution_window", + "click_referer", + "click_time", + "connection_type", + "conversion_duration", + "cost_amount", + "cost_currency", + "cost_id_md5", + "cost_type", + "country", + "country_subdivision", + "cpu_type", + "created_at", + "created_at_milli", + "creative_name", + "dbm_campaign_type", + "dbm_creative_id", + "dbm_exchange_id", + "dbm_external_customer_id", + "dbm_insertion_order_id", + "dbm_line_item_id", + "dbm_line_item_name", + "dcm_campaign_type", + "dcm_creative_id", + "dcm_external_customer_id", + "dcm_placement_id", + "dcm_placement_name", + "dcm_site_id", + "dcp_adset_id", + "dcp_adset_name", + "dcp_bundle_id", + "dcp_campaign_id", + "dcp_creative_id", + "dcp_creative_size", + "dcp_creative_type", + "dcp_custom_field_1", + "dcp_custom_field_2", + "dcp_custom_field_3", + "dcp_custom_field_4", + "dcp_custom_field_5", + "dcp_developer_id", + "dcp_developer_name", + "dcp_exchange", + "dcp_placement", + "dcp_promotion_name", + "dcp_subpub_name", + "dcp_subpubid", + "deeplink", + "device_atlas_id", + "device_manufacturer", + "device_name", + "device_type", + "engagement_time", + "environment", + "external_device_id_md5", + "fb_install_referrer", + "fb_install_referrer_account_id", + "fb_install_referrer_ad_id", + "fb_install_referrer_ad_objective_name", + "fb_install_referrer_adgroup_id", + "fb_install_referrer_adgroup_name", + "fb_install_referrer_campaign_group_id", + "fb_install_referrer_campaign_group_name", + "fb_install_referrer_campaign_id", + "fb_install_referrer_campaign_name", + "fb_install_referrer_publisher_platform", + "fingerprint_attribution_window", + "fire_adid", + "first_tracker", + "first_tracker_name", + "gclid", + "gmp_product_type", + "google_ads_ad_type", + "google_ads_adgroup_id", + "google_ads_adgroup_name", + "google_ads_campaign_id", + "google_ads_campaign_name", + "google_ads_campaign_type", + "google_ads_creative_id", + "google_ads_external_customer_id", + "google_ads_keyword", + "google_ads_matchtype", + "google_ads_network_subtype", + "google_ads_network_type", + "google_ads_placement", + "google_ads_video_id", + "gps_adid", + "hardware_name", + "iad_ad_id", + "iad_conversion_type", + "iad_creative_set_id", + "iad_creative_set_name", + "iad_keyword_matchtype", + "idfa", + "idfv", + "impression_attribution_window", + "impression_based", + "impression_time", + "install_begin_time", + "install_finish_time", + "installed_at", + "is_imported", + "is_organic", + "is_reattributed", + "is_s2s_engagement_based", + "isp", + "label", + "language", + "lifetime_session_count", + "mac_md5", + "match_type", + "mcc", + "mnc", + "network_name", + "network_type", + "nonce", + "oaid", + "os_name", + "os_version", + "partner_parameters", + "platform", + "postal_code", + "probmatching_attribution_window", + "publisher_parameters", + "push_token", + "random_user_id", + "received_at", + "referral_time", + "referrer", + "reftag", + "reftags", + "region", + "rejection_reason", + "reporting_cost", + "san_engagement_times", + "sdk_version", + "search_term", + "secret_id", + "session_count", + "store", + "timezone", + "tracker", + "tracker_name", + "tracking_enabled", + "tracking_limited", + "tweet_id", + "twitter_line_item_id", + "web_uuid", + "within_callback_ttl", +] + + +class HelpOption(BaseModel): + names: list[str] + help: str + + +class HelpCommand(BaseModel): + name: str + help: str + + +class HelpOutput(BaseModel): + help: str = "" + options: list[HelpOption] = [] + commands: list[HelpCommand] = [] + + +def parse_help(result: str) -> HelpOutput: + updating: Optional[str] = None + rv = HelpOutput() + for line in result.splitlines(): + line = line.strip() + if not line: + continue + if line.startswith("Usage:"): + updating = "help" + elif line.startswith("Options:"): + updating = "options" + elif line.startswith("Commands:"): + updating = "commands" + elif updating: + if updating == "help": + if rv.help: + rv.help += "\n" + rv.help += line + parts = line.split(" ", 1) + if len(parts) != 2: + continue + elif updating == "options": + rv.options.append(HelpOption(names=parts[0].split(", "), help=parts[1].strip())) + elif updating == "commands": + rv.commands.append(HelpCommand(name=parts[0], help=parts[1].strip())) + return rv + + +def recursive_directory_listing(path: str) -> dict[str, str]: + import hashlib + + files = {} + for root, _, filenames in os.walk(path): + for filename in filenames: + file_path = os.path.join(root, filename) + md5_hash = hashlib.md5(open(file_path, "rb").read()).hexdigest() + relative_path = os.path.relpath(file_path, path) + files[relative_path] = md5_hash + return files