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 },
+]