diff --git a/fixattiosync/__main__.py b/fixattiosync/__main__.py index 2ec2ddd..3b03f9d 100644 --- a/fixattiosync/__main__.py +++ b/fixattiosync/__main__.py @@ -1,13 +1,32 @@ import sys from .logger import add_args as logging_add_args, log from .args import parse_args +from .fixdata import FixData, add_args as fixdata_add_args +from .attiodata import AttioData, add_args as attio_add_args +from .sync import sync_fix_to_attio +from pprint import pprint def main() -> None: - args = parse_args([logging_add_args]) + args = parse_args([logging_add_args, attio_add_args, fixdata_add_args]) + if args.attio_api_key is None: + log.error("Attio API key is required") + sys.exit(1) + if args.password is None: + log.error("Database password is required") + sys.exit(1) + exit_code = 0 log.info("Starting Fix Attio Sync") + fix = FixData(db=args.db, user=args.user, password=args.password, host=args.host, port=args.port) + fix.hydrate() + + attio = AttioData(args.attio_api_key) + attio.hydrate() + + sync_fix_to_attio(fix, attio) + log.info("Shutdown complete") sys.exit(exit_code) diff --git a/fixattiosync/args.py b/fixattiosync/args.py index 63fb305..2a369d2 100644 --- a/fixattiosync/args.py +++ b/fixattiosync/args.py @@ -1,10 +1,9 @@ -import os from argparse import ArgumentParser, Namespace from typing import Callable, List def parse_args(add_args: List[Callable[[ArgumentParser], None]]) -> Namespace: - arg_parser = ArgumentParser(prog="fixattiosync", description="Attio Sync") + arg_parser = ArgumentParser(prog="fixattiosync", description="Fix Attio Sync") for add_arg in add_args: add_arg(arg_parser) diff --git a/fixattiosync/attiodata.py b/fixattiosync/attiodata.py new file mode 100644 index 0000000..08e2c1a --- /dev/null +++ b/fixattiosync/attiodata.py @@ -0,0 +1,144 @@ +import os +import requests +from uuid import UUID +from typing import Union +from argparse import ArgumentParser +from .logger import log +from .attioresources import AttioWorkspace, AttioPerson, AttioUser + + +class AttioData: + def __init__(self, api_key, default_limit=500): + self.api_key = api_key + self.base_url = "https://api.attio.com/v2/" + self.default_limit = default_limit + self.hydrated = False + self.__workspaces = {} + self.__people = {} + self.__users = {} + + def _headers(self): + return { + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json", + "Accept": "application/json", + } + + def _post_data(self, endpoint, json=None, params=None): + log.debug(f"Fetching data from {endpoint}") + url = self.base_url + endpoint + response = requests.post(url, headers=self._headers(), json=json, params=params) + if response.status_code == 200: + return response.json() + else: + raise Exception(f"Error fetching data from {url}: {response.status_code} {response.text}") + + def _put_data(self, endpoint: str, json: dict = None, params: dict = None): + log.debug(f"Putting data to {endpoint}") + url = self.base_url + endpoint + response = requests.put(url, headers=self._headers(), json=json, params=params) + if response.status_code == 200: + return response.json() + else: + raise Exception(f"Error putting data to {url}: {response.status_code} {response.text}") + + def assert_record( + self, object_id: str, matching_attribute: str, data: dict + ) -> Union[AttioPerson, AttioUser, AttioWorkspace]: + endpoint = f"objects/{object_id}/records" + params = {"matching_attribute": matching_attribute} + match object_id: + case "users": + attio_cls = AttioUser + self_store = self.__users + case "people": + attio_cls = AttioPerson + self_store = self.__people + case "workspaces": + attio_cls = AttioWorkspace + self_store = self.__workspaces + case _: + raise ValueError(f"Unknown object_id: {object_id}") + + response = self._put_data(endpoint, params=params, json=data) + + if response.get("data", []): + attio_obj = attio_cls.make(response["data"]) + log.debug(f"Asserted {object_id} {attio_obj} in Attio, updating locally") + self_store[attio_obj.record_id] = attio_obj + return attio_obj + else: + raise RuntimeError(f"Error asserting {object_id} in Attio: {response}") + + def _records(self, object_id: str): + log.debug(f"Fetching {object_id}") + endpoint = f"objects/{object_id}/records/query" + all_data = [] + offset = 0 + + while True: + params = {"limit": self.default_limit, "offset": offset} + response_data = self._post_data(endpoint, params) + data = response_data.get("data", []) + all_data.extend(data) + + if len(data) < self.default_limit: + break + + offset += self.default_limit + log.debug(f"Found {len(all_data)} {object_id} in Attio") + return all_data + + @property + def workspaces(self): + if not self.hydrated: + self.hydrate() + return list(self.__workspaces.values()) + + @property + def people(self): + if not self.hydrated: + self.hydrate() + return list(self.__people.values()) + + @property + def users(self): + if not self.hydrated: + self.hydrate() + return list(self.__users.values()) + + def hydrate(self): + log.debug("Hydrating Attio data") + self.__workspaces = self.__marshal(self._records("workspaces"), AttioWorkspace) + self.__people = self.__marshal(self._records("people"), AttioPerson) + self.__users = self.__marshal(self._records("users"), AttioUser) + self.__connect() + self.hydrated = True + + def __connect(self): + for user in self.__users.values(): + if user.person_id in self.__people: + person = self.__people[user.person_id] + person.users.append(user) + user.person = person + if user.workspace_refs is not None and len(user.workspace_refs) > 0: + for workspace_ref in user.workspace_refs: + if workspace_ref in self.__workspaces: + workspace = self.__workspaces[workspace_ref] + workspace.users.append(user) + user.workspaces.append(workspace) + + def __marshal( + self, data: dict, cls: Union[AttioWorkspace, AttioPerson, AttioUser] + ) -> dict[UUID, Union[AttioWorkspace, AttioPerson, AttioUser]]: + ret = {} + for item in data: + obj = cls.make(item) + ret[obj.record_id] = obj + return ret + + +def add_args(arg_parser: ArgumentParser) -> None: + arg_parser.add_argument( + "--api-key", dest="attio_api_key", help="Attio API Key", default=os.environ.get("ATTIO_API_KEY", None) + ) diff --git a/fixattiosync/attioresources.py b/fixattiosync/attioresources.py new file mode 100644 index 0000000..b3e64d6 --- /dev/null +++ b/fixattiosync/attioresources.py @@ -0,0 +1,216 @@ +from __future__ import annotations +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from datetime import datetime +from uuid import UUID +from typing import Optional, Self, Type, ClassVar +from .logger import log + + +def get_latest_value(value: list[dict]) -> dict: + if value and len(value) > 0: + return value[0] + return {} + + +def optional_uuid(value: str) -> Optional[UUID]: + uuid = None + try: + uuid = UUID(value) + except (ValueError, TypeError): + pass + return uuid + + +@dataclass +class AttioResource(ABC): + matching_attribute: ClassVar[str] = "record_id" + + object_id: UUID + record_id: UUID + workspace_id: UUID + created_at: datetime + + @classmethod + @abstractmethod + def make(cls: Type[Self], data: dict) -> Self: + pass + + +@dataclass +class AttioWorkspace(AttioResource): + matching_attribute: ClassVar[str] = "workspace_id" + + id: Optional[UUID] + name: Optional[str] + tier: Optional[str] + status: Optional[str] + fix_workspace_id: Optional[UUID] + users: list[AttioUser] = field(default_factory=list) + + def __eq__(self, other): + return self.id == other.id and self.tier == other.tier + + @classmethod + def make(cls: Type[Self], data: dict) -> Self: + object_id = UUID(data["id"]["object_id"]) + record_id = UUID(data["id"]["record_id"]) + workspace_id = UUID(data["id"]["workspace_id"]) + created_at = datetime.fromisoformat(data["created_at"].rstrip("Z")) + + values = data.get("values", {}) + + name_info = get_latest_value(values.get("name", [{}])) + name = name_info.get("value") + + product_tier_info = get_latest_value(values.get("product_tier", [{}])) + product_tier = product_tier_info.get("option", {}).get("title") + + status_info = get_latest_value(values.get("status", [{}])) + status = status_info.get("status", {}).get("title") + + fix_workspace_id_info = get_latest_value(values.get("workspace_id", [{}])) + fix_workspace_id = optional_uuid(fix_workspace_id_info.get("value")) + if fix_workspace_id is None: + log.error(f"Fix workspace ID not found for {record_id}: {data}") + + cls_data = { + "id": fix_workspace_id, + "object_id": object_id, + "record_id": record_id, + "workspace_id": workspace_id, + "created_at": created_at, + "name": name, + "tier": product_tier, + "status": status, + "fix_workspace_id": fix_workspace_id, + } + + return cls(**cls_data) + + +@dataclass +class AttioPerson(AttioResource): + matching_attribute: ClassVar[str] = "email_addresses" + + full_name: Optional[str] + first_name: Optional[str] + last_name: Optional[str] + email: Optional[str] + linkedin: Optional[str] + job_title: Optional[str] + users: list[AttioUser] = field(default_factory=list) + + @classmethod + def make(cls: Type[Self], data: dict) -> Self: + object_id = UUID(data["id"]["object_id"]) + record_id = UUID(data["id"]["record_id"]) + workspace_id = UUID(data["id"]["workspace_id"]) + created_at = datetime.fromisoformat(data["created_at"].rstrip("Z")) + + values = data["values"] + + name_info = get_latest_value(values.get("name", [{}])) + full_name = name_info.get("full_name") + first_name = name_info.get("first_name") + last_name = name_info.get("last_name") + + email_info = get_latest_value(values.get("email_addresses", [{}])) + email_address = email_info.get("email_address") + + job_title_info = get_latest_value(values.get("job_title", [{}])) + job_title = job_title_info.get("value") + + linkedin_info = get_latest_value(values.get("linkedin", [{}])) + linkedin = linkedin_info.get("value") + + cls_data = { + "object_id": object_id, + "record_id": record_id, + "workspace_id": workspace_id, + "created_at": created_at, + "full_name": full_name, + "first_name": first_name, + "last_name": last_name, + "email": email_address, + "job_title": job_title, + "linkedin": linkedin, + } + + return cls(**cls_data) + + +@dataclass +class AttioUser(AttioResource): + matching_attribute: ClassVar[str] = "user_id" + + id: Optional[UUID] + demo_workspace_viewed: Optional[bool] + email: Optional[str] + registered_at: Optional[datetime] + status: Optional[str] + user_id: Optional[UUID] + person_id: Optional[UUID] + workspace_refs: Optional[list[UUID]] = None + person: Optional[AttioPerson] = None + workspaces: list[AttioWorkspace] = field(default_factory=list) + + def __eq__(self, other): + return self.id == other.id and self.email.lower() == other.email.lower() + + @classmethod + def make(cls: Type[Self], data: dict) -> Self: + object_id = UUID(data["id"]["object_id"]) + record_id = UUID(data["id"]["record_id"]) + workspace_id = UUID(data["id"]["workspace_id"]) + created_at = datetime.fromisoformat(data["created_at"].rstrip("Z")) + + values = data.get("values", {}) + + email_info = get_latest_value(values.get("primary_email_address", [{}])) + primary_email_address = email_info.get("email_address") + + status_info = get_latest_value(values.get("status", [{}])) + status = status_info.get("status", {}).get("title") + + user_id_info = get_latest_value(values.get("user_id", [{}])) + user_id = optional_uuid(user_id_info["value"]) + if user_id is None: + log.error(f"Fix user ID not found for {record_id}: {data}") + + person_info = get_latest_value(values.get("person", [{}])) + person_id = optional_uuid(person_info.get("target_record_id")) + + workspace_refs = None + workspace_info = values.get("workspace", []) + for workspace in workspace_info: + workspace_ref = optional_uuid(workspace.get("target_record_id")) + if workspace_refs is None: + workspace_refs = [] + workspace_refs.append(workspace_ref) + + cls_data = { + "id": user_id, + "object_id": object_id, + "record_id": record_id, + "workspace_id": workspace_id, + "created_at": created_at, + "demo_workspace_viewed": None, + "email": primary_email_address, + "registered_at": None, + "status": status, + "user_id": user_id, + "person_id": person_id, + "workspace_refs": workspace_refs, + } + + return cls(**cls_data) + + def create_or_update(self) -> tuple[str, dict]: + data = { + "values": { + "primary_email_address": [{"email_address": self.email}], + "user_id": [{"value": str(self.id)}], + } + } + return f"objects/users/records?matching_attribute={self.id}", data diff --git a/fixattiosync/fixdata.py b/fixattiosync/fixdata.py new file mode 100644 index 0000000..6396740 --- /dev/null +++ b/fixattiosync/fixdata.py @@ -0,0 +1,100 @@ +import os +import psycopg +from psycopg.rows import dict_row +from argparse import ArgumentParser +from .logger import log +from .fixresources import FixUser, FixWorkspace + + +class FixData: + def __init__(self, db, user, password, host="localhost", port=5432): + self.db = db + self.user = user + self.password = password + self.host = host + self.port = port + self.conn = None + self.hydrated = False + self.__workspaces = {} + self.__users = {} + + @property + def users(self) -> list[FixUser]: + if not self.hydrated: + self.hydrate() + return list(self.__users.values()) + + @property + def workspaces(self) -> list[FixWorkspace]: + if not self.hydrated: + self.hydrate() + return list(self.__workspaces.values()) + + def connect(self): + log.debug("Connecting to the database") + if self.conn is None: + try: + self.conn = psycopg.connect( + dbname=self.db, user=self.user, password=self.password, host=self.host, port=self.port + ) + log.debug("Connection successful") + except psycopg.DatabaseError as e: + log.error(f"Error connecting to the database: {e}") + self.conn = None + + def hydrate(self): + if self.conn is None: + self.connect() + + log.debug("Hydrating Fix database data") + if self.conn is not None: + try: + with self.conn.cursor(row_factory=dict_row) as cursor: + cursor.execute('SELECT * FROM public."user";') + rows = cursor.fetchall() + for row in rows: + user = FixUser(**row) + self.__users[user.id] = user + with self.conn.cursor(row_factory=dict_row) as cursor: + cursor.execute('SELECT * FROM public."organization";') + rows = cursor.fetchall() + for row in rows: + workspace = FixWorkspace(**row) + self.__workspaces[workspace.id] = workspace + with self.conn.cursor(row_factory=dict_row) as cursor: + cursor.execute('SELECT * FROM public."organization_owners";') + rows = cursor.fetchall() + for row in rows: + self.__workspaces[row["organization_id"]].owner = self.__users[row["user_id"]] + with self.conn.cursor(row_factory=dict_row) as cursor: + cursor.execute('SELECT * FROM public."organization_members";') + rows = cursor.fetchall() + for row in rows: + self.__workspaces[row["organization_id"]].users.append(self.__users[row["user_id"]]) + self.__users[row["user_id"]].workspaces.append(self.__workspaces[row["organization_id"]]) + except psycopg.Error as e: + log.error(f"Error fetching data: {e}") + return None + finally: + self.close() + log.debug(f"Found {len(self.__workspaces)} workspaces in database") + log.debug(f"Found {len(self.__users)} users in database") + self.hydrated = True + + def close(self): + if self.conn is not None: + log.debug("Closing database connection") + self.conn.close() + + +def add_args(arg_parser: ArgumentParser) -> None: + arg_parser.add_argument("--db", dest="db", help="Database name", default="fix-database") + arg_parser.add_argument("--user", dest="user", help="Database user", default="fixuser") + arg_parser.add_argument( + "--password", + dest="password", + help="Database password", + default=os.environ.get("PGPASSWORD", None), + ) + arg_parser.add_argument("--host", dest="host", help="Database host", default="localhost") + arg_parser.add_argument("--port", dest="port", help="Database port", default=5432, type=int) diff --git a/fixattiosync/fixresources.py b/fixattiosync/fixresources.py new file mode 100644 index 0000000..8ca52a0 --- /dev/null +++ b/fixattiosync/fixresources.py @@ -0,0 +1,109 @@ +from __future__ import annotations +from dataclasses import dataclass, field +from datetime import datetime +from uuid import UUID +from typing import Optional +from .attioresources import AttioPerson, AttioWorkspace + + +@dataclass +class FixUser: + id: UUID + email: str + hashed_password: str + is_active: bool + is_superuser: bool + is_verified: bool + otp_secret: Optional[str] + is_mfa_active: Optional[bool] + created_at: datetime + updated_at: datetime + workspaces: list[FixWorkspace] = field(default_factory=list) + + def __eq__(self, other): + return self.id == other.id and self.email.lower() == other.email.lower() + + def attio_data( + self, person: Optional[AttioPerson] = None, workspaces: Optional[list[AttioWorkspace]] = None + ) -> dict: + object_id = "users" + matching_attribute = "user_id" + data = { + "data": { + "values": { + "user_id": str(self.id), + "primary_email_address": [{"email_address": self.email}], + "status": "Signed up" if self.is_active else "Invited", + } + } + } + if person: + data["data"]["values"]["person"] = { + "target_object": "people", + "target_record_id": str(person.record_id), + } + if workspaces: + data["data"]["values"]["workspace"] = [] + for workspace in workspaces: + data["data"]["values"]["workspace"].append( + { + "target_object": "workspaces", + "target_record_id": str(workspace.record_id), + } + ) + + return { + "object_id": object_id, + "matching_attribute": matching_attribute, + "data": data, + } + + def attio_person(self) -> dict: + object_id = "people" + matching_attribute = "email_addresses" + data = {"data": {"values": {"email_addresses": [{"email_address": self.email}]}}} + return { + "object_id": object_id, + "matching_attribute": matching_attribute, + "data": data, + } + + +@dataclass +class FixWorkspace: + id: UUID + slug: str + name: str + external_id: UUID + tier: str + subscription_id: Optional[UUID] + payment_on_hold_since: Optional[datetime] + created_at: datetime + updated_at: datetime + owner_id: UUID + highest_current_cycle_tier: Optional[str] + current_cycle_ends_at: Optional[datetime] + tier_updated_at: Optional[datetime] + owner: Optional[FixUser] = None + users: list[FixUser] = field(default_factory=list) + + def __eq__(self, other): + return self.id == other.id and self.name == other.name and self.tier == other.tier + + def attio_data(self) -> dict: + object_id = "workspaces" + matching_attribute = "workspace_id" + data = { + "data": { + "values": { + "workspace_id": str(self.id), + "name": self.name, + "product_tier": self.tier, + } + } + } + return { + "object_id": object_id, + "matching_attribute": matching_attribute, + "data": data, + } diff --git a/fixattiosync/sync.py b/fixattiosync/sync.py new file mode 100644 index 0000000..9320d93 --- /dev/null +++ b/fixattiosync/sync.py @@ -0,0 +1,160 @@ +from .logger import log +from .attiodata import AttioData +from .fixdata import FixData +from .fixresources import FixUser, FixWorkspace +from .attioresources import AttioUser, AttioWorkspace + + +def sync_fix_to_attio(fix: FixData, attio: AttioData) -> None: + workspaces_missing = workspaces_missing_in_attio(fix, attio) + users_missing = users_missing_in_attio(fix, attio) + obsolete_workspaces = workspaces_no_longer_in_fix(fix, attio) + obsolete_users = users_no_longer_in_fix(fix, attio) + users_outdated = users_outdated_in_attio(fix, attio) + workspaces_outdated = workspaces_outdated_in_attio(fix, attio) + + for fix_workspace in workspaces_missing: + log.info(f"Creating workspace {fix_workspace.name}") + try: + attio.assert_record(**fix_workspace.attio_data()) + except Exception as e: + log.error(f"Error creating workspace {fix_workspace.name}: {e}") + + for fix_workspace in workspaces_outdated: + log.info(f"Updating workspace {fix_workspace.name}") + try: + attio.assert_record(**fix_workspace.attio_data()) + except Exception as e: + log.error(f"Error updating workspace {fix_workspace.name}: {e}") + + for user in users_missing: + log.info(f"Asserting person {user.email}") + try: + attio_person = attio.assert_record(**user.attio_person()) + workspace_ids = [workspace.id for workspace in user.workspaces] + attio_workspaces = [ + attio_workspace + for attio_workspace in attio.workspaces + if attio_workspace.fix_workspace_id in workspace_ids + ] + try: + attio_user = attio.assert_record(**user.attio_data(attio_person, attio_workspaces)) + attio_user.person = attio_person + attio_person.users.append(attio_user) + attio_user.workspaces.extend(attio_workspaces) + for attio_workspace in attio_workspaces: + attio_workspace.users.append(attio_user) + except Exception as e: + log.error(f"Error asserting user {user.email}: {e}") + except Exception as e: + log.error(f"Error asserting person {user.email}: {e}") + + for user in users_outdated: + attio_user = None + for au in attio.users: + if au.id == user.id: + attio_user = au + break + if attio_user is None: + log.error(f"User {user.email} ({user.id}) not found in Attio - skipping") + continue + log.info(f"Updating user {user.email}") + attio_person = attio_user.person + workspace_ids = [workspace.id for workspace in user.workspaces] + attio_workspaces = [ + attio_workspace for attio_workspace in attio.workspaces if attio_workspace.fix_workspace_id in workspace_ids + ] + try: + attio_user = attio.assert_record(**user.attio_data(attio_person, attio_workspaces)) + except Exception as e: + log.error(f"Error updating user {user.email}: {e}") + + +def workspaces_missing_in_attio(fix: FixData, attio: AttioData) -> list[FixWorkspace]: + fix_workspace_ids = {workspace.id for workspace in fix.workspaces} + attio_workspace_ids = {workspace.id for workspace in attio.workspaces} + + missing = fix_workspace_ids - attio_workspace_ids + + log.debug(f"Number of workspaces missing in Attio: {len(missing)}") + + return [fix_workspace for fix_workspace in fix.workspaces if fix_workspace.id in missing] + + +def users_missing_in_attio(fix: FixData, attio: AttioData) -> list[FixUser]: + fix_user_ids = {user.id for user in fix.users} + attio_user_ids = {user.id for user in attio.users} + + missing = fix_user_ids - attio_user_ids + + log.debug(f"Number of users missing in Attio: {len(missing)}") + + return [fix_user for fix_user in fix.users if fix_user.id in missing] + + +def users_no_longer_in_fix(fix: FixData, attio: AttioData) -> list[AttioUser]: + fix_user_ids = {user.id for user in fix.users} + attio_user_ids = {user.id for user in attio.users} + + missing = attio_user_ids - fix_user_ids + + log.debug(f"Number of users no longer in Fix: {len(missing)}") + + return [attio_user for attio_user in attio.users if attio_user.id in missing] + + +def workspaces_no_longer_in_fix(fix: FixData, attio: AttioData) -> list[AttioWorkspace]: + fix_workspace_ids = {workspace.id for workspace in fix.workspaces} + attio_workspace_ids = {workspace.id for workspace in attio.workspaces} + + missing = attio_workspace_ids - fix_workspace_ids + + log.debug(f"Number of workspaces no longer in Fix: {len(missing)}") + + return [attio_workspace for attio_workspace in attio.workspaces if attio_workspace.id in missing] + + +def users_outdated_in_attio(fix: FixData, attio: AttioData) -> list[FixUser]: + fix_user_ids = {user.id for user in fix.users} + attio_user_ids = {user.id for user in attio.users} + + common_user_ids = fix_user_ids & attio_user_ids + + fix_users_by_id = {user.id: user for user in fix.users} + attio_users_by_id = {user.id: user for user in attio.users} + + outdated = set() + + for user_id in common_user_ids: + fix_user = fix_users_by_id[user_id] + attio_user = attio_users_by_id[user_id] + + if fix_user != attio_user: + outdated.add(user_id) + + log.debug(f"Number of outdated users in Attio: {len(outdated)}") + + return [fix_user for fix_user in fix.users if fix_user.id in outdated] + + +def workspaces_outdated_in_attio(fix: FixData, attio: AttioData) -> list[FixWorkspace]: + fix_workspace_ids = {workspace.id for workspace in fix.workspaces} + attio_workspace_ids = {workspace.id for workspace in attio.workspaces} + + common_workspace_ids = fix_workspace_ids & attio_workspace_ids + + fix_workspaces_by_id = {workspace.id: workspace for workspace in fix.workspaces} + attio_workspaces_by_id = {workspace.id: workspace for workspace in attio.workspaces} + + outdated = set() + + for workspace_id in common_workspace_ids: + fix_workspace = fix_workspaces_by_id[workspace_id] + attio_workspace = attio_workspaces_by_id[workspace_id] + + if fix_workspace != attio_workspace: + outdated.add(workspace_id) + + log.debug(f"Number of outdated workspaces in Attio: {len(outdated)}") + + return [fix_workspace for fix_workspace in fix.workspaces if fix_workspace.id in outdated] diff --git a/pyproject.toml b/pyproject.toml index bdfa939..10b3c08 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,7 +33,7 @@ dependencies = [ ] [project.scripts] -fixbackup = "fixbackup.__main__:main" +fixattiosync = "fixattiosync.__main__:main" [project.optional-dependencies] test = [ diff --git a/requirements-test.txt b/requirements-test.txt index 3bb507d..06985e7 100644 --- a/requirements-test.txt +++ b/requirements-test.txt @@ -105,7 +105,7 @@ typing-extensions==4.12.2 # via # mypy # psycopg -urllib3==2.2.2 +urllib3==2.2.3 # via requests virtualenv==20.26.4 # via tox diff --git a/requirements.txt b/requirements.txt index aa504c9..a061e75 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,5 +12,5 @@ requests==2.32.3 # via fixattiosync (pyproject.toml) typing-extensions==4.12.2 # via psycopg -urllib3==2.2.2 +urllib3==2.2.3 # via requests