From b7311d088d43a0956cf00a3dd308e60dc9389262 Mon Sep 17 00:00:00 2001 From: Marc Koch Date: Thu, 20 Mar 2025 15:32:30 +0100 Subject: [PATCH] =?UTF-8?q?=F0=9F=94=96=20Bump=20version:=200.1.0=20?= =?UTF-8?q?=E2=86=92=201.0.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env | 0 .gitignore | 270 +++++++++ .idea/adGroupSync.iml | 3 +- .idea/misc.xml | 5 +- .python-version | 1 + CiviCRM/models.py | 0 README.md | 72 +++ adGroupSync/__init__.py | 0 adGroupSync/main.py | 109 ---- adGroupSync/models.py | 92 --- pyproject.toml | 64 ++ {CiviCRM => src}/__init__.py | 0 src/adgroupsync/__init__.py | 2 + src/adgroupsync/__main__.py | 289 +++++++++ src/adgroupsync/conf.py | 128 ++++ src/adgroupsync/enums.py | 11 + {adGroupSync => src/adgroupsync}/logger.py | 70 ++- src/adgroupsync/models.py | 548 ++++++++++++++++++ src/adgroupsync/resources/example_config.yml | 25 + .../resources}/logging_config.json | 6 +- uv.lock | 521 +++++++++++++++++ 21 files changed, 1993 insertions(+), 223 deletions(-) delete mode 100644 .env create mode 100644 .gitignore create mode 100644 .python-version delete mode 100644 CiviCRM/models.py delete mode 100644 adGroupSync/__init__.py delete mode 100644 adGroupSync/main.py delete mode 100644 adGroupSync/models.py create mode 100644 pyproject.toml rename {CiviCRM => src}/__init__.py (100%) create mode 100644 src/adgroupsync/__init__.py create mode 100644 src/adgroupsync/__main__.py create mode 100644 src/adgroupsync/conf.py create mode 100644 src/adgroupsync/enums.py rename {adGroupSync => src/adgroupsync}/logger.py (58%) create mode 100644 src/adgroupsync/models.py create mode 100644 src/adgroupsync/resources/example_config.yml rename {conf => src/adgroupsync/resources}/logging_config.json (90%) create mode 100644 uv.lock diff --git a/.env b/.env deleted file mode 100644 index e69de29..0000000 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1365052 --- /dev/null +++ b/.gitignore @@ -0,0 +1,270 @@ + +adgroupsync_config.yml + +# Created by https://www.toptal.com/developers/gitignore/api/pycharm+all +# Edit at https://www.toptal.com/developers/gitignore?templates=pycharm+all + +### Python ### +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +### Python Patch ### +# Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration +poetry.toml + +# ruff +.ruff_cache/ + +# LSP config files +pyrightconfig.json + +# End of https://www.toptal.com/developers/gitignore/api/python + +### PyCharm+all ### +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/**/usage.statistics.xml +.idea/**/dictionaries +.idea/**/shelf + +# AWS User-specific +.idea/**/aws.xml + +# Generated files +.idea/**/contentModel.xml + +# Sensitive or high-churn files +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml +.idea/**/dbnavigator.xml + +# Gradle +.idea/**/gradle.xml +.idea/**/libraries + +# Gradle and Maven with auto-import +# When using Gradle or Maven with auto-import, you should exclude module files, +# since they will be recreated, and may cause churn. Uncomment if using +# auto-import. +# .idea/artifacts +# .idea/compiler.xml +# .idea/jarRepositories.xml +# .idea/modules.xml +# .idea/*.iml +# .idea/modules +# *.iml +# *.ipr + +# CMake +cmake-build-*/ + +# Mongo Explorer plugin +.idea/**/mongoSettings.xml + +# File-based project format +*.iws + +# IntelliJ +out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Cursive Clojure plugin +.idea/replstate.xml + +# SonarLint plugin +.idea/sonarlint/ + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +# Editor-based Rest Client +.idea/httpRequests + +# Android studio 3.1+ serialized cache file +.idea/caches/build_file_checksums.ser + +### PyCharm+all Patch ### +# Ignore everything but code style settings and run configurations +# that are supposed to be shared within teams. + +.idea/* + +!.idea/codeStyles +!.idea/runConfigurations + +# End of https://www.toptal.com/developers/gitignore/api/pycharm+all + diff --git a/.idea/adGroupSync.iml b/.idea/adGroupSync.iml index 7c6dd0c..def5902 100644 --- a/.idea/adGroupSync.iml +++ b/.idea/adGroupSync.iml @@ -2,9 +2,10 @@ + - + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml index 2e8a6a7..19c3c40 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -1,7 +1,10 @@ + + - + \ No newline at end of file diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..e4fba21 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/CiviCRM/models.py b/CiviCRM/models.py deleted file mode 100644 index e69de29..0000000 diff --git a/README.md b/README.md index 7f855bc..0f88c2f 100644 --- a/README.md +++ b/README.md @@ -1 +1,73 @@ # adGroupSync + +This program synchronizes Active Directory groups with CiviCRM groups. +It is designed to be run as a cron job. + +## Installation via pipx + +```bash +pipx install --include-deps --index-url https://git.propeace.de/api/packages/ProPeace/pypi/simple/ --pip-args='--extra-index-url https://pypi.org/simple/' adgroupsync +``` + +## Configuration + +Create a new configuration file: + +```bash +adgroupsync --create-config --conf /path/to/adgroupsync_config.yaml +``` + +Edit the configuration file and set the following values: + +### AD Configuration + +- `AD.DOMAIN`: The domain of the Active Directory server. +- `AD.USER`: The username of the user to connect to the Active Directory server. +- `AD.PASSWORD`: The password of the user to connect to the Active Directory + server. +- `AD.LDAP_SERVER`: The LDAP server of the Active Directory server. +- `AD.PARENT_GROUP`: The parent group in Active Directory that contains all + groups that should be synchronized. +- `AD.TIMEZONE`: The timezone of the Active Directory server. + +### Civicrm Configuration + +- `CIVICRM.BASE_URL`: The URL of the CiviCRM server. +- `CIVICRM.API_KEY`: The API key of the CiviCRM user. +- `CIVICRM.BATCH_SIZE`: The batch size for the API requests to the CiviCRM + server (only applied to contact sync). _DEFAULT: 50_ +- `CIVICRM.RETRIES`: The number of retries for the API requests to the CiviCRM + server. _DEFAULT: 3_ + +### Logging Configuration + +- `LOGGING.STDOUT_LOG_LEVEL`: The log level for the stdout logger. _DEFAULT: + INFO_ +- `LOGGING.FILE_LOG_LEVEL`: The log level for the file logger. _DEFAULT: INFO_ +- `LOGGING.LOG_DIR`: The path to the log file. _DEFAULT: + /var/log/adgroupsync.log_ + +### NTFY (optional) + +If you want to send notifications about failed syncs, you can +configure [ntfy](https://ntfy.sh/). + +- `NTFY.URL`: The URL of the NTFY server. +- `NTFY.TOPIC`: The topic to post the message to. +- `NTFY.ACCESS_TOKEN`: The access token for the NTFY server. + +## Usage + +### Manual Sync + +```bash +adgroupsync --conf /path/to/adgroupsync_config.yaml +``` + +### Cron Job + +Synchronize the groups every 10 minutes: + +```bash +*/10 * * * * adgroupsync --conf /path/to/adgroupsync_config.yaml 2>&1 +``` diff --git a/adGroupSync/__init__.py b/adGroupSync/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/adGroupSync/main.py b/adGroupSync/main.py deleted file mode 100644 index 443789a..0000000 --- a/adGroupSync/main.py +++ /dev/null @@ -1,109 +0,0 @@ -import logging -import os -from datetime import datetime -from pathlib import Path - -import pytz -from ldap3 import SIMPLE -from ms_active_directory import ADDomain, ADGroup, ADUser, ADObject - -from .models import RecentRun - -DOMAIN = os.getenv('AD_DOMAIN') -USER_NAME = os.getenv('AD_USER') -PASSWORD = os.getenv('AD_PASSWORD') -LDAP_SERVER = [s.strip() for s in os.getenv('AD_LDAP_SERVER').split(',')] -TIMEZONE = pytz.timezone(os.getenv('TIMEZONE', 'UTC')) -MAILINGLISTS_PARENT_GROUP = os.getenv('AD_MAILINGLISTS_PARENT_GROUP') - -# Setup logging -logger = logging.getLogger('adGroupSync') -logger.setLevel(os.getenv('LOG_LEVEL', logging.INFO)) - - -def has_changed(object_: ADObject, recent_run): - """ - Check if the object has changed since the last run - :param object_: Object to check - :param recent_run: RecentRun object - :return: True if the object has changed, False otherwise - """ - modify_timestamp = object_.get('modifyTimestamp') - if modify_timestamp is None: - message = (f"Object '{object_.get('sn')}' of type '{type(object_)}' " - "does not have 'modifyTimestamp' attribute") - logger.error(message) - raise ValueError(message) - return modify_timestamp > recent_run - - -def main(): - - logger.info("Running group sync") - - # Get the last run timestamp - recent_run = RecentRun(Path().home() / '.recent_run') - logger.info(f"Last run: {recent_run.datetime.strftime('%Y-%m-%d %H:%M:%S %Z')}") - - # Setup session - domain = ADDomain(DOMAIN, ldap_servers_or_uris=LDAP_SERVER) - logger.debug(f"Domain: {domain}", extra={'domain': domain}) - try: - session = domain.create_session_as_user( - USER_NAME, PASSWORD, authentication_mechanism=SIMPLE) - except Exception as e: - logger.error(f"Error creating session: {e}") - exit(1) - logger.debug(f"Session: {session}") - logger.info(f"Session opened: {session.is_open()}") - - # Get parent group - parent_group = session.find_group_by_name( - MAILINGLISTS_PARENT_GROUP, ['modifyTimestamp']) - parent_group_has_changed = has_changed(parent_group, recent_run) - logger.debug(f"Parent group: {parent_group}", extra={ - 'group': parent_group, - 'modifyTimestamp': parent_group.get('modifyTimestamp'), - 'has_changed': parent_group_has_changed}) - - # Get child groups - group_attrs = [ - 'modifyTimestamp', - 'objectSid', - 'mail', - 'sAMAccountName', - ] - group_members = session.find_members_of_group_recursive( - parent_group, group_attrs)[1] - groups = (m for m in group_members.values() if isinstance(m, ADGroup)) - logger.debug(f"Found {len(groups)} child groups", - extra={'child_group_count': len(groups), - 'child_groups': groups}) - users = (m for m in groups if not isinstance(m, ADUser)) - - # If the parent group has changed, we need to update the child groups - # in CiviCRM - if parent_group_has_changed: - civicrm.update_mailing_lists(groups) - - # Check if the mailing list groups have changed and update them in CiviCRM - for ml_group in groups: - if has_changed(ml_group, recent_run): - civicrm.update_mailing_list_members(ml_group) - - # Check if the users have changed and update them in CiviCRM - for user in users: - if has_changed(user, recent_run): - civicrm.update_user(user) - - # Set the last run timestamp - recent_run.datetime = datetime.now(tz=TIMEZONE) - -if __name__ == '__main__': - try: - main() - except Exception as e: - logger.error(f"Error: {e}") - exit(1) - - diff --git a/adGroupSync/models.py b/adGroupSync/models.py deleted file mode 100644 index 4f1a15c..0000000 --- a/adGroupSync/models.py +++ /dev/null @@ -1,92 +0,0 @@ -from datetime import datetime, tzinfo -from pathlib import Path -import pytz - - -class RecentRun: - """ - Class to manage the last run of the script - """ - - def __init__(self, file_path: Path, timezone: tzinfo = pytz.utc): - """ - Initialize the class - :param file_path: File path to store the last run timestamp - :param timezone: Timezone to use for the timestamp - """ - self._datetime = None - self._timezone = timezone - self._file_path = file_path - - if not self._file_path.exists(): - raise FileNotFoundError(f"File {self._file_path} does not exist") - - self._read_recent_run_from_file() - - def _write_recent_run_to_file(self): - """ - Write the recent run timestamp to the file - :return: - """ - with open(self._file_path, 'w') as f: - f.write(str(self.timestamp)) - - def _read_recent_run_from_file(self): - """ - Read the recent run timestamp from the file - :return: - """ - with open(self._file_path, 'r') as f: - line = f.readline() - if not line or line.strip() == '': - return None - try: - self._datetime = datetime.fromtimestamp(float(line), tz=self._timezone) - except ValueError: - raise ValueError(f"Invalid timestamp '{line}' in {self._file_path}") - - @property - def datetime(self): - return self._datetime - - @property - def timestamp(self): - return self._datetime.timestamp() - - @datetime.setter - def datetime(self, value: datetime): - if value.tzinfo is None: - value = value.astimezone(self._timezone) - self._datetime = value - self._write_recent_run_to_file() - - @staticmethod - def _to_datetime(value: datetime|str|float): - """ - Convert the value to a datetime object - :param value: - :return: - """ - try: - if isinstance(value, str): - value = float(value) - if isinstance(value, float): - value = datetime.fromtimestamp(value) - except ValueError: - raise ValueError(f"Invalid timestamp '{value}'") - return value - - def __gt__(self, other: datetime|str|float): - return self.datetime > self._to_datetime(other) - - def __lt__(self, other: datetime|str|float): - return self.datetime < self._to_datetime(other) - - def __eq__(self, other: datetime|str|float): - return self.datetime == self._to_datetime(other) - - def __ge__(self, other: datetime|str|float): - return self.datetime >= self._to_datetime(other) - - def __le__(self, other: datetime|str|float): - return self.datetime <= self._to_datetime(other) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..d1a41a1 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,64 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "adgroupsync" +version = "1.0.0" +description = "Sync Active Directory groups to CiviCRM" +authors = [ + { name = "Marc Koch", email = "marc.koch@propeace.de" } +] +readme = "README.md" +license = "MIT" +requires-python = ">=3.12" +keywords = ["CiviCRM", "Active Directory"] +dependencies = [ + "civifang>=0.2.6", + "httpx>=0.28.1", + "ms-active-directory>=1.14.1", + "pyyaml>=6.0.2", + "validators>=0.34.0", +] + +[[tool.uv.index]] +name = "propeace" +url = "https://git.propeace.de/api/packages/ProPeace/pypi/simple/" +explicit = true + +[tool.uv.sources] +civifang = { index = "propeace" } + +[dependency-groups] +dev = [ + "bump-my-version>=1.0.2", + "uv>=0.6.5", +] + +[project.scripts] +adgroupsync = "adgroupsync.__main__:main" + +[tool.bumpversion] +current_version = "1.0.0" +parse = "(?P\\d+)\\.(?P\\d+)\\.(?P\\d+)" +serialize = ["{major}.{minor}.{patch}"] +search = "{current_version}" +replace = "{new_version}" +regex = false +files = [ + {filename = "src/adgroupsync/__init__.py"} +] +ignore_missing_version = false +ignore_missing_files = false +tag = false +sign_tags = true +tag_name = "v{new_version}" +tag_message = "Bump version: {current_version} → {new_version}" +allow_dirty = false +commit = true +message = "🔖 Bump version: {current_version} → {new_version}" +moveable_tags = [] +commit_args = "" +setup_hooks = [] +pre_commit_hooks = [] +post_commit_hooks = [] diff --git a/CiviCRM/__init__.py b/src/__init__.py similarity index 100% rename from CiviCRM/__init__.py rename to src/__init__.py diff --git a/src/adgroupsync/__init__.py b/src/adgroupsync/__init__.py new file mode 100644 index 0000000..06c0b5a --- /dev/null +++ b/src/adgroupsync/__init__.py @@ -0,0 +1,2 @@ +__version__ = "1.0.0" +__author__ = "Marc Koch" \ No newline at end of file diff --git a/src/adgroupsync/__main__.py b/src/adgroupsync/__main__.py new file mode 100644 index 0000000..976eb1a --- /dev/null +++ b/src/adgroupsync/__main__.py @@ -0,0 +1,289 @@ +import json +import logging +from pathlib import Path + +from ldap3 import SIMPLE +from ms_active_directory import ADDomain, ADGroup, ADUser, ADObject + +from .conf import ( + AD_DOMAIN, + AD_USER_NAME, + AD_PASSWORD, + AD_LDAP_SERVER, + AD_PARENT_GROUP, + AD_TIMEZONE, + STDOUT_LOG_LEVEL, + FILE_LOG_LEVEL, + LOG_DIR, + CIVICRM_BASE_URL, + CIVICRM_API_KEY, + CIVICRM_BATCH_SIZE, + CIVICRM_RETRIES, + CIVICRM_IGNORE_SSL, + NTFY_URL, + NTFY_TOPIC, + NTFY_ACCESS_TOKEN, +) +from .logger import setup_logging +from .models import RecentRun, CiviCrm, Ntfy + +logger = logging.getLogger(__package__) + +civicrm_credentials = { + 'base_url': CIVICRM_BASE_URL, + 'api_key': CIVICRM_API_KEY, + 'batch_size': CIVICRM_BATCH_SIZE, + 'ignore_ssl': CIVICRM_IGNORE_SSL, +} + + +def is_user_disabled(user: ADUser): + """ + Check if the user account is disabled. + :param user: ADUser object + :return: True if the user is disabled, False otherwise + """ + user_account_control = user.get('userAccountControl') + is_disabled = user_account_control is not None and ( + user_account_control & 0b10) != 0 + if is_disabled: + logger.debug(f"User '{user.name}' is disabled", + extra={'user': user.__dict__, + 'status': 'disabled', + 'userAccountControl': user_account_control}) + return is_disabled + + +def has_changed(object_: ADObject, recent_run): + """ + Check if the object has changed since the last run + :param object_: Object to check + :param recent_run: RecentRun object + :return: True if the object has changed, False otherwise + """ + modify_timestamp = object_.get('modifyTimestamp') + if modify_timestamp is None: + message = (f"Object '{object_.get('sn')}' of type '{type(object_)}' " + "does not have 'modifyTimestamp' attribute") + raise ValueError(message) + if recent_run.datetime is None: + return True + return modify_timestamp > recent_run + + +def check_group_changes(group, level_dict, recent_run, visited): + """ + Check if the group or any of its child groups have changed. + :param group: The group to check. + :param level_dict: The dictionary containing the hierarchy. + :param recent_run: The RecentRun object. + :param visited: Set of visited groups to avoid infinite loops. + :return: True if the group or any of its child groups have changed, False otherwise. + """ + if group in visited: + return False + + visited.add(group) + + # First, check if the group itself has changed + if has_changed(group, recent_run): + return True + + # Then, check if any child group has changed + for child in level_dict.get(group, []): + if isinstance(child, ADGroup) and check_group_changes( + child, level_dict, recent_run, visited): + return True + + return False + + +def collect_members(group, level_dict, visited): + if group in visited: + return set() + + visited.add(group) + members = set() + + for child in level_dict.get(group, []): + if isinstance(child, ADUser) and not is_user_disabled( + child): # If it is an active user, add to members # If it is a user, add to members + members.add(child) + elif isinstance(child, + ADGroup): # If it is a group, recursively collect members + members.update(collect_members(child, level_dict, visited)) + + return members + + +def build_group_membership(level_dict): + all_groups = {} + + for group in level_dict.keys(): + visited = set() + members = collect_members(group, level_dict, visited) + if members: + all_groups[group] = members + + return all_groups + + +def find_changed(all_groups, level_dict, recent_run): + changed_groups = set() + changed_users = set() + + for group in level_dict.keys(): + if check_group_changes(group, level_dict, recent_run, set()): + changed_groups.add(group) + logger.debug(f"Group '{group.name}' has changed", extra={ + 'group': group.__dict__, + 'modifyTimestamp': group.get('modifyTimestamp'), + }) + + for group, members in all_groups.items(): + for user in members: + if isinstance(user, ADUser) and not is_user_disabled( + user) and has_changed(user, recent_run): + changed_users.add(user) + logger.debug(f"User '{user.name}' has changed", extra={ + 'user': user.__dict__, + 'modifyTimestamp': user.get('modifyTimestamp'), + }) + + return changed_groups, changed_users + + +def sync_groups(recent_run: RecentRun): + # Setup ntfy if URL and topic are provided + ntfy = False + if NTFY_URL and NTFY_TOPIC: + ntfy = Ntfy(NTFY_URL, NTFY_ACCESS_TOKEN) + + # Setup session + domain = ADDomain(AD_DOMAIN, ldap_servers_or_uris=AD_LDAP_SERVER) + logger.debug(f"Connecting to Domain '{domain.domain}'", + extra={'domain': domain.__dict__}) + try: + session = domain.create_session_as_user( + AD_USER_NAME, AD_PASSWORD, authentication_mechanism=SIMPLE, + read_only=True) + except Exception as e: + logger.error(f"Error creating session: {e}") + exit(1) + logger.debug(f"Session opened: {session.is_open()}", + extra={'session': session.__dict__}) + + # Get parent group + parent_group = session.find_group_by_name( + AD_PARENT_GROUP, ['modifyTimestamp']) + + # Get child groups + group_attrs = [ + 'modifyTimestamp', + 'objectSid', + 'givenName', + 'sn', + 'mail', + 'sAMAccountName', + 'description', + 'userAccountControl', + ] + + mailinglists_levels = session.find_members_of_group_recursive( + parent_group, group_attrs) + + level_dict = {k: v for level in mailinglists_levels for k, v in + level.items()} + + groups = build_group_membership(level_dict) + + mailinglists = {group: members for group, members in groups.items() + if group in mailinglists_levels[1].keys()} + + changed_groups, changed_users = find_changed(mailinglists, level_dict, + recent_run) + + groups_to_update = {group: members for group, members + in mailinglists.items() if group in changed_groups} + + users_to_update = set(user for user in changed_users + if not any((user in members) for members + in groups_to_update.values())) + + # Break if there are no requests to send + if not groups_to_update and not users_to_update: + logger.info('No changes detected. Exiting...') + return + + # Connect to CiviCRM + with CiviCrm(**civicrm_credentials) as civicrm: + + # Prepare request for changed users + civicrm.update_users(users_to_update) + + # Prepare requests for changed groups + civicrm.update_groups(groups_to_update) + + # Send requests and retry 3 times + retry_count = 0 + while retry_count < CIVICRM_RETRIES \ + and (error_count := civicrm.send_requests()) != 0: + retry_count += 1 + logger.warning(f"A total of {error_count} requests failed." + f" Retrying {retry_count}/3") + + if retry_count >= CIVICRM_RETRIES: + logger.error( + f"Failed to send requests after {CIVICRM_RETRIES} retries.") + + # Send notification if ntfy is set + if ntfy: + logger.info('Sending notification via ntfy') + ntfy_message = ( + f"Failed to send requests after {CIVICRM_RETRIES} retries.\n" + '## Errors\n```json' + f"{json.dumps(civicrm.error_bag, indent=2)}\n```" + ) + ntfy.send( + topic=NTFY_TOPIC, + title='Failed to sync AD groups with CiviCRM', + message=ntfy_message, + priority=ntfy.PRIORITY.HIGH, + markdown=True, + ) + else: + logger.info('All requests were sent successfully!') + + +def main(): + setup_logging(file_log_level=FILE_LOG_LEVEL, + stdout_log_level=STDOUT_LOG_LEVEL, + logdir=LOG_DIR) + + try: + logger.info('Running group sync') + + # Get the recent run timestamp + file_path = Path().home() / '.recent_run' + with RecentRun(file_path, tz=AD_TIMEZONE) as recent_run: + if recent_run.datetime is None: + logger.info('No recent run found') + else: + rr_time = recent_run.datetime.strftime('%Y-%m-%d %H:%M:%S %Z') + logger.info( + f"Recent run at: {rr_time}") + + # Synchronize groups + sync_groups(recent_run) + + # Log the current run timestamp + started_at = recent_run.started_at.strftime('%Y-%m-%d %H:%M:%S %Z') + logger.info(f"Setting previous run to: {started_at}") + + except Exception as e: + logger.error(f"An error occurred: {e}", exc_info=True) + exit(1) + + +if __name__ == '__main__': + main() diff --git a/src/adgroupsync/conf.py b/src/adgroupsync/conf.py new file mode 100644 index 0000000..e38c806 --- /dev/null +++ b/src/adgroupsync/conf.py @@ -0,0 +1,128 @@ +import argparse +import logging +import os +from pathlib import Path + +import pytz +import yaml + +logger = logging.getLogger(__package__) + + +def create_config_file(dest: Path): + if dest.is_dir(): + dest = dest / f"{__package__}_config.yml" + example_conf = Path(__file__).parent / 'resources' / 'example_config.yml' + with open(example_conf, "r") as source: + with open(dest, "w") as d: + d.writelines(source) + + +# Assign environment variables or configuration file values +AD_DOMAIN = os.getenv('AD_DOMAIN') +AD_USER_NAME = os.getenv('AD_USER') +AD_PASSWORD = os.getenv('AD_PASSWORD') +AD_LDAP_SERVER = [s.strip() for s in os.getenv('AD_LDAP_SERVER').split(',')] \ + if os.getenv('AD_LDAP_SERVER') is not None else None +AD_TIMEZONE = pytz.timezone(os.getenv('AD_TIMEZONE')) \ + if os.getenv('AD_TIMEZONE') else None +AD_PARENT_GROUP = os.getenv('AD_PARENT_GROUP') +STDOUT_LOG_LEVEL = os.getenv('STDOUT_LOG_LEVEL') +FILE_LOG_LEVEL = os.getenv('FILE_LOG_LEVEL') +LOG_DIR = os.getenv('LOG_DIR') +CIVICRM_BASE_URL = os.getenv('CIVICRM_BASE_URL') +CIVICRM_API_KEY = os.getenv('CIVICRM_API_KEY') +CIVICRM_BATCH_SIZE = int(os.getenv('CIVICRM_BATCH_SIZE')) \ + if os.getenv('CIVICRM_BATCH_SIZE') is not None else None +CIVICRM_RETRIES = int(os.getenv('CIVICRM_RETRIES')) \ + if os.getenv('CIVICRM_RETRIES') is not None else None +CIVICRM_IGNORE_SSL = bool(os.getenv('CIVICRM_IGNORE_SSL')) \ + if os.getenv('CIVICRM_IGNORE_SSL') is not None else None +NTFY_URL = os.getenv('NTFY_URL') +NTFY_TOPIC = os.getenv('NTFY_TOPIC') +NTFY_ACCESS_TOKEN = os.getenv('NTFY_ACCESS_TOKEN') + +try: + argparser = argparse.ArgumentParser( + description='This program synchronizes Active Directory groups with ' + 'CiviCRM groups.') + + argparser.add_argument( + "--conf", + action="store", + type=Path, + help="Path the configuration file", + ) + + argparser.add_argument( + "--create-conf", + action="store_true", + help="Create a configuration file", + ) + + args = argparser.parse_args() + + # If a path to a config file was provided + if args.conf: + + # Check if configuration file exists + config_file = Path(args.conf) + if not config_file.is_file() and not args.create_conf: + raise FileNotFoundError( + f"Configuration file '{config_file}' does not exist.") + + # Create configuration file if requested and exit + if args.create_conf: + create_config_file(config_file) + exit(0) + + # Load configuration file + with open(config_file, 'r') as file: + config = yaml.safe_load(file) + + # Get values from configuration file + AD_DOMAIN = AD_DOMAIN or config['AD']['DOMAIN'] + AD_USER_NAME = AD_USER_NAME or config['AD']['USER'] + AD_PASSWORD = AD_PASSWORD or config['AD']['PASSWORD'] + AD_LDAP_SERVER = AD_LDAP_SERVER or config['AD'].get('LDAP_SERVER') + AD_TIMEZONE = AD_TIMEZONE \ + or pytz.timezone(config['AD'].get('TIMEZONE', 'UTC')) + AD_PARENT_GROUP = AD_PARENT_GROUP or config['AD']['PARENT_GROUP'] + STDOUT_LOG_LEVEL = STDOUT_LOG_LEVEL \ + or config['LOGGING'].get('STDOUT_LOG_LEVEL', 'INFO') + FILE_LOG_LEVEL = FILE_LOG_LEVEL \ + or config['LOGGING'].get('FILE_LOG_LEVEL', 'WARNING') + LOG_DIR = LOG_DIR or config['LOGGING'].get('LOG_DIR') + CIVICRM_BASE_URL = CIVICRM_BASE_URL or config['CIVICRM']['BASE_URL'] + CIVICRM_API_KEY = CIVICRM_API_KEY or config['CIVICRM']['API_KEY'] + CIVICRM_BATCH_SIZE = CIVICRM_BATCH_SIZE \ + or config['CIVICRM']['BATCH_SIZE'] + CIVICRM_RETRIES = CIVICRM_RETRIES \ + or config['CIVICRM'].get('RETRIES', 3) + CIVICRM_IGNORE_SSL = CIVICRM_IGNORE_SSL \ + or bool(config['CIVICRM'].get('IGNORE_SSL', False)) + NTFY_URL = NTFY_URL or config['NTFY'].get('URL') if 'NTFY' in config else None + NTFY_TOPIC = NTFY_TOPIC or config['NTFY'].get('TOPIC') if 'NTFY' in config else None + NTFY_ACCESS_TOKEN = NTFY_ACCESS_TOKEN \ + or config['NTFY'].get('ACCESS_TOKEN') if 'NTFY' in config else None + + # Check if some required values are missing + required = { + "AD_DOMAIN": AD_DOMAIN, + "AD_USER_NAME": AD_USER_NAME, + "AD_PASSWORD": AD_PASSWORD, + "AD_LDAP_SERVER": AD_LDAP_SERVER, + "AD_PARENT_GROUP": AD_PARENT_GROUP, + "CIVICRM_BASE_URL": CIVICRM_BASE_URL, + "CIVICRM_API_KEY": CIVICRM_API_KEY, + } + if len(missing := [k for k, v in required.items() if v is None]) > 0: + raise ValueError('Some required values are missing. ' + 'Please use a configuration file ' + 'or provide all required environment variables. ' + 'Missing: %s' + % ','.join(missing)) + +except Exception as e: + logger.error(e, exc_info=True) + exit(1) diff --git a/src/adgroupsync/enums.py b/src/adgroupsync/enums.py new file mode 100644 index 0000000..06ec593 --- /dev/null +++ b/src/adgroupsync/enums.py @@ -0,0 +1,11 @@ +from enum import Enum + +class Priority(Enum): + """ + Enum for the different priority levels. + """ + MIN = 1 + LOW = 2 + DEFAULT = 3 + HIGH = 4 + MAX = URGENT = 5 diff --git a/adGroupSync/logger.py b/src/adgroupsync/logger.py similarity index 58% rename from adGroupSync/logger.py rename to src/adgroupsync/logger.py index e4f9ccf..f4457bb 100644 --- a/adGroupSync/logger.py +++ b/src/adgroupsync/logger.py @@ -3,15 +3,10 @@ import datetime as dt import json import logging import logging.config -import os from pathlib import Path from typing import override -PROJECT_ROOT = Path(__file__).parent.parent -if LOG_DIR := os.getenv("LOG_DIR"): - LOG_DIR = Path(LOG_DIR) - LOG_DIR.mkdir(parents=True, exist_ok=True) - +PROJECT_ROOT = Path(__file__).parent LOG_RECORD_BUILTIN_ATTRS = { "args", "asctime", @@ -39,26 +34,35 @@ LOG_RECORD_BUILTIN_ATTRS = { } -def setup_logging(loglevel: int | str | None = None): +def setup_logging( + logdir: Path | str | None = None, + **kwargs, +): """ Setup logging configuration + :param logdir: Directory to store the log file + :keyword file_log_level: Log level for the file handler + :keyword stdout_log_level: Log level for the stdout handler :return: """ - config_file = PROJECT_ROOT / "conf" / "logging_config.json" + handlers = ["file", "stdout"] + + config_file = PROJECT_ROOT / "resources" / "logging_config.json" with open(config_file, "r") as file: config = json.load(file) # Override log level if provided - if loglevel: - level_str = logging.getLevelName(loglevel) - if not isinstance(level_str, str): - level_str = logging.getLevelName(level_str) - for handler in config["handlers"].values(): - handler["level"] = level_str + for handler in handlers: + if log_level := kwargs.get(f"{handler}_log_level"): + level_str = logging.getLevelName(log_level.upper()) + if not isinstance(level_str, str): + level_str = logging.getLevelName(level_str) + config["handlers"][handler]["level"] = level_str # Set log file path to user log directory - if LOG_DIR: - config["handlers"]["file"]["filename"] = LOG_DIR / "adGroupSync.log.jsonl" + if logdir: + logdir = Path(logdir) + config["handlers"]["file"]["filename"] = logdir / "adGroupSync.log.jsonl" # Create path and file if it does not exist Path(config["handlers"]["file"]["filename"]).parent.mkdir( @@ -76,6 +80,9 @@ class JSONFormatter(logging.Formatter): """ A custom JSON formatter for logging """ + HIDE_KEYS = ["password", "token", "api_key", "site_key"] + + def __init__( self, *, @@ -87,8 +94,37 @@ class JSONFormatter(logging.Formatter): @override def format(self, record: logging.LogRecord) -> str: message = self._prepare_log_dict(record) + + # Exclude passwords from the log + self._hide_passwords(record) + return json.dumps(message, default=str) + def _hide_passwords(self, log_record: logging.LogRecord|dict): + """ + Recursively replace all values with keys containing 'password', + 'token', etc. with '********' + :param log_record: + :return: + """ + if not isinstance(log_record, dict): + dict_obj = log_record.__dict__ + else: + dict_obj = log_record + + for key, value in dict_obj.items(): + if isinstance(value, dict): + dict_obj = self._hide_passwords(value) + elif any(hide_key in key.lower() for hide_key in self.HIDE_KEYS): + dict_obj[key] = "********" + + if isinstance(log_record, logging.LogRecord): + for key, value in dict_obj.items(): + setattr(log_record, key, value) + return log_record + else: + return dict_obj + def _prepare_log_dict(self, record: logging.LogRecord) -> dict: always_fields = { "message": record.getMessage(), @@ -115,4 +151,4 @@ class JSONFormatter(logging.Formatter): if key not in LOG_RECORD_BUILTIN_ATTRS: message[key] = val - return message + return message \ No newline at end of file diff --git a/src/adgroupsync/models.py b/src/adgroupsync/models.py new file mode 100644 index 0000000..3e74df1 --- /dev/null +++ b/src/adgroupsync/models.py @@ -0,0 +1,548 @@ +import json +import logging +from collections import deque +from datetime import datetime as dt, timezone +from pathlib import Path + +import pytz +from civifang import api +from httpx import post +from ms_active_directory import ADUser, ADGroup + +from .enums import Priority + +logger = logging.getLogger(__package__) + + +class RecentRun: + """ + Class to manage the last run of the script + """ + + def __init__(self, file_path: Path, tz: pytz = pytz.utc): + """ + Initialize the class + :param file_path: File path to store the recent run timestamp + :param tz: Timezone to use for the timestamp + """ + self._datetime = None + self._timezone = tz + self._file_path = file_path + self._is_running = False + self._started_at = None + + # Create the file if it does not exist + self._file_path.touch(exist_ok=True) + + self._read_data_from_file() + + def _sync_file( + self, + recent_run: dt | None = None, + is_running: bool = False + ): + """ + Write the recent run timestamp and running status to the file + :param recent_run: + :param is_running: + :return: + """ + # Convert the is_running boolean to a string + is_running = 'true' if is_running else 'false' \ + if is_running is not None else None + + # Read the file and update the values if they are different + with open(self._file_path, 'r+') as f: + # Read the data from the file + data = f.readlines() + old_recent_run, old_is_running = self._read_data(data) + + # Update the values if they were provided + timestamp = recent_run.timestamp() if recent_run else old_recent_run + is_running = is_running or old_is_running + new_data = [ + f"recent-run:{timestamp}", + '\n', + f"is-running:{is_running}", + ] + + # Write the new data to the file + f.seek(0) + f.truncate() + f.writelines(new_data) + + @staticmethod + def _read_data(data: list): + """ + Read data + :param data: + :return: Tuple of recent_run and is_running ('true'/'false') + """ + time = None + is_running = None + for line in data: + line = line.strip() + if line.startswith('recent-run:'): + time = line.split(':', 1)[1].strip() + elif line.startswith('is-running:'): + is_running = line.split(':', 1)[1].strip() + + return float(time), is_running + + def _read_data_from_file(self): + """ + Read the recent run time from the file + :return: + """ + with open(self._file_path, 'r') as f: + data = f.readlines() + recent_run, is_running = self._read_data(data) + + # Read running status + self._is_running = is_running == 'true' + + # Set the datetime to the recent run time + if not recent_run: + return + try: + self._datetime = dt.fromtimestamp(float(recent_run)) \ + .astimezone(self._timezone) + except ValueError as e: + raise ValueError( + f"Invalid timestamp '{recent_run}' in {self._file_path}: {e}") + + @property + def datetime(self) -> dt | None: + """ + Get the recent run timestamp + :return: + """ + return self._datetime + + @property + def started_at(self) -> dt | None: + """ + Get the time the script was started + :return: + """ + return self._started_at + + @property + def timestamp(self) -> float: + """ + Get the recent run timestamp as a timestamp + :return: + """ + return self._datetime.timestamp() + + @property + def is_running(self): + """ + Get the running status + :return: + """ + return self._is_running + + @datetime.setter + def datetime(self, value: datetime): + """ + Set the recent run timestamp + :param value: + :return: + """ + if value.tzinfo is None: + value = value.astimezone(self._timezone) + self._datetime = value + + @staticmethod + def _to_datetime(value: dt | str | float) -> datetime: + """ + Convert the value to a datetime object + :param value: + :return: + """ + try: + if isinstance(value, str): + value = float(value) + if isinstance(value, float): + value = dt.fromtimestamp(value).astimezone(timezone.utc) + except ValueError: + raise ValueError(f"Invalid timestamp '{value}'") + return value + + def __enter__(self): + self._started_at = dt.now(self._timezone) + self._is_running = True + self._sync_file(is_running=self._is_running) + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + datetime = None + self._is_running = False + + # If an exception occurred, do not update the recent run timestamp + if exc_type is None: + self.datetime = datetime = self._started_at + + self._sync_file(datetime, is_running=self._is_running) + + def __gt__(self, other: dt | str | float): + return self.datetime > self._to_datetime(other) + + def __lt__(self, other: dt | str | float): + return self.datetime < self._to_datetime(other) + + def __eq__(self, other: dt | str | float): + return self.datetime == self._to_datetime(other) + + def __ge__(self, other: dt | str | float): + return self.datetime >= self._to_datetime(other) + + def __le__(self, other: dt | str | float): + return self.datetime <= self._to_datetime(other) + + +class CiviCrm: + """ + Class to interact with CiviCRM via the API + """ + + def __init__( + self, + base_url: str, + api_key: str, + batch_size: int, + ignore_ssl: bool = False, + ): + """ + Initialize the class + :param base_url: Base URL of the CiviCRM installation + :param api_key: API key for CiviCRM + :param batch_size: Number of users to send in one request + :param ignore_ssl: Accept unencrypted connections + """ + self._base_url = base_url + self._api_key = api_key + self._auth_flow = api.AUTH_FLOWS.XHEADER + self._batch_size = batch_size + self._ignore_ssl = ignore_ssl + self._requests = {'groups': deque(), 'users': deque()} + self._failed_requests = {'groups': [], 'users': []} + self._error_bag = [] + + def __enter__(self): + self._setup() + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + self.close() + if isinstance(exc_val, Exception): + logger.exception( + "The connection to CiviCRM was closed due to an exception", + extra={'exc_type': exc_type, 'exc_val': exc_val, + 'exc_tb': exc_tb}) + exit(1) + + def _setup(self): + api_params = { + "url": self._base_url, + "api_key": self._api_key, + "auth_flow": self._auth_flow, + "ignore_ssl": self._ignore_ssl, + } + + # Check for missing parameters + if any([v for v in api_params.values() if not v]): + missing_params = [k for k, v in api_params.items() if not v] + raise ValueError( + f"Missing API parameters: {', '.join(missing_params)}") + + # Connect to CiviCRM + try: + logger.debug("Connecting to CiviCRM", extra=api_params) + api.setup(**api_params) + except Exception as e: + logger.exception(f"Error connecting to CiviCRM: {e}", + extra=api_params) + raise e + + def update_groups(self, groups: dict[ADGroup, set]): + """ + Update the groups in CiviCRM via Mailingslistsync.Adgroupsync API + :param groups: + :return: + """ + groups_data = [] + + for group, users in groups.items(): + + # Filter users for missing values and wrong types + users = self._filter_users(users) + + group_data = { + 'sid': group.get("objectSid"), + 'email': group.get("mail"), + 'name': group.name, + 'description': group.get("description"), + 'recipients': json.dumps(users), + } + + # Check group for missing values + name = group.name or 'Unknown' + sid = group.get('objectSid') or 'Unknown' + message = f"Missing values for group '{name}' ({sid}): %s" + if self.check_values(group_data, message, ['description']): + groups_data.append(self._filter_data(group_data)) + + # Add the groups to the request list + for group in groups_data: + self._requests['groups'].append({ + 'entity': 'Mailinglistsync', + 'action': 'Adgroupsync', + 'query': group, + 'method': api.HTTP_METHODS.POST, + }) + + def update_users(self, users: set[ADUser]): + """ + Update the users in CiviCRM via Mailingslistsync.Adgroupsync API + :param users: + :return: + """ + + # Filter users for missing values and wrong types + users = self._filter_users(users) + + # Split the users into batches + data_batches = self._chunks(users, self._batch_size) + + # Add the users to the request list + for batch in data_batches: + self._requests['users'].append({ + 'entity': 'Mailinglistsync', + 'action': 'Adgroupsync', + 'query': {'recipients': json.dumps(batch)}, + 'method': api.HTTP_METHODS.POST, + }) + + def send_requests(self) -> int: + """ + Run the tasks in the task queue + :return: Number of failed requests + """ + error_count = 0 + failed_requests = {'groups': deque(), 'users': deque()} + + for name, requests in self._requests.items(): + logger.info(f"Sending {len(requests)} {name}") + + while requests: + request = requests.popleft() + + try: + result = api.api3(**request) + logger.info(f"Result: {result}", extra={'result': result}) + if result.get('is_error', False): + raise Exception(result.get('error_message')) + + except Exception as e: + self._error_bag.append({ + 'name': name, + 'request': { + 'entity': request['entity'], + 'action': request['action'], + 'query': { + k: (json.loads(v) if k == 'recipients' else v) + for k, v in request['query'].items()}, + 'method': str(request['method']), + }, + 'error': str(e), + }) + logger.exception(f"Error sending request: {e}", + extra=request) + failed_requests[name].append(request) + error_count += 1 + + # Append failed requests to requests again + for name, requests in failed_requests.items(): + while requests: + self._requests[name].append(requests.popleft()) + + return error_count + + @staticmethod + def _chunks(lst, n): + """Yield successive n-sized chunks from lst.""" + for i in range(0, len(lst), n): + yield lst[i:i + n] + + @classmethod + def _filter_users(cls, users: set) -> list | None: + """ + Filter users for missing values and wrong types + :param users: Set of users + :return: List of filtered users + """ + result = [] + for user in users: + if isinstance(user, ADUser): + data = { + 'sid': user.get("objectSid"), + 'email': user.get("mail"), + 'first_name': user.get("givenName"), + 'last_name': user.get("sn"), + } + # Check for missing values and log them + name = user.get('sn') or 'Unknown' + sid = user.get('objectSid') or 'Unknown' + message = f"Missing values for user '{name}' ({sid}): %s" + if cls.check_values(data, message, + ['first_name', 'last_name']): + result.append(cls._filter_data(data)) + else: + raise ValueError(f"Invalid user type: {type(user)}") + return result + + @staticmethod + def _filter_data(data): + """ + Filter the data for missing values + :return: + """ + return {k: v for k, v in data.items() if v is not None} + + @staticmethod + def check_values(data: dict, message: str, ignore_keys: list[str] = None): + """ + Check for missing values in the data and log them. + :param data: + :param message: Should contain an %s placeholder for the missing values + :param ignore_keys: List of keys to ignore + :return: True if all values are present, False otherwise + """ + if ignore_keys is None: + ignore_keys = [] + + missing_values = { + key: value for key, value in data.items() if + not value and key not in ignore_keys + } + + if missing_values: + message = message % ', '.join(missing_values.keys()) + log_data = {} + for key, value in data.items(): # Sanitize the data + if key in ['name']: + log_data['name_'] = value + else: + log_data[key] = value + logger.debug( + message, + extra={'data': log_data} + ) + + return not bool(missing_values) + + @staticmethod + def close(): + """ + Close the connection to CiviCRM + :return: + """ + api.disconnect() + + @property + def requests(self) -> dict: + """ + Get the requests + :return: + """ + return self._requests + + @property + def error_bag(self) -> list: + """ + Get the error bag + :return: + """ + return self._error_bag + + +class Ntfy: + """ + Class to send notifications via ntfy + """ + + PRIORITY = Priority + + def __init__(self, url: str, access_token: str = None): + """ + Initialize the class + :param url: nfyt URL + :param access_token: Access token if required + """ + self.url = url if url.endswith('/') else f"{url}/" + self.access_token = access_token + + def send( + self, + topic, + message: str = None, + title: str = None, + tags: str | list = None, + priority: int | PRIORITY = None, + link: str = None, + markdown: bool = False, + **kwargs + ): + """ + Send a notification via ntfy + :param topic: Topic to send the notification to + :param message: Message to send + :param title: Message title + :param tags: Tags to add to the message (see ntfy documentation) + :param priority: See Priority enum + :param link: A link to add to the message + :param markdown: Whether to use markdown + :param kwargs: + :return: + """ + if self.access_token: + headers = { + 'Authorization': f'Bearer {self.access_token}', + } | kwargs.get('headers', {}) + else: + headers = kwargs.get('headers', {}) + + match priority: + case self.PRIORITY.MIN: + headers['Priority'] = 'min' + case self.PRIORITY.LOW: + headers['Priority'] = 'low' + case self.PRIORITY.DEFAULT: + headers['Priority'] = 'default' + case self.PRIORITY.HIGH: + headers['Priority'] = 'high' + case self.PRIORITY.MAX: + headers['Priority'] = 'max' + case _: + headers['Priority'] = 'default' + + if title: + headers['Title'] = title + if tags: + headers['Tags'] = tags if isinstance(tags, str) else ','.join(tags) + if link: + headers['Click'] = link + if markdown: + headers['Markdown'] = 'yes' + + try: + post(f"{self.url}{topic}", headers=headers, data=message) + except Exception as e: + logger.exception(f"Error sending notification: {e}", extra={ + 'url': self.url, + 'topic': topic, + 'headers': headers, + 'message': message, + }) diff --git a/src/adgroupsync/resources/example_config.yml b/src/adgroupsync/resources/example_config.yml new file mode 100644 index 0000000..fdd31de --- /dev/null +++ b/src/adgroupsync/resources/example_config.yml @@ -0,0 +1,25 @@ +AD: + DOMAIN: ad.example.com + USER: example\username + PASSWORD: xxxxxxxx + LDAP_SERVER: + - ldaps://server1.ad.example.com:636 + PARENT_GROUP: Mailinglists + TIMEZONE: UTC + +LOGGING: + STDOUT_LOG_LEVEL: info + FILE_LOG_LEVEL: info + LOG_DIR: /var/log/adGroupSync/ + +CIVICRM: + BASE_URL: https://civicrm.example.com + API_KEY: xxxxxxxx + BATCH_SIZE: 50 + RETRIES: 3 + # IGNORE_SSL: yes + +NTFY: + URL: https://ntfy.example.com + TOPIC: adGroupSync + ACCESS_TOKEN: tk_xxxxxxxxxxxxxxxxxxx \ No newline at end of file diff --git a/conf/logging_config.json b/src/adgroupsync/resources/logging_config.json similarity index 90% rename from conf/logging_config.json rename to src/adgroupsync/resources/logging_config.json index 2baa047..83d9211 100644 --- a/conf/logging_config.json +++ b/src/adgroupsync/resources/logging_config.json @@ -7,7 +7,7 @@ "datefmt": "%Y-%m-%dT%H:%M:%Sz" }, "json": { - "()": "forum_booking.logger.JSONFormatter", + "()": "adgroupsync.logger.JSONFormatter", "fmt_keys": { "level": "levelname", "message": "message", @@ -25,12 +25,12 @@ "class": "logging.StreamHandler", "formatter": "simple", "stream": "ext://sys.stdout", - "level": "DEBUG" + "level": "INFO" }, "file": { "class": "logging.handlers.RotatingFileHandler", "formatter": "json", - "filename": "forum_booking.log.jsonl", + "filename": "adgroupsync.log.jsonl", "level": "INFO", "maxBytes": 10000000, "backupCount": 3 diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..b77ef59 --- /dev/null +++ b/uv.lock @@ -0,0 +1,521 @@ +version = 1 +revision = 1 +requires-python = ">=3.12" + +[[package]] +name = "adgroupsync" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "civifang" }, + { name = "httpx" }, + { name = "ms-active-directory" }, + { name = "pyyaml" }, + { name = "validators" }, +] + +[package.dev-dependencies] +dev = [ + { name = "bump-my-version" }, + { name = "uv" }, +] + +[package.metadata] +requires-dist = [ + { name = "civifang", specifier = ">=0.2.6", index = "https://git.propeace.de/api/packages/ProPeace/pypi/simple/" }, + { name = "httpx", specifier = ">=0.28.1" }, + { name = "ms-active-directory", specifier = ">=1.14.1" }, + { name = "pyyaml", specifier = ">=6.0.2" }, + { name = "validators", specifier = ">=0.34.0" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "bump-my-version", specifier = ">=1.0.2" }, + { name = "uv", specifier = ">=0.6.5" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643 }, +] + +[[package]] +name = "anyio" +version = "4.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "sniffio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/73/199a98fc2dae33535d6b8e8e6ec01f8c1d76c9adb096c6b7d64823038cde/anyio-4.8.0.tar.gz", hash = "sha256:1d9fe889df5212298c0c0723fa20479d1b94883a2df44bd3897aa91083316f7a", size = 181126 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/eb/e7f063ad1fec6b3178a3cd82d1a3c4de82cccf283fc42746168188e1cdd5/anyio-4.8.0-py3-none-any.whl", hash = "sha256:b5011f270ab5eb0abf13385f851315585cc37ef330dd88e27ec3d34d651fd47a", size = 96041 }, +] + +[[package]] +name = "bracex" +version = "2.5.post1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/6c/57418c4404cd22fe6275b8301ca2b46a8cdaa8157938017a9ae0b3edf363/bracex-2.5.post1.tar.gz", hash = "sha256:12c50952415bfa773d2d9ccb8e79651b8cdb1f31a42f6091b804f6ba2b4a66b6", size = 26641 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/02/8db98cdc1a58e0abd6716d5e63244658e6e63513c65f469f34b6f1053fd0/bracex-2.5.post1-py3-none-any.whl", hash = "sha256:13e5732fec27828d6af308628285ad358047cec36801598368cb28bc631dbaf6", size = 11558 }, +] + +[[package]] +name = "bump-my-version" +version = "1.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "httpx" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "questionary" }, + { name = "rich" }, + { name = "rich-click" }, + { name = "tomlkit" }, + { name = "wcmatch" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/c9/22f5e6de03ec21357fd37e61fad2970043c406a9af217a0bfc68747148d8/bump_my_version-1.0.2.tar.gz", hash = "sha256:2f156877d2cdcda69afcb257ae4564c26e70f2fd5e5b15f2c7f26ab9e91502da", size = 1102688 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bb/ce/dc13887c45dead36075a210487ff66304ef0dc3fbc997d2b12bcde2f0401/bump_my_version-1.0.2-py3-none-any.whl", hash = "sha256:61d350b8c71968dd4520fc6b9df8b982c7df254cd30858b8645eff0f4eaf380b", size = 58573 }, +] + +[[package]] +name = "certifi" +version = "2025.1.31" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/ab/c9f1e32b7b1bf505bf26f0ef697775960db7932abeb7b516de930ba2705f/certifi-2025.1.31.tar.gz", hash = "sha256:3d5da6925056f6f18f119200434a4780a94263f10d1c21d032a6f6b2baa20651", size = 167577 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/fc/bce832fd4fd99766c04d1ee0eead6b0ec6486fb100ae5e74c1d91292b982/certifi-2025.1.31-py3-none-any.whl", hash = "sha256:ca78db4565a652026a4db2bcdf68f2fb589ea80d0be70e03929ed730746b84fe", size = 166393 }, +] + +[[package]] +name = "civifang" +version = "0.2.6" +source = { registry = "https://git.propeace.de/api/packages/ProPeace/pypi/simple/" } +dependencies = [ + { name = "httpx" }, + { name = "validators" }, +] +sdist = { url = "https://git.propeace.de/api/packages/ProPeace/pypi/files/civifang/0.2.6/civifang-0.2.6.tar.gz", hash = "sha256:008878e561733473514129ccc65999afc7ff61f9931616ae7dbdc9d891196770" } +wheels = [ + { url = "https://git.propeace.de/api/packages/ProPeace/pypi/files/civifang/0.2.6/civifang-0.2.6-py3-none-any.whl", hash = "sha256:a868929da3468738b16441d16388aa4d3a79465e2af26db58afe0f9aba174db8" }, +] + +[[package]] +name = "click" +version = "8.1.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188 }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, +] + +[[package]] +name = "dnspython" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/4a/263763cb2ba3816dd94b08ad3a33d5fdae34ecb856678773cc40a3605829/dnspython-2.7.0.tar.gz", hash = "sha256:ce9c432eda0dc91cf618a5cedf1a4e142651196bbcd2c80e89ed5a907e5cfaf1", size = 345197 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/1b/e0a87d256e40e8c888847551b20a017a6b98139178505dc7ffb96f04e954/dnspython-2.7.0-py3-none-any.whl", hash = "sha256:b4c34b7d10b51bcc3a5071e7b8dee77939f1e878477eeecc965e9835f63c6c86", size = 313632 }, +] + +[[package]] +name = "h11" +version = "0.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/38/3af3d3633a34a3316095b39c8e8fb4853a28a536e55d347bd8d8e9a14b03/h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d", size = 100418 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/04/ff642e65ad6b90db43e668d70ffb6736436c7ce41fcc549f4e9472234127/h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761", size = 58259 }, +] + +[[package]] +name = "httpcore" +version = "1.0.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6a/41/d7d0a89eb493922c37d343b607bc1b5da7f5be7e383740b4753ad8943e90/httpcore-1.0.7.tar.gz", hash = "sha256:8551cb62a169ec7162ac7be8d4817d561f60e08eaa485234898414bb5a8a0b4c", size = 85196 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/f5/72347bc88306acb359581ac4d52f23c0ef445b57157adedb9aee0cd689d2/httpcore-1.0.7-py3-none-any.whl", hash = "sha256:a3fff8f43dc260d5bd363d9f9cf1830fa3a458b332856f34282de498ed420edd", size = 78551 }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517 }, +] + +[[package]] +name = "idna" +version = "3.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 }, +] + +[[package]] +name = "ldap3" +version = "2.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyasn1" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/ac/96bd5464e3edbc61595d0d69989f5d9969ae411866427b2500a8e5b812c0/ldap3-2.9.1.tar.gz", hash = "sha256:f3e7fc4718e3f09dda568b57100095e0ce58633bcabbed8667ce3f8fbaa4229f", size = 398830 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/f6/71d6ec9f18da0b2201287ce9db6afb1a1f637dedb3f0703409558981c723/ldap3-2.9.1-py2.py3-none-any.whl", hash = "sha256:5869596fc4948797020d3f03b7939da938778a0f9e2009f7a072ccf92b8e8d70", size = 432192 }, +] + +[[package]] +name = "markdown-it-py" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528 }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979 }, +] + +[[package]] +name = "ms-active-directory" +version = "1.14.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dnspython" }, + { name = "ldap3" }, + { name = "pyasn1" }, + { name = "pycryptodome" }, + { name = "pytz" }, + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d0/a0/da7aaa5c87d155f2af60db0db3ab12eec50bfdeeca6f6cd1559ca92375c0/ms_active_directory-1.14.1.tar.gz", hash = "sha256:86c3b9de8b8b5546104f0fe480db689b1a1d0a4109a4208603be4f981ce12040", size = 155782 } + +[[package]] +name = "prompt-toolkit" +version = "3.0.50" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wcwidth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a1/e1/bd15cb8ffdcfeeb2bdc215de3c3cffca11408d829e4b8416dcfe71ba8854/prompt_toolkit-3.0.50.tar.gz", hash = "sha256:544748f3860a2623ca5cd6d2795e7a14f3d0e1c3c9728359013f79877fc89bab", size = 429087 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e4/ea/d836f008d33151c7a1f62caf3d8dd782e4d15f6a43897f64480c2b8de2ad/prompt_toolkit-3.0.50-py3-none-any.whl", hash = "sha256:9b6427eb19e479d98acff65196a307c555eb567989e6d88ebbb1b509d9779198", size = 387816 }, +] + +[[package]] +name = "pyasn1" +version = "0.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/e9/01f1a64245b89f039897cb0130016d79f77d52669aae6ee7b159a6c4c018/pyasn1-0.6.1.tar.gz", hash = "sha256:6f580d2bdd84365380830acf45550f2511469f673cb4a5ae3857a3170128b034", size = 145322 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/f1/d6a797abb14f6283c0ddff96bbdd46937f64122b8c925cab503dd37f8214/pyasn1-0.6.1-py3-none-any.whl", hash = "sha256:0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629", size = 83135 }, +] + +[[package]] +name = "pycryptodome" +version = "3.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/13/52/13b9db4a913eee948152a079fe58d035bd3d1a519584155da8e786f767e6/pycryptodome-3.21.0.tar.gz", hash = "sha256:f7787e0d469bdae763b876174cf2e6c0f7be79808af26b1da96f1a64bcf47297", size = 4818071 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/88/5e83de10450027c96c79dc65ac45e9d0d7a7fef334f39d3789a191f33602/pycryptodome-3.21.0-cp36-abi3-macosx_10_9_universal2.whl", hash = "sha256:2480ec2c72438430da9f601ebc12c518c093c13111a5c1644c82cdfc2e50b1e4", size = 2495937 }, + { url = "https://files.pythonhosted.org/packages/66/e1/8f28cd8cf7f7563319819d1e172879ccce2333781ae38da61c28fe22d6ff/pycryptodome-3.21.0-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:de18954104667f565e2fbb4783b56667f30fb49c4d79b346f52a29cb198d5b6b", size = 1634629 }, + { url = "https://files.pythonhosted.org/packages/6a/c1/f75a1aaff0c20c11df8dc8e2bf8057e7f73296af7dfd8cbb40077d1c930d/pycryptodome-3.21.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2de4b7263a33947ff440412339cb72b28a5a4c769b5c1ca19e33dd6cd1dcec6e", size = 2168708 }, + { url = "https://files.pythonhosted.org/packages/ea/66/6f2b7ddb457b19f73b82053ecc83ba768680609d56dd457dbc7e902c41aa/pycryptodome-3.21.0-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0714206d467fc911042d01ea3a1847c847bc10884cf674c82e12915cfe1649f8", size = 2254555 }, + { url = "https://files.pythonhosted.org/packages/2c/2b/152c330732a887a86cbf591ed69bd1b489439b5464806adb270f169ec139/pycryptodome-3.21.0-cp36-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7d85c1b613121ed3dbaa5a97369b3b757909531a959d229406a75b912dd51dd1", size = 2294143 }, + { url = "https://files.pythonhosted.org/packages/55/92/517c5c498c2980c1b6d6b9965dffbe31f3cd7f20f40d00ec4069559c5902/pycryptodome-3.21.0-cp36-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:8898a66425a57bcf15e25fc19c12490b87bd939800f39a03ea2de2aea5e3611a", size = 2160509 }, + { url = "https://files.pythonhosted.org/packages/39/1f/c74288f54d80a20a78da87df1818c6464ac1041d10988bb7d982c4153fbc/pycryptodome-3.21.0-cp36-abi3-musllinux_1_2_i686.whl", hash = "sha256:932c905b71a56474bff8a9c014030bc3c882cee696b448af920399f730a650c2", size = 2329480 }, + { url = "https://files.pythonhosted.org/packages/39/1b/d0b013bf7d1af7cf0a6a4fce13f5fe5813ab225313755367b36e714a63f8/pycryptodome-3.21.0-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:18caa8cfbc676eaaf28613637a89980ad2fd96e00c564135bf90bc3f0b34dd93", size = 2254397 }, + { url = "https://files.pythonhosted.org/packages/14/71/4cbd3870d3e926c34706f705d6793159ac49d9a213e3ababcdade5864663/pycryptodome-3.21.0-cp36-abi3-win32.whl", hash = "sha256:280b67d20e33bb63171d55b1067f61fbd932e0b1ad976b3a184303a3dad22764", size = 1775641 }, + { url = "https://files.pythonhosted.org/packages/43/1d/81d59d228381576b92ecede5cd7239762c14001a828bdba30d64896e9778/pycryptodome-3.21.0-cp36-abi3-win_amd64.whl", hash = "sha256:b7aa25fc0baa5b1d95b7633af4f5f1838467f1815442b22487426f94e0d66c53", size = 1812863 }, +] + +[[package]] +name = "pydantic" +version = "2.10.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b7/ae/d5220c5c52b158b1de7ca89fc5edb72f304a70a4c540c84c8844bf4008de/pydantic-2.10.6.tar.gz", hash = "sha256:ca5daa827cce33de7a42be142548b0096bf05a7e7b365aebfa5f8eeec7128236", size = 761681 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/3c/8cc1cc84deffa6e25d2d0c688ebb80635dfdbf1dbea3e30c541c8cf4d860/pydantic-2.10.6-py3-none-any.whl", hash = "sha256:427d664bf0b8a2b34ff5dd0f5a18df00591adcee7198fbd71981054cef37b584", size = 431696 }, +] + +[[package]] +name = "pydantic-core" +version = "2.27.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fc/01/f3e5ac5e7c25833db5eb555f7b7ab24cd6f8c322d3a3ad2d67a952dc0abc/pydantic_core-2.27.2.tar.gz", hash = "sha256:eb026e5a4c1fee05726072337ff51d1efb6f59090b7da90d30ea58625b1ffb39", size = 413443 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d6/74/51c8a5482ca447871c93e142d9d4a92ead74de6c8dc5e66733e22c9bba89/pydantic_core-2.27.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:9e0c8cfefa0ef83b4da9588448b6d8d2a2bf1a53c3f1ae5fca39eb3061e2f0b0", size = 1893127 }, + { url = "https://files.pythonhosted.org/packages/d3/f3/c97e80721735868313c58b89d2de85fa80fe8dfeeed84dc51598b92a135e/pydantic_core-2.27.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:83097677b8e3bd7eaa6775720ec8e0405f1575015a463285a92bfdfe254529ef", size = 1811340 }, + { url = "https://files.pythonhosted.org/packages/9e/91/840ec1375e686dbae1bd80a9e46c26a1e0083e1186abc610efa3d9a36180/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:172fce187655fece0c90d90a678424b013f8fbb0ca8b036ac266749c09438cb7", size = 1822900 }, + { url = "https://files.pythonhosted.org/packages/f6/31/4240bc96025035500c18adc149aa6ffdf1a0062a4b525c932065ceb4d868/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:519f29f5213271eeeeb3093f662ba2fd512b91c5f188f3bb7b27bc5973816934", size = 1869177 }, + { url = "https://files.pythonhosted.org/packages/fa/20/02fbaadb7808be578317015c462655c317a77a7c8f0ef274bc016a784c54/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:05e3a55d124407fffba0dd6b0c0cd056d10e983ceb4e5dbd10dda135c31071d6", size = 2038046 }, + { url = "https://files.pythonhosted.org/packages/06/86/7f306b904e6c9eccf0668248b3f272090e49c275bc488a7b88b0823444a4/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9c3ed807c7b91de05e63930188f19e921d1fe90de6b4f5cd43ee7fcc3525cb8c", size = 2685386 }, + { url = "https://files.pythonhosted.org/packages/8d/f0/49129b27c43396581a635d8710dae54a791b17dfc50c70164866bbf865e3/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6fb4aadc0b9a0c063206846d603b92030eb6f03069151a625667f982887153e2", size = 1997060 }, + { url = "https://files.pythonhosted.org/packages/0d/0f/943b4af7cd416c477fd40b187036c4f89b416a33d3cc0ab7b82708a667aa/pydantic_core-2.27.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:28ccb213807e037460326424ceb8b5245acb88f32f3d2777427476e1b32c48c4", size = 2004870 }, + { url = "https://files.pythonhosted.org/packages/35/40/aea70b5b1a63911c53a4c8117c0a828d6790483f858041f47bab0b779f44/pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:de3cd1899e2c279b140adde9357c4495ed9d47131b4a4eaff9052f23398076b3", size = 1999822 }, + { url = "https://files.pythonhosted.org/packages/f2/b3/807b94fd337d58effc5498fd1a7a4d9d59af4133e83e32ae39a96fddec9d/pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:220f892729375e2d736b97d0e51466252ad84c51857d4d15f5e9692f9ef12be4", size = 2130364 }, + { url = "https://files.pythonhosted.org/packages/fc/df/791c827cd4ee6efd59248dca9369fb35e80a9484462c33c6649a8d02b565/pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a0fcd29cd6b4e74fe8ddd2c90330fd8edf2e30cb52acda47f06dd615ae72da57", size = 2158303 }, + { url = "https://files.pythonhosted.org/packages/9b/67/4e197c300976af185b7cef4c02203e175fb127e414125916bf1128b639a9/pydantic_core-2.27.2-cp312-cp312-win32.whl", hash = "sha256:1e2cb691ed9834cd6a8be61228471d0a503731abfb42f82458ff27be7b2186fc", size = 1834064 }, + { url = "https://files.pythonhosted.org/packages/1f/ea/cd7209a889163b8dcca139fe32b9687dd05249161a3edda62860430457a5/pydantic_core-2.27.2-cp312-cp312-win_amd64.whl", hash = "sha256:cc3f1a99a4f4f9dd1de4fe0312c114e740b5ddead65bb4102884b384c15d8bc9", size = 1989046 }, + { url = "https://files.pythonhosted.org/packages/bc/49/c54baab2f4658c26ac633d798dab66b4c3a9bbf47cff5284e9c182f4137a/pydantic_core-2.27.2-cp312-cp312-win_arm64.whl", hash = "sha256:3911ac9284cd8a1792d3cb26a2da18f3ca26c6908cc434a18f730dc0db7bfa3b", size = 1885092 }, + { url = "https://files.pythonhosted.org/packages/41/b1/9bc383f48f8002f99104e3acff6cba1231b29ef76cfa45d1506a5cad1f84/pydantic_core-2.27.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:7d14bd329640e63852364c306f4d23eb744e0f8193148d4044dd3dacdaacbd8b", size = 1892709 }, + { url = "https://files.pythonhosted.org/packages/10/6c/e62b8657b834f3eb2961b49ec8e301eb99946245e70bf42c8817350cbefc/pydantic_core-2.27.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:82f91663004eb8ed30ff478d77c4d1179b3563df6cdb15c0817cd1cdaf34d154", size = 1811273 }, + { url = "https://files.pythonhosted.org/packages/ba/15/52cfe49c8c986e081b863b102d6b859d9defc63446b642ccbbb3742bf371/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:71b24c7d61131bb83df10cc7e687433609963a944ccf45190cfc21e0887b08c9", size = 1823027 }, + { url = "https://files.pythonhosted.org/packages/b1/1c/b6f402cfc18ec0024120602bdbcebc7bdd5b856528c013bd4d13865ca473/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fa8e459d4954f608fa26116118bb67f56b93b209c39b008277ace29937453dc9", size = 1868888 }, + { url = "https://files.pythonhosted.org/packages/bd/7b/8cb75b66ac37bc2975a3b7de99f3c6f355fcc4d89820b61dffa8f1e81677/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce8918cbebc8da707ba805b7fd0b382816858728ae7fe19a942080c24e5b7cd1", size = 2037738 }, + { url = "https://files.pythonhosted.org/packages/c8/f1/786d8fe78970a06f61df22cba58e365ce304bf9b9f46cc71c8c424e0c334/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eda3f5c2a021bbc5d976107bb302e0131351c2ba54343f8a496dc8783d3d3a6a", size = 2685138 }, + { url = "https://files.pythonhosted.org/packages/a6/74/d12b2cd841d8724dc8ffb13fc5cef86566a53ed358103150209ecd5d1999/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bd8086fa684c4775c27f03f062cbb9eaa6e17f064307e86b21b9e0abc9c0f02e", size = 1997025 }, + { url = "https://files.pythonhosted.org/packages/a0/6e/940bcd631bc4d9a06c9539b51f070b66e8f370ed0933f392db6ff350d873/pydantic_core-2.27.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8d9b3388db186ba0c099a6d20f0604a44eabdeef1777ddd94786cdae158729e4", size = 2004633 }, + { url = "https://files.pythonhosted.org/packages/50/cc/a46b34f1708d82498c227d5d80ce615b2dd502ddcfd8376fc14a36655af1/pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:7a66efda2387de898c8f38c0cf7f14fca0b51a8ef0b24bfea5849f1b3c95af27", size = 1999404 }, + { url = "https://files.pythonhosted.org/packages/ca/2d/c365cfa930ed23bc58c41463bae347d1005537dc8db79e998af8ba28d35e/pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:18a101c168e4e092ab40dbc2503bdc0f62010e95d292b27827871dc85450d7ee", size = 2130130 }, + { url = "https://files.pythonhosted.org/packages/f4/d7/eb64d015c350b7cdb371145b54d96c919d4db516817f31cd1c650cae3b21/pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ba5dd002f88b78a4215ed2f8ddbdf85e8513382820ba15ad5ad8955ce0ca19a1", size = 2157946 }, + { url = "https://files.pythonhosted.org/packages/a4/99/bddde3ddde76c03b65dfd5a66ab436c4e58ffc42927d4ff1198ffbf96f5f/pydantic_core-2.27.2-cp313-cp313-win32.whl", hash = "sha256:1ebaf1d0481914d004a573394f4be3a7616334be70261007e47c2a6fe7e50130", size = 1834387 }, + { url = "https://files.pythonhosted.org/packages/71/47/82b5e846e01b26ac6f1893d3c5f9f3a2eb6ba79be26eef0b759b4fe72946/pydantic_core-2.27.2-cp313-cp313-win_amd64.whl", hash = "sha256:953101387ecf2f5652883208769a79e48db18c6df442568a0b5ccd8c2723abee", size = 1990453 }, + { url = "https://files.pythonhosted.org/packages/51/b2/b2b50d5ecf21acf870190ae5d093602d95f66c9c31f9d5de6062eb329ad1/pydantic_core-2.27.2-cp313-cp313-win_arm64.whl", hash = "sha256:ac4dbfd1691affb8f48c2c13241a2e3b60ff23247cbcf981759c768b6633cf8b", size = 1885186 }, +] + +[[package]] +name = "pydantic-settings" +version = "2.8.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/88/82/c79424d7d8c29b994fb01d277da57b0a9b09cc03c3ff875f9bd8a86b2145/pydantic_settings-2.8.1.tar.gz", hash = "sha256:d5c663dfbe9db9d5e1c646b2e161da12f0d734d422ee56f567d0ea2cee4e8585", size = 83550 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/53/a64f03044927dc47aafe029c42a5b7aabc38dfb813475e0e1bf71c4a59d0/pydantic_settings-2.8.1-py3-none-any.whl", hash = "sha256:81942d5ac3d905f7f3ee1a70df5dfb62d5569c12f51a5a647defc1c3d9ee2e9c", size = 30839 }, +] + +[[package]] +name = "pygments" +version = "2.19.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7c/2d/c3338d48ea6cc0feb8446d8e6937e1408088a72a39937982cc6111d17f84/pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f", size = 4968581 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293 }, +] + +[[package]] +name = "python-dotenv" +version = "1.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bc/57/e84d88dfe0aec03b7a2d4327012c1627ab5f03652216c63d49846d7a6c58/python-dotenv-1.0.1.tar.gz", hash = "sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca", size = 39115 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/3e/b68c118422ec867fa7ab88444e1274aa40681c606d59ac27de5a5588f082/python_dotenv-1.0.1-py3-none-any.whl", hash = "sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a", size = 19863 }, +] + +[[package]] +name = "pytz" +version = "2025.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/57/df1c9157c8d5a05117e455d66fd7cf6dbc46974f832b1058ed4856785d8a/pytz-2025.1.tar.gz", hash = "sha256:c2db42be2a2518b28e65f9207c4d05e6ff547d1efa4086469ef855e4ab70178e", size = 319617 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/38/ac33370d784287baa1c3d538978b5e2ea064d4c1b93ffbd12826c190dd10/pytz-2025.1-py2.py3-none-any.whl", hash = "sha256:89dd22dca55b46eac6eda23b2d72721bf1bdfef212645d81513ef5d03038de57", size = 507930 }, +] + +[[package]] +name = "pyyaml" +version = "6.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/86/0c/c581167fc46d6d6d7ddcfb8c843a4de25bdd27e4466938109ca68492292c/PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab", size = 183873 }, + { url = "https://files.pythonhosted.org/packages/a8/0c/38374f5bb272c051e2a69281d71cba6fdb983413e6758b84482905e29a5d/PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725", size = 173302 }, + { url = "https://files.pythonhosted.org/packages/c3/93/9916574aa8c00aa06bbac729972eb1071d002b8e158bd0e83a3b9a20a1f7/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5", size = 739154 }, + { url = "https://files.pythonhosted.org/packages/95/0f/b8938f1cbd09739c6da569d172531567dbcc9789e0029aa070856f123984/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425", size = 766223 }, + { url = "https://files.pythonhosted.org/packages/b9/2b/614b4752f2e127db5cc206abc23a8c19678e92b23c3db30fc86ab731d3bd/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476", size = 767542 }, + { url = "https://files.pythonhosted.org/packages/d4/00/dd137d5bcc7efea1836d6264f049359861cf548469d18da90cd8216cf05f/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48", size = 731164 }, + { url = "https://files.pythonhosted.org/packages/c9/1f/4f998c900485e5c0ef43838363ba4a9723ac0ad73a9dc42068b12aaba4e4/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b", size = 756611 }, + { url = "https://files.pythonhosted.org/packages/df/d1/f5a275fdb252768b7a11ec63585bc38d0e87c9e05668a139fea92b80634c/PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4", size = 140591 }, + { url = "https://files.pythonhosted.org/packages/0c/e8/4f648c598b17c3d06e8753d7d13d57542b30d56e6c2dedf9c331ae56312e/PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8", size = 156338 }, + { url = "https://files.pythonhosted.org/packages/ef/e3/3af305b830494fa85d95f6d95ef7fa73f2ee1cc8ef5b495c7c3269fb835f/PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba", size = 181309 }, + { url = "https://files.pythonhosted.org/packages/45/9f/3b1c20a0b7a3200524eb0076cc027a970d320bd3a6592873c85c92a08731/PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1", size = 171679 }, + { url = "https://files.pythonhosted.org/packages/7c/9a/337322f27005c33bcb656c655fa78325b730324c78620e8328ae28b64d0c/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133", size = 733428 }, + { url = "https://files.pythonhosted.org/packages/a3/69/864fbe19e6c18ea3cc196cbe5d392175b4cf3d5d0ac1403ec3f2d237ebb5/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484", size = 763361 }, + { url = "https://files.pythonhosted.org/packages/04/24/b7721e4845c2f162d26f50521b825fb061bc0a5afcf9a386840f23ea19fa/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5", size = 759523 }, + { url = "https://files.pythonhosted.org/packages/2b/b2/e3234f59ba06559c6ff63c4e10baea10e5e7df868092bf9ab40e5b9c56b6/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc", size = 726660 }, + { url = "https://files.pythonhosted.org/packages/fe/0f/25911a9f080464c59fab9027482f822b86bf0608957a5fcc6eaac85aa515/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652", size = 751597 }, + { url = "https://files.pythonhosted.org/packages/14/0d/e2c3b43bbce3cf6bd97c840b46088a3031085179e596d4929729d8d68270/PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", size = 140527 }, + { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446 }, +] + +[[package]] +name = "questionary" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "prompt-toolkit" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a8/b8/d16eb579277f3de9e56e5ad25280fab52fc5774117fb70362e8c2e016559/questionary-2.1.0.tar.gz", hash = "sha256:6302cdd645b19667d8f6e6634774e9538bfcd1aad9be287e743d96cacaf95587", size = 26775 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ad/3f/11dd4cd4f39e05128bfd20138faea57bec56f9ffba6185d276e3107ba5b2/questionary-2.1.0-py3-none-any.whl", hash = "sha256:44174d237b68bc828e4878c763a9ad6790ee61990e0ae72927694ead57bab8ec", size = 36747 }, +] + +[[package]] +name = "rich" +version = "13.9.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ab/3a/0316b28d0761c6734d6bc14e770d85506c986c85ffb239e688eeaab2c2bc/rich-13.9.4.tar.gz", hash = "sha256:439594978a49a09530cff7ebc4b5c7103ef57baf48d5ea3184f21d9a2befa098", size = 223149 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/71/39c7c0d87f8d4e6c020a393182060eaefeeae6c01dab6a84ec346f2567df/rich-13.9.4-py3-none-any.whl", hash = "sha256:6049d5e6ec054bf2779ab3358186963bac2ea89175919d699e378b99738c2a90", size = 242424 }, +] + +[[package]] +name = "rich-click" +version = "1.8.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "rich" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a6/7a/4b78c5997f2a799a8c5c07f3b2145bbcda40115c4d35c76fbadd418a3c89/rich_click-1.8.8.tar.gz", hash = "sha256:547c618dea916620af05d4a6456da797fbde904c97901f44d2f32f89d85d6c84", size = 39066 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/69/963f0bf44a654f6465bdb66fb5a91051b0d7af9f742b5bd7202607165036/rich_click-1.8.8-py3-none-any.whl", hash = "sha256:205aabd5a98e64ab2c105dee9e368be27480ba004c7dfa2accd0ed44f9f1550e", size = 35747 }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050 }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235 }, +] + +[[package]] +name = "tomlkit" +version = "0.13.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b1/09/a439bec5888f00a54b8b9f05fa94d7f901d6735ef4e55dcec9bc37b5d8fa/tomlkit-0.13.2.tar.gz", hash = "sha256:fff5fe59a87295b278abd31bec92c15d9bc4a06885ab12bcea52c71119392e79", size = 192885 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/b6/a447b5e4ec71e13871be01ba81f5dfc9d0af7e473da256ff46bc0e24026f/tomlkit-0.13.2-py3-none-any.whl", hash = "sha256:7a974427f6e119197f670fbbbeae7bef749a6c14e793db934baefc1b5f03efde", size = 37955 }, +] + +[[package]] +name = "typing-extensions" +version = "4.12.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/df/db/f35a00659bc03fec321ba8bce9420de607a1d37f8342eee1863174c69557/typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8", size = 85321 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", size = 37438 }, +] + +[[package]] +name = "uv" +version = "0.6.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/71/c5/6d5a98618437255a70106338a5e3aaf154b18e3ef0e0313bbe79791cd792/uv-0.6.6.tar.gz", hash = "sha256:abf8be1e056f9d36ddda9c3c8c07510f6d4fe61915d4cd797374756f58249c81", size = 3071736 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/7a/a01226a4c2282afcab0e518082772cc3f5133c325d428f8e298c1aac7c5a/uv-0.6.6-py3-none-linux_armv6l.whl", hash = "sha256:8a6d2aca8794e72e2e68ebfae06b8697bb0ea8a8d016229109125d364f743b7a", size = 15662414 }, + { url = "https://files.pythonhosted.org/packages/41/13/0258d919d97358516a670c5ca354e0fb6af8bdd2caa3c8e141c55547d426/uv-0.6.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:c55f1ebc980bd4a2013999e0a22e2796deb08b56c7815942d74ba23abce8d4fc", size = 15604372 }, + { url = "https://files.pythonhosted.org/packages/5b/81/cbc733571f07d1177f95c4b531756db1fd2e348f2105a0ac93527d5e0d10/uv-0.6.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d4b41b3024ca55c17e7dfda1e907249e598379a8202d2a76e05018156a1c0501", size = 14536284 }, + { url = "https://files.pythonhosted.org/packages/e8/23/d29f270e0b6bf8a2af9bef4af4e43f47873373dfd7c7f031b75f50d0596b/uv-0.6.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:7f0836ba3d9c979e744a0991b212934877b786541fd5c9ee7eff99a3f8c9dd6a", size = 14971148 }, + { url = "https://files.pythonhosted.org/packages/fc/c9/5c218dafe1135bbbf0ab9174686344554645f8ebe908351079f31c4bfc57/uv-0.6.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8733355d21e325bb896bd2b7bc37bbcb888097d532ce14265efbb53beaf07ca0", size = 15391689 }, + { url = "https://files.pythonhosted.org/packages/be/6a/e8e363458096e00841d205fbfa502a94e986284111bdd0b5130e952bcb90/uv-0.6.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:af832fe366bc6174fe822b968bbeb1bb1f8aeb42418941281a696257a5669bb7", size = 15957340 }, + { url = "https://files.pythonhosted.org/packages/66/88/110b95b9bc8652c24176fdca74cc317f9558dddf6737158d3df65bfb64ab/uv-0.6.6-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:c05f376f56a925d43304ee02d2915df002497fa1c3f51908252b868704131c32", size = 16898780 }, + { url = "https://files.pythonhosted.org/packages/9d/f5/20793e443af05c4755e8e7ead85b6fd70073204682e34eced190786d33bc/uv-0.6.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8105a72d6502d5a5fbf404afa1124afe6501878ce6a05caeac29e457cea2785", size = 16628180 }, + { url = "https://files.pythonhosted.org/packages/c7/f9/90ad562eec31c5aa20987964450606d8080c1e0eafb5b303be7cdb1dfd57/uv-0.6.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f0f4934dbfff8ea30800aeda5e9336fc2dc06710f3a6216fac783bc63f98fc54", size = 20832699 }, + { url = "https://files.pythonhosted.org/packages/14/65/84399efca40f3abf51958f289b65b5ae9e643817e9ed98defbe4da97efca/uv-0.6.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fe1f84bced6d373a02d8b960efc3a4b65d63ab19e1fdc4f12a56a483d687f4db", size = 16233044 }, + { url = "https://files.pythonhosted.org/packages/26/5f/c7534ae000a31d4eca939668234ec385abab925b28d1514a6c5f01155384/uv-0.6.6-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:257b44eb43790c1cde59527f53efd1263528bf791959c94be40c3d32c8ac4e6d", size = 15254345 }, + { url = "https://files.pythonhosted.org/packages/8a/70/9df763ee88b054729118ca4caf5479160d741a2e3303a81f5c447c9b76ff/uv-0.6.6-py3-none-musllinux_1_1_armv7l.whl", hash = "sha256:5a6839ff6cdaa2fc7864ae893d725d04dd914e36fb20f64c6603edc4a17dfe78", size = 15396565 }, + { url = "https://files.pythonhosted.org/packages/15/3d/231379ca356cd3468633d712e099e801b597a06f891f3bb7ec3aed2c071a/uv-0.6.6-py3-none-musllinux_1_1_i686.whl", hash = "sha256:1d62a3fb6fdbb05518e5124950d252033908e8e2dd98e17c63fd9b0aa807da6f", size = 15574407 }, + { url = "https://files.pythonhosted.org/packages/d1/4d/e3a00a5cd318ba6d131c1d566f87cc449b54fc84b9010af0b5bfa252bd36/uv-0.6.6-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:61f13d8af0aad5b1f9332fee2cd7eeeec5cf93634c5071bbbcf5d338a0920098", size = 16375912 }, + { url = "https://files.pythonhosted.org/packages/77/ef/511a9ac6cd732e5ba581426bd9f9983606511c2e676f696dbd1b7a9c72c0/uv-0.6.6-py3-none-win32.whl", hash = "sha256:419e8cd84db545a0880223fd0a042c063a1412179903797a87f5bd0d1613cdbd", size = 15720370 }, + { url = "https://files.pythonhosted.org/packages/7b/d4/8f2df45ef1cfb645f38e48595532c8406658f702a330f5d002033e84ebfd/uv-0.6.6-py3-none-win_amd64.whl", hash = "sha256:c9802cac1fb9cbff97d1adf2c2516f2f368eea60c7d6a8e3a474f2bca7b44c6c", size = 17110840 }, + { url = "https://files.pythonhosted.org/packages/6b/bc/9cf8ffe31607e32bc1de05edea2c11158b3aa7309cffc8e59ec7409a4988/uv-0.6.6-py3-none-win_arm64.whl", hash = "sha256:b804a7f8f37c109e714ce02084735cc39a96b7e3062e58420120fe4798a65ef1", size = 15930063 }, +] + +[[package]] +name = "validators" +version = "0.34.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/64/07/91582d69320f6f6daaf2d8072608a4ad8884683d4840e7e4f3a9dbdcc639/validators-0.34.0.tar.gz", hash = "sha256:647fe407b45af9a74d245b943b18e6a816acf4926974278f6dd617778e1e781f", size = 70955 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6e/78/36828a4d857b25896f9774c875714ba4e9b3bc8a92d2debe3f4df3a83d4f/validators-0.34.0-py3-none-any.whl", hash = "sha256:c804b476e3e6d3786fa07a30073a4ef694e617805eb1946ceee3fe5a9b8b1321", size = 43536 }, +] + +[[package]] +name = "wcmatch" +version = "10.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "bracex" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/41/ab/b3a52228538ccb983653c446c1656eddf1d5303b9cb8b9aef6a91299f862/wcmatch-10.0.tar.gz", hash = "sha256:e72f0de09bba6a04e0de70937b0cf06e55f36f37b3deb422dfaf854b867b840a", size = 115578 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/df/4ee467ab39cc1de4b852c212c1ed3becfec2e486a51ac1ce0091f85f38d7/wcmatch-10.0-py3-none-any.whl", hash = "sha256:0dd927072d03c0a6527a20d2e6ad5ba8d0380e60870c383bc533b71744df7b7a", size = 39347 }, +] + +[[package]] +name = "wcwidth" +version = "0.2.13" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6c/63/53559446a878410fc5a5974feb13d31d78d752eb18aeba59c7fef1af7598/wcwidth-0.2.13.tar.gz", hash = "sha256:72ea0c06399eb286d978fdedb6923a9eb47e1c486ce63e9b4e64fc18303972b5", size = 101301 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/84/fd2ba7aafacbad3c4201d395674fc6348826569da3c0937e75505ead3528/wcwidth-0.2.13-py2.py3-none-any.whl", hash = "sha256:3da69048e4540d84af32131829ff948f1e022c1c6bdb8d6102117aac784f6859", size = 34166 }, +]