🔖 Bump version: 0.1.0 → 1.0.0
This commit is contained in:
parent
cbf68294ca
commit
b7311d088d
21 changed files with 1993 additions and 223 deletions
0
.env
0
.env
270
.gitignore
vendored
Normal file
270
.gitignore
vendored
Normal file
|
@ -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
|
||||
|
3
.idea/adGroupSync.iml
generated
3
.idea/adGroupSync.iml
generated
|
@ -2,9 +2,10 @@
|
|||
<module type="PYTHON_MODULE" version="4">
|
||||
<component name="NewModuleRootManager">
|
||||
<content url="file://$MODULE_DIR$">
|
||||
<sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/.venv" />
|
||||
</content>
|
||||
<orderEntry type="jdk" jdkName="Python 3.12 (adGroupSync)" jdkType="Python SDK" />
|
||||
<orderEntry type="jdk" jdkName="Python 3.12 virtualenv at ~/development/repositories/adGroupSync/.venv" jdkType="Python SDK" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
</module>
|
5
.idea/misc.xml
generated
5
.idea/misc.xml
generated
|
@ -1,7 +1,10 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="Black">
|
||||
<option name="sdkName" value="Python 3.12 (adGroupSync)" />
|
||||
</component>
|
||||
<component name="MarkdownSettingsMigration">
|
||||
<option name="stateVersion" value="1" />
|
||||
</component>
|
||||
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.8 (tazPlease)" project-jdk-type="Python SDK" />
|
||||
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.12 virtualenv at ~/development/repositories/adGroupSync/.venv" project-jdk-type="Python SDK" />
|
||||
</project>
|
1
.python-version
Normal file
1
.python-version
Normal file
|
@ -0,0 +1 @@
|
|||
3.12
|
72
README.md
72
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
|
||||
```
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
@ -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)
|
64
pyproject.toml
Normal file
64
pyproject.toml
Normal file
|
@ -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<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<patch>\\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 = []
|
2
src/adgroupsync/__init__.py
Normal file
2
src/adgroupsync/__init__.py
Normal file
|
@ -0,0 +1,2 @@
|
|||
__version__ = "1.0.0"
|
||||
__author__ = "Marc Koch"
|
289
src/adgroupsync/__main__.py
Normal file
289
src/adgroupsync/__main__.py
Normal file
|
@ -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()
|
128
src/adgroupsync/conf.py
Normal file
128
src/adgroupsync/conf.py
Normal file
|
@ -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)
|
11
src/adgroupsync/enums.py
Normal file
11
src/adgroupsync/enums.py
Normal file
|
@ -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
|
|
@ -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
|
548
src/adgroupsync/models.py
Normal file
548
src/adgroupsync/models.py
Normal file
|
@ -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,
|
||||
})
|
25
src/adgroupsync/resources/example_config.yml
Normal file
25
src/adgroupsync/resources/example_config.yml
Normal file
|
@ -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
|
|
@ -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
|
521
uv.lock
generated
Normal file
521
uv.lock
generated
Normal file
|
@ -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 },
|
||||
]
|
Loading…
Add table
Add a link
Reference in a new issue