replace tomlib with tomlkit

tomlkit can read and write toml files
This commit is contained in:
Marc Koch 2025-03-21 13:22:23 +01:00 committed by leiva
parent 19603d6d9f
commit e8503bd073
No known key found for this signature in database
8 changed files with 145 additions and 156 deletions

2
.gitignore vendored
View file

@ -1,5 +1,5 @@
adgroupsync_config.yml
adgroupsync_config.toml
# Created by https://www.toptal.com/developers/gitignore/api/pycharm+all
# Edit at https://www.toptal.com/developers/gitignore?templates=pycharm+all

View file

@ -14,7 +14,7 @@ pipx install --include-deps --index-url https://git.propeace.de/api/packages/Pro
Create a new configuration file:
```bash
adgroupsync --create-config --conf /path/to/adgroupsync_config.yaml
adgroupsync --create-config --conf /path/to/adgroupsync_config.toml
```
Edit the configuration file and set the following values:
@ -22,37 +22,39 @@ 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.LDAP_SERVER`: List of LDAP servers to connect to.
- `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.
- `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.
### Civicrm Configuration
- `CIVICRM.BASE_URL`: The URL of the CiviCRM server.
- `CIVICRM.API_KEY`: The API key of the CiviCRM user.
- `CIVICRM.BASE_URL`: The URL of the CiviCRM server.
- `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_
- `CIVICRM.IGNORE_SSL`: Allow insecure connections to the CiviCRM server.
_DEFAULT: False_
### 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_
- `LOGGING.LOG_DIR`: The directory to store the log file. _DEFAULT:
`/var/log/adGroupSync/`
### 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.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.
@ -61,7 +63,7 @@ configure [ntfy](https://ntfy.sh/).
### Manual Sync
```bash
adgroupsync --conf /path/to/adgroupsync_config.yaml
adgroupsync --conf /path/to/adgroupsync_config.toml
```
### Cron Job
@ -69,5 +71,5 @@ adgroupsync --conf /path/to/adgroupsync_config.yaml
Synchronize the groups every 10 minutes:
```bash
*/10 * * * * adgroupsync --conf /path/to/adgroupsync_config.yaml 2>&1
*/10 * * * * adgroupsync --conf /path/to/adgroupsync_config.toml > /dev/null 2>&1
```

View file

@ -18,8 +18,7 @@ dependencies = [
"httpx>=0.28.1",
"ms-active-directory>=1.14.1",
"python-crontab>=3.2.0",
"pyyaml>=6.0.2",
"tomli-w>=1.2.0",
"tomlkit>=0.13.2",
"validators>=0.34.0",
]

View file

@ -5,7 +5,6 @@ from pathlib import Path
from ldap3 import SIMPLE
from ms_active_directory import ADDomain, ADGroup, ADUser, ADObject
from .exceptions import ScriptAlreadyRunningError
from .conf import (
AD_DOMAIN,
AD_USER_NAME,
@ -25,6 +24,7 @@ from .conf import (
NTFY_TOPIC,
NTFY_ACCESS_TOKEN,
)
from .exceptions import ScriptAlreadyRunningError
from .logger import setup_logging
from .models import RecentRun, CiviCrm, Ntfy

View file

@ -6,37 +6,83 @@ from crontab import CronTab, CronSlices
import shutil
import pytz
import yaml
import tomlkit
from tomlkit.toml_file import TOMLFile
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)
dest = dest / f"{__package__}_config.toml"
conf = tomlkit.document()
conf.add(tomlkit.comment(f"Configuration file for {__package__}"))
conf.add(tomlkit.nl())
def create_cron_job(cron_job: str, config_file: Path):
# Check if the script exists and its executable
if not shutil.which(__package__):
print(
f"No executable found, please add this to your crontab manually: '/path/to/adgroupsync --conf {config_file} >/dev/null 2>&1'"
)
# Add AD section
ad = tomlkit.table()
ad.add('DOMAIN', 'ad.example.com')
ad['DOMAIN'].comment('The domain of the Active Directory server')
ad.add('LDAP_SERVER', ['ldaps://server1.ad.example.com:636'])
ad['LDAP_SERVER'].comment('List of LDAP servers to connect to')
ad.add('PARENT_GROUP', 'Mailinglists')
ad['PARENT_GROUP'].comment('The parent group in Active Directory that '
'contains all groups that should be '
'synchronized')
ad.add('TIMEZONE', 'UTC')
ad.add('USER', tomlkit.string('example\\username', literal=True))
ad['USER'].comment('The username of the user to connect to the Active '
'Directory server. (use single quotes)')
ad.add('PASSWORD', 'xxxxxxxx')
ad['PASSWORD'].comment('The password of the user to connect to the Active '
'Directory')
conf.add('AD', ad)
# Checking if the string is valid
if not CronSlices.is_valid(cron_job):
raise Exception(f"Cron job '{cron_job}' is not valid.")
# Add CiviCRM section
civicrm = tomlkit.table()
civicrm.add('BASE_URL', 'https://civicrm.example.com')
civicrm.add('API_KEY', 'xxxxxxxx')
civicrm['API_KEY'].comment('The API key of the CiviCRM user')
civicrm['BASE_URL'].comment('The URL of the CiviCRM server')
civicrm.add('BATCH_SIZE', 50)
civicrm['BATCH_SIZE'].comment('The batch size for the API requests to the '
'CiviCRM server - only applied to contact '
'sync (default: 50)')
civicrm.add('RETRIES', 3)
civicrm['RETRIES'].comment('The number of retries for the API requests to '
'the CiviCRM (default: 3)')
civicrm.add('IGNORE_SSL', False)
civicrm.value.item('IGNORE_SSL').comment('Allow insecure connections to '
'the CiviCRM server (default: '
'false)')
conf.add('CIVICRM', civicrm)
# Creating the cron job
cron = CronTab(user=True)
job = cron.new(command=f"adgroupsync --conf {config_file} >/dev/null 2>&1")
job.setall(cron_job)
cron.write()
# Add Logging section
logging_ = tomlkit.table()
logging_.add('STDOUT_LOG_LEVEL', 'info')
logging_['STDOUT_LOG_LEVEL'].comment('The log level for the stdout logger '
'(default: info)')
logging_.add('FILE_LOG_LEVEL', 'info')
logging_['FILE_LOG_LEVEL'].comment('The log level for the file logger '
'(default: info)')
logging_.add('LOG_DIR', '/var/log/adGroupSync/')
logging_['LOG_DIR'].comment('The directory to store the log file')
conf.add('LOGGING', logging_)
# Add Ntfy section
ntfy = tomlkit.table()
ntfy.add(tomlkit.comment('Optional section for ntfy configuration'))
ntfy.add('URL', 'https://ntfy.example.com')
ntfy['URL'].comment('The URL of the ntfy server')
ntfy.add('TOPIC', 'adGroupSync')
ntfy['TOPIC'].comment('The topic to post the message to')
ntfy.add('ACCESS_TOKEN', 'tk_xxxxxxxxxxxxxxxxxxx')
ntfy['ACCESS_TOKEN'].comment('The access token for the ntfy server')
conf.add('NTFY', ntfy)
# Write configuration file
TOMLFile(dest).write(conf)
# Assign environment variables or configuration file values
AD_DOMAIN = os.getenv("AD_DOMAIN")
@ -124,39 +170,36 @@ try:
exit(0)
# Load configuration file
with open(config_file, "r") as file:
config = yaml.safe_load(file)
config = TOMLFile(config_file).read()
# 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
)
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 = {

View file

@ -1,16 +1,15 @@
import datetime
import json
import logging
import tomllib
from collections import deque
from datetime import datetime as dt, timezone
from pathlib import Path
import pytz
import tomli_w
from civifang import api
from httpx import post
from ms_active_directory import ADUser, ADGroup
from tomlkit.toml_file import TOMLFile
from .enums import Priority
from .exceptions import ScriptAlreadyRunningError
@ -56,26 +55,34 @@ class RecentRun:
:param is_running:
:return:
"""
rr = recent_run if recent_run else self._datetime
is_running = is_running if is_running is not None else self._already_running
new_data = {
'recent-run': rr,
'is-running': is_running,
}
# Return if no data is provided
if recent_run is None and is_running is None:
return
# Read the existing data
toml_file = TOMLFile(self._file_path)
toml = toml_file.read()
# Update the values
if recent_run:
toml['recent_run'] = recent_run
if is_running is not None:
toml['is-running'] = is_running
# Write the data to the file
with open(self._file_path, 'wb') as f:
tomli_w.dump(new_data, f)
toml_file.write(toml)
def _read_data_from_file(self):
"""
Read the recent run time from the file
:return:
"""
with open(self._file_path, 'rb') as f:
data = tomllib.load(f)
self._already_running = data.get('is-running', False)
recent_run = data.get('recent-run')
# Read the data from the file
toml_file = TOMLFile(self._file_path)
toml = toml_file.read()
self._already_running = toml.get('is-running', False)
recent_run = toml.get('recent-run')
# Set the datetime to the recent run time
if not recent_run:

View file

@ -1,25 +0,0 @@
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

43
uv.lock generated
View file

@ -4,14 +4,13 @@ requires-python = ">=3.12"
[[package]]
name = "adgroupsync"
version = "1.0.0"
version = "1.1.0"
source = { editable = "." }
dependencies = [
{ name = "civifang" },
{ name = "httpx" },
{ name = "ms-active-directory" },
{ name = "pyyaml" },
{ name = "tomli-w" },
{ name = "tomlkit" },
{ name = "validators" },
]
@ -26,8 +25,7 @@ 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 = "tomli-w", specifier = ">=1.2.0" },
{ name = "tomlkit", specifier = ">=0.13.2" },
{ name = "validators", specifier = ">=0.34.0" },
]
@ -366,32 +364,6 @@ 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"
@ -449,15 +421,6 @@ 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 = "tomli-w"
version = "1.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/19/75/241269d1da26b624c0d5e110e8149093c759b7a286138f4efd61a60e75fe/tomli_w-1.2.0.tar.gz", hash = "sha256:2dd14fac5a47c27be9cd4c976af5a12d87fb1f0b4512f81d69cce3b35ae25021", size = 7184 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/18/c86eb8e0202e32dd3df50d43d7ff9854f8e0603945ff398974c1d91ac1ef/tomli_w-1.2.0-py3-none-any.whl", hash = "sha256:188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90", size = 6675 },
]
[[package]]
name = "tomlkit"
version = "0.13.2"