From 08aa8934a5de6a6030ec61a1f7461c040e3d222c Mon Sep 17 00:00:00 2001 From: Tom Hacohen Date: Mon, 2 Sep 2024 11:32:36 -0500 Subject: [PATCH 1/2] CI: update Python version --- .github/workflows/ci.yml | 9 ++++----- .github/workflows/lint.yml | 8 ++++---- .github/workflows/manual.yml | 6 +++--- setup.py | 1 - 4 files changed, 11 insertions(+), 13 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a4827948..2f084d68 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,12 +30,11 @@ jobs: matrix: python: # Versions 3.0 - 3.5 are not provided by actions/python-versions - - '3.6' - - '3.7' - - '3.8' - '3.9' - '3.10' - os: [ubuntu-18.04, windows-latest, macos-latest] + - '3.11' + - '3.12' + os: [ubuntu-latest, windows-latest, macos-latest] steps: - name: Checkout code @@ -44,7 +43,7 @@ jobs: fetch-depth: 5 - name: Set up Python ${{ matrix.python }} - uses: actions/setup-python@v2 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python }} diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 4cbd84b6..d594cd4b 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -11,17 +11,17 @@ on: jobs: lint: - runs-on: ubuntu-18.04 + runs-on: ubuntu-latest name: Code linting steps: - name: Checkout code uses: actions/checkout@v2 - - name: Set up Python 3.7 - uses: actions/setup-python@v2 + - name: Set up Python 3.21 + uses: actions/setup-python@v5 with: - python-version: '3.7' + python-version: '3.12' - name: Update pip and install deps run: | diff --git a/.github/workflows/manual.yml b/.github/workflows/manual.yml index 4527877a..4e09664b 100644 --- a/.github/workflows/manual.yml +++ b/.github/workflows/manual.yml @@ -11,15 +11,15 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - os: [ubuntu-18.04, windows-latest, macos-10.15] + os: [ubuntu-latest, windows-latest, macos-10.15] steps: - uses: actions/checkout@v2 - - uses: actions/setup-python@v2 + - uses: actions/setup-python@v5 name: Install Python with: - python-version: '3.8' + python-version: '3.12' - name: Update pip and install deps run: | diff --git a/setup.py b/setup.py index 77c7fc5a..d81ee396 100644 --- a/setup.py +++ b/setup.py @@ -115,7 +115,6 @@ def get_version_string(): 'Flask>=1.1.1', 'Flask-WTF>=0.14.2,<1.0.0', 'requests[socks]>=2.21', - 'types-setuptools>=50.0.0', 'pyobjc-framework-Cocoa>=7.0.0 ; sys_platform=="darwin"', ] From 50aa45a2020c5735b1801be114bb30c982aa8b89 Mon Sep 17 00:00:00 2001 From: Tom Hacohen Date: Mon, 2 Sep 2024 11:44:38 -0500 Subject: [PATCH 2/2] Switch to ruff and reformat code. --- .github/workflows/ci.yml | 1 + .github/workflows/lint.yml | 8 +- .github/workflows/manual.yml | 1 + MANIFEST.in | 2 + etesync_dav/__init__.py | 14 +- etesync_dav/config.py | 31 ++-- etesync_dav/local_cache/__init__.py | 44 +++--- etesync_dav/local_cache/models.py | 14 +- etesync_dav/mac_helpers.py | 53 ++++--- etesync_dav/manage.py | 28 ++-- etesync_dav/radicale/__init__.py | 2 +- etesync_dav/radicale/creds.py | 31 ++-- etesync_dav/radicale/etesync_cache.py | 15 +- etesync_dav/radicale/href_mapper.py | 5 +- etesync_dav/radicale/rights.py | 9 +- etesync_dav/radicale/storage.py | 104 +++++++------- .../radicale/storage_etebase_collection.py | 75 +++++----- etesync_dav/radicale/web.py | 9 +- etesync_dav/radicale_main/__init__.py | 47 +++--- etesync_dav/radicale_main/server.py | 83 +++++------ etesync_dav/webui.py | 134 ++++++++++-------- pyinstaller/hooks/hook-dateutil.py | 2 +- pyinstaller/hooks/hook-etesync_dav.py | 8 +- pyinstaller/hooks/hook-vobject.py | 2 +- pyproject.toml | 28 ++++ requirements-dev.txt | 26 ++++ requirements.in/development.txt | 2 + setup.py | 112 +++++++-------- 28 files changed, 472 insertions(+), 418 deletions(-) create mode 100644 pyproject.toml create mode 100644 requirements-dev.txt create mode 100644 requirements.in/development.txt diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2f084d68..a39b269a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,6 +49,7 @@ jobs: - name: Minimal setuptools load-test run: | + python -m pip install setuptools python setup.py --fullname python setup.py --description python setup.py --long-description diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index d594cd4b..07529a96 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -25,11 +25,15 @@ jobs: - name: Update pip and install deps run: | + python -m pip install setuptools python -m pip install --upgrade pip - python -m pip install check-manifest flake8 + python -m pip install check-manifest + python -m pip install -r requirements-dev.txt - name: Check MANIFEST.in in a source package run: check-manifest -v - name: Static code analysis and code style check - run: flake8 ./ + run: | + ruff check . + ruff format --check . diff --git a/.github/workflows/manual.yml b/.github/workflows/manual.yml index 4e09664b..14e82195 100644 --- a/.github/workflows/manual.yml +++ b/.github/workflows/manual.yml @@ -24,6 +24,7 @@ jobs: - name: Update pip and install deps run: | python -m pip install --upgrade pip pyinstaller wheel + python -m pip install setuptools python -m pip install -r requirements.txt . - name: Build binaries diff --git a/MANIFEST.in b/MANIFEST.in index 395dedb8..7cbd5ffe 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -24,6 +24,8 @@ recursive-exclude pyinstaller * # Include requirements.txt for reproducible tests include requirements.txt +include requirements.in/*.txt +include requirements-dev.txt # Add templates for the management UI. recursive-include etesync_dav/templates * diff --git a/etesync_dav/__init__.py b/etesync_dav/__init__.py index 1fb6c62d..5106dc00 100644 --- a/etesync_dav/__init__.py +++ b/etesync_dav/__init__.py @@ -32,10 +32,10 @@ """ -__copyright__ = 'Copyright (C) 2017-2021 Tom Hacohen' -__version__ = '0.32.1' -__license__ = 'GPL-3.0-only' -__author__ = 'Tom Hacohen' -__author_email__ = 'tom@stosb.com' -__url__ = 'https://github.com/etesync/etesync-dav' -__description__ = 'A CalDAV and CardDAV frontend for EteSync' +__copyright__ = "Copyright (C) 2017-2021 Tom Hacohen" +__version__ = "0.32.1" +__license__ = "GPL-3.0-only" +__author__ = "Tom Hacohen" +__author_email__ = "tom@stosb.com" +__url__ = "https://github.com/etesync/etesync-dav" +__description__ = "A CalDAV and CardDAV frontend for EteSync" diff --git a/etesync_dav/config.py b/etesync_dav/config.py index 0cdb4ead..40093ddf 100644 --- a/etesync_dav/config.py +++ b/etesync_dav/config.py @@ -12,25 +12,26 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -from appdirs import user_config_dir, user_data_dir import os -LISTEN_ADDRESS = os.environ.get('ETESYNC_LISTEN_ADDRESS', 'localhost') -LISTEN_PORT = os.environ.get('ETESYNC_LISTEN_PORT', '37358') +from appdirs import user_config_dir, user_data_dir + +LISTEN_ADDRESS = os.environ.get("ETESYNC_LISTEN_ADDRESS", "localhost") +LISTEN_PORT = os.environ.get("ETESYNC_LISTEN_PORT", "37358") -DEFAULT_HOSTS = '{}:{}'.format(LISTEN_ADDRESS, LISTEN_PORT) +DEFAULT_HOSTS = "{}:{}".format(LISTEN_ADDRESS, LISTEN_PORT) -SERVER_HOSTS = os.environ.get('ETESYNC_SERVER_HOSTS', DEFAULT_HOSTS) -LEGACY_CONFIG_DIR = os.environ.get('ETESYNC_CONFIG_DIR', user_config_dir("etesync-dav", "etesync")) -DATA_DIR = os.environ.get('ETESYNC_DATA_DIR', user_data_dir("etesync-dav", "etesync")) +SERVER_HOSTS = os.environ.get("ETESYNC_SERVER_HOSTS", DEFAULT_HOSTS) +LEGACY_CONFIG_DIR = os.environ.get("ETESYNC_CONFIG_DIR", user_config_dir("etesync-dav", "etesync")) +DATA_DIR = os.environ.get("ETESYNC_DATA_DIR", user_data_dir("etesync-dav", "etesync")) -ETESYNC_URL = os.environ.get('ETESYNC_URL', 'https://api.etebase.com/partner/etesync/') -LEGACY_ETESYNC_URL = os.environ.get('ETESYNC_URL', 'https://api.etesync.com/') -DATABASE_FILE = os.environ.get('ETESYNC_DATABASE_FILE', os.path.join(DATA_DIR, 'etesync_data.db')) -ETEBASE_DATABASE_FILE = os.environ.get('ETEBASE_DATABASE_FILE', os.path.join(DATA_DIR, 'etebase_data.db')) +ETESYNC_URL = os.environ.get("ETESYNC_URL", "https://api.etebase.com/partner/etesync/") +LEGACY_ETESYNC_URL = os.environ.get("ETESYNC_URL", "https://api.etesync.com/") +DATABASE_FILE = os.environ.get("ETESYNC_DATABASE_FILE", os.path.join(DATA_DIR, "etesync_data.db")) +ETEBASE_DATABASE_FILE = os.environ.get("ETEBASE_DATABASE_FILE", os.path.join(DATA_DIR, "etebase_data.db")) -HTPASSWD_FILE = os.path.join(DATA_DIR, 'htpaswd') -CREDS_FILE = os.path.join(DATA_DIR, 'etesync_creds') +HTPASSWD_FILE = os.path.join(DATA_DIR, "htpaswd") +CREDS_FILE = os.path.join(DATA_DIR, "etesync_creds") -SSL_KEY_FILE = os.path.join(DATA_DIR, 'etesync.key') -SSL_CERT_FILE = os.path.join(DATA_DIR, 'etesync.crt') +SSL_KEY_FILE = os.path.join(DATA_DIR, "etesync.key") +SSL_CERT_FILE = os.path.join(DATA_DIR, "etesync.crt") diff --git a/etesync_dav/local_cache/__init__.py b/etesync_dav/local_cache/__init__.py index c5e5c19a..9c2d7c4b 100644 --- a/etesync_dav/local_cache/__init__.py +++ b/etesync_dav/local_cache/__init__.py @@ -1,13 +1,12 @@ import os import msgpack +from etebase import Account, Client, CollectionAccessLevel, FetchOptions -from etebase import Account, Client, FetchOptions, CollectionAccessLevel from etesync_dav import config from . import db, models - COL_TYPES = ["etebase.vcard", "etebase.vevent", "etebase.vtodo"] @@ -30,11 +29,12 @@ def msgpack_decode(content): def batch(iterable, n=1): length = len(iterable) for ndx in range(0, length, n): - yield iterable[ndx:min(ndx + n, length)] + yield iterable[ndx : min(ndx + n, length)] def get_millis(): import time + return int(round(time.time() * 1000)) @@ -65,26 +65,30 @@ def _init_db(self, db_path): from playhouse.sqlite_ext import SqliteExtDatabase directory = os.path.dirname(db_path) - if directory != '' and not os.path.exists(directory): + if directory != "" and not os.path.exists(directory): os.makedirs(directory) - database = SqliteExtDatabase(db_path, pragmas={ - 'journal_mode': 'wal', - 'foreign_keys': 1, - }) + database = SqliteExtDatabase( + db_path, + pragmas={ + "journal_mode": "wal", + "foreign_keys": 1, + }, + ) self._set_db(database) def _init_db_tables(self, database, additional_tables=None): CURRENT_DB_VERSION = 1 - database.create_tables([models.Config, models.User, models.CollectionEntity, - models.ItemEntity, models.HrefMapper], safe=True) + database.create_tables( + [models.Config, models.User, models.CollectionEntity, models.ItemEntity, models.HrefMapper], safe=True + ) if additional_tables: database.create_tables(additional_tables, safe=True) default_db_version = CURRENT_DB_VERSION - config, created = models.Config.get_or_create(defaults={'db_version': default_db_version}) + config, created = models.Config.get_or_create(defaults={"db_version": default_db_version}) def sync(self): self.sync_collection_list() @@ -235,8 +239,12 @@ def get(self, uid): with db.database_proxy: col_mgr = self.etebase.get_collection_manager() try: - return Collection(col_mgr, self.user.collections.where( - (models.CollectionEntity.uid == uid) & ~models.CollectionEntity.deleted).get()) + return Collection( + col_mgr, + self.user.collections.where( + (models.CollectionEntity.uid == uid) & ~models.CollectionEntity.deleted + ).get(), + ) except models.CollectionEntity.DoesNotExist as e: raise DoesNotExist(e) @@ -301,10 +309,10 @@ def get(self, uid): with db.database_proxy: item_mgr = self.col_mgr.get_item_manager(self.col) try: - return Item(item_mgr, - self.cache_col.items.where( - (models.ItemEntity.uid == uid) & ~models.ItemEntity.deleted - ).get()) + return Item( + item_mgr, + self.cache_col.items.where((models.ItemEntity.uid == uid) & ~models.ItemEntity.deleted).get(), + ) except models.ItemEntity.DoesNotExist: return None @@ -323,7 +331,7 @@ def __init__(self, item_mgr, cache_item): @property def uid(self): - return self.meta['name'] + return self.meta["name"] # FIXME: cache @property diff --git a/etesync_dav/local_cache/models.py b/etesync_dav/local_cache/models.py index a107578d..d2462d43 100644 --- a/etesync_dav/local_cache/models.py +++ b/etesync_dav/local_cache/models.py @@ -13,7 +13,7 @@ class User(db.BaseModel): class CollectionEntity(db.BaseModel): - local_user = pw.ForeignKeyField(User, backref='collections', on_delete='CASCADE') + local_user = pw.ForeignKeyField(User, backref="collections", on_delete="CASCADE") # The uid of the collection (same as Etebase) uid = pw.CharField(null=False, index=True) eb_col = pw.BlobField() @@ -24,13 +24,11 @@ class CollectionEntity(db.BaseModel): local_stoken = pw.CharField(null=True, default=None) class Meta: - indexes = ( - (('local_user', 'uid'), True), - ) + indexes = ((("local_user", "uid"), True),) class ItemEntity(db.BaseModel): - collection = pw.ForeignKeyField(CollectionEntity, backref='items', on_delete='CASCADE') + collection = pw.ForeignKeyField(CollectionEntity, backref="items", on_delete="CASCADE") # The uid of the content (vobject uid) uid = pw.CharField(null=False, index=True) eb_item = pw.BlobField() @@ -39,11 +37,9 @@ class ItemEntity(db.BaseModel): deleted = pw.BooleanField(null=False, default=False) class Meta: - indexes = ( - (('collection', 'uid'), True), - ) + indexes = ((("collection", "uid"), True),) class HrefMapper(db.BaseModel): - content = pw.ForeignKeyField(ItemEntity, primary_key=True, backref='href', on_delete='CASCADE') + content = pw.ForeignKeyField(ItemEntity, primary_key=True, backref="href", on_delete="CASCADE") href = pw.CharField(null=False, index=True) diff --git a/etesync_dav/mac_helpers.py b/etesync_dav/mac_helpers.py index 5d8addd0..3d2aac38 100644 --- a/etesync_dav/mac_helpers.py +++ b/etesync_dav/mac_helpers.py @@ -18,20 +18,19 @@ from subprocess import check_call from cryptography import x509 -from cryptography.x509.oid import NameOID -from cryptography.hazmat.primitives import hashes from cryptography.hazmat.backends import default_backend -from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives import hashes, serialization from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.x509.oid import NameOID -from etesync_dav.config import SSL_KEY_FILE, SSL_CERT_FILE +from etesync_dav.config import SSL_CERT_FILE, SSL_KEY_FILE -KEY_CIPHER = 'rsa' +KEY_CIPHER = "rsa" KEY_SIZE = 4096 KEY_DAYS = 3650 # That's 10 years. -ON_MAC = sys.platform == 'darwin' -ON_WINDOWS = sys.platform in ['win32', 'cygwin'] +ON_MAC = sys.platform == "darwin" +ON_WINDOWS = sys.platform in ["win32", "cygwin"] class Error(Exception): @@ -43,17 +42,17 @@ def has_ssl(): def needs_ssl(): - return (ON_MAC or ON_WINDOWS) and \ - not has_ssl() + return (ON_MAC or ON_WINDOWS) and not has_ssl() -def generate_cert(cert_path: str = SSL_CERT_FILE, key_path: str = SSL_KEY_FILE, - key_size: int = KEY_SIZE, key_days: int = KEY_DAYS): +def generate_cert( + cert_path: str = SSL_CERT_FILE, key_path: str = SSL_KEY_FILE, key_size: int = KEY_SIZE, key_days: int = KEY_DAYS +): if os.path.exists(key_path): - print('Skipping key generation as already exists.') + print("Skipping key generation as already exists.") return - hostname = 'localhost' + hostname = "localhost" key = rsa.generate_private_key( public_exponent=65537, @@ -61,9 +60,7 @@ def generate_cert(cert_path: str = SSL_CERT_FILE, key_path: str = SSL_KEY_FILE, backend=default_backend(), ) - name = x509.Name([ - x509.NameAttribute(NameOID.COMMON_NAME, hostname) - ]) + name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, hostname)]) # best practice seem to be to include the hostname in the SAN, which *SHOULD* mean COMMON_NAME is ignored. alt_names = [x509.DNSName(hostname)] @@ -92,31 +89,31 @@ def generate_cert(cert_path: str = SSL_CERT_FILE, key_path: str = SSL_KEY_FILE, encryption_algorithm=serialization.NoEncryption(), ) - with open(key_path, 'wb') as f: + with open(key_path, "wb") as f: f.write(key_pem) - with open(cert_path, 'wb') as f: + with open(cert_path, "wb") as f: f.write(cert_pem) def macos_trust_cert(cert_path: str = SSL_CERT_FILE): if not ON_MAC: - raise Error('this is not macOS.') - check_call(['security', 'import', cert_path]) - check_call(['security', 'add-trusted-cert', '-p', 'ssl', cert_path]) + raise Error("this is not macOS.") + check_call(["security", "import", cert_path]) + check_call(["security", "add-trusted-cert", "-p", "ssl", cert_path]) def windows_trust_cert(cert_path: str = SSL_CERT_FILE): """Import given certificate into a certificate store.""" if not ON_WINDOWS: - raise Error('this is not Windows.') + raise Error("this is not Windows.") check_call( [ - 'powershell.exe', - 'Import-Certificate', - '-FilePath', + "powershell.exe", + "Import-Certificate", + "-FilePath", '"{}"'.format(cert_path), - '-CertStoreLocation', - r'Cert:\CurrentUser\Root', + "-CertStoreLocation", + r"Cert:\CurrentUser\Root", ] ) @@ -127,4 +124,4 @@ def trust_cert(cert_path: str = SSL_CERT_FILE): elif ON_MAC: macos_trust_cert(cert_path) else: - raise Error('Only supported on windows/macOS') + raise Error("Only supported on windows/macOS") diff --git a/etesync_dav/manage.py b/etesync_dav/manage.py index d3a6dd83..d127f546 100644 --- a/etesync_dav/manage.py +++ b/etesync_dav/manage.py @@ -12,18 +12,20 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . +import hashlib import os import random import string import time -import hashlib -import etesync as api import etebase as Etebase +import etesync as api + +from etesync_dav.config import CREDS_FILE, DATA_DIR, ETESYNC_URL, HTPASSWD_FILE, LEGACY_CONFIG_DIR, LEGACY_ETESYNC_URL + +from . import local_cache from .radicale.creds import Credentials from .radicale.etesync_cache import etesync_for_user -from . import local_cache -from etesync_dav.config import CREDS_FILE, HTPASSWD_FILE, LEGACY_ETESYNC_URL, ETESYNC_URL, DATA_DIR, LEGACY_CONFIG_DIR class Htpasswd: @@ -34,7 +36,7 @@ def __init__(self, filename): def load(self): if os.path.exists(self.filename): with open(self.filename, "r") as f: - self.content = dict(map(lambda x: x.strip(), line.split(':', 1)) for line in f) + self.content = dict(map(lambda x: x.strip(), line.split(":", 1)) for line in f) else: self.content = {} @@ -58,13 +60,12 @@ def list(self): class Manager: - def __init__(self, - config_dir=DATA_DIR, htpasswd_file=HTPASSWD_FILE, creds_file=CREDS_FILE): - + def __init__(self, config_dir=DATA_DIR, htpasswd_file=HTPASSWD_FILE, creds_file=CREDS_FILE): if not os.path.exists(config_dir): # If the old dir still exists and the new one doesn't, mv the location if os.path.exists(LEGACY_CONFIG_DIR): import shutil + shutil.move(LEGACY_CONFIG_DIR, DATA_DIR) else: os.makedirs(config_dir, mode=0o700) @@ -77,13 +78,14 @@ def __init__(self, self.htpasswd.save() def _generate_pasword(self): - return ''.join( - [random.choice(string.ascii_uppercase + string.ascii_lowercase + string.digits) for i in range(16)]) + return "".join( + [random.choice(string.ascii_uppercase + string.ascii_lowercase + string.digits) for i in range(16)] + ) def validate_username(self, username): if username is None: raise RuntimeError("Username is required") - if ':' in username: + if ":" in username: raise RuntimeError("Username can't include a colon.") return self.htpasswd.get(username) is not None @@ -112,7 +114,7 @@ def add(self, username, login_password, encryption_password, remote_url=LEGACY_E auth_token = api.Authenticator(remote_url).get_auth_token(username, login_password) print("Deriving password") - etesync = api.EteSync(username, auth_token, remote=remote_url, db_path=':memory:') + etesync = api.EteSync(username, auth_token, remote=remote_url, db_path=":memory:") cipher_key = etesync.derive_key(encryption_password) print("Saving config") @@ -208,7 +210,7 @@ def delete(self, username): try: with etesync_for_user(username) as (etesync, _): - if hasattr(etesync, 'clear_user'): + if hasattr(etesync, "clear_user"): etesync.clear_user() else: # Legacy etesync, do manually: diff --git a/etesync_dav/radicale/__init__.py b/etesync_dav/radicale/__init__.py index 88ae0af3..0d0c420d 100644 --- a/etesync_dav/radicale/__init__.py +++ b/etesync_dav/radicale/__init__.py @@ -13,4 +13,4 @@ # along with this program. If not, see . # import these here so pyinstaller finds them -from . import web, storage, rights # noqa: F401 +from . import rights, storage, web # noqa: F401 diff --git a/etesync_dav/radicale/creds.py b/etesync_dav/radicale/creds.py index 8cc44d2b..7ab92bd2 100644 --- a/etesync_dav/radicale/creds.py +++ b/etesync_dav/radicale/creds.py @@ -23,7 +23,7 @@ class Credentials: def __init__(self, filename): self.filename = filename self.last_mtime = 0 - self.content = {'users': {}} + self.content = {"users": {}} self.load() def load(self): @@ -39,46 +39,39 @@ def save(self): json.dump(self.content, f) def get_server_url(self, username): - users = self.content['users'] + users = self.content["users"] if username not in users: return None user = users[username] - return user.get('serverUrl', LEGACY_ETESYNC_URL) + return user.get("serverUrl", LEGACY_ETESYNC_URL) def get(self, username): - users = self.content['users'] + users = self.content["users"] if username not in users: return None, None user = users[username] - return user['authToken'], base64.b64decode(user['cipherKey']) + return user["authToken"], base64.b64decode(user["cipherKey"]) def set(self, username, auth_token, cipher_key, server_url): - users = self.content['users'] - user = { - 'authToken': auth_token, - 'cipherKey': base64.b64encode(cipher_key).decode(), - 'serverUrl': server_url - } + users = self.content["users"] + user = {"authToken": auth_token, "cipherKey": base64.b64encode(cipher_key).decode(), "serverUrl": server_url} users[username] = user def get_etebase(self, username): - users = self.content['users'] + users = self.content["users"] if username not in users: return None user = users[username] - return user.get('storedSession', None) + return user.get("storedSession", None) def set_etebase(self, username, stored_session, server_url): - users = self.content['users'] - user = { - 'storedSession': stored_session, - 'serverUrl': server_url - } + users = self.content["users"] + user = {"storedSession": stored_session, "serverUrl": server_url} users[username] = user def delete(self, username): - users = self.content['users'] + users = self.content["users"] users.pop(username, None) diff --git a/etesync_dav/radicale/etesync_cache.py b/etesync_dav/radicale/etesync_cache.py index f1907328..bf10c641 100644 --- a/etesync_dav/radicale/etesync_cache.py +++ b/etesync_dav/radicale/etesync_cache.py @@ -12,17 +12,17 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -from .creds import Credentials -import threading import os +import threading from contextlib import contextmanager import etesync as api -from etesync_dav import config -from .href_mapper import HrefMapper +from etesync_dav import config from ..local_cache import Etebase +from .creds import Credentials +from .href_mapper import HrefMapper class EteSync(api.EteSync): @@ -47,8 +47,9 @@ def etesync_for_user(self, user): etesync = self._etesync_cache[user] if isinstance(etesync, Etebase) and (etesync.stored_session == self.creds.get_etebase(user)): return etesync, False - elif isinstance(etesync, EteSync) and \ - ((etesync.auth_token, etesync.cipher_key) == self.creds.get(user)): + elif isinstance(etesync, EteSync) and ( + (etesync.auth_token, etesync.cipher_key) == self.creds.get(user) + ): return etesync, False else: del self._etesync_cache[user] @@ -62,7 +63,7 @@ def etesync_for_user(self, user): else: auth_token, cipher_key = self.creds.get(user) - db_name_unique = 'generic' + db_name_unique = "generic" db_path = self.db_path.format(db_name_unique) diff --git a/etesync_dav/radicale/href_mapper.py b/etesync_dav/radicale/href_mapper.py index cebb9989..42b3e638 100644 --- a/etesync_dav/radicale/href_mapper.py +++ b/etesync_dav/radicale/href_mapper.py @@ -12,11 +12,10 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -import peewee as pw - import etesync as api +import peewee as pw class HrefMapper(api.db.BaseModel): - content = pw.ForeignKeyField(api.pim.Content, primary_key=True, backref='href', on_delete='CASCADE') + content = pw.ForeignKeyField(api.pim.Content, primary_key=True, backref="href", on_delete="CASCADE") href = pw.CharField(null=False, index=True) diff --git a/etesync_dav/radicale/rights.py b/etesync_dav/radicale/rights.py index 22a3fcca..278e7fde 100644 --- a/etesync_dav/radicale/rights.py +++ b/etesync_dav/radicale/rights.py @@ -12,12 +12,11 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . +import etesync as api from radicale import pathutils, rights from .etesync_cache import etesync_for_user -import etesync as api - class Rights(rights.BaseRights): def authorization(self, user, path): @@ -28,7 +27,7 @@ def authorization(self, user, path): if not sane_path: return "R" - attributes = sane_path.split('/') + attributes = sane_path.split("/") if user != attributes[0]: return "" @@ -42,8 +41,8 @@ def authorization(self, user, path): try: journal = etesync.get(journal_uid) except api.exceptions.DoesNotExist: - return '' + return "" - return 'rw' if not journal.read_only else 'r' + return "rw" if not journal.read_only else "r" return "" diff --git a/etesync_dav/radicale/storage.py b/etesync_dav/radicale/storage.py index aa376ba5..4eff0b38 100644 --- a/etesync_dav/radicale/storage.py +++ b/etesync_dav/radicale/storage.py @@ -12,30 +12,30 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . +import hashlib import logging +import posixpath import re -from contextlib import contextmanager import threading -import hashlib -import posixpath import time - -from .etesync_cache import etesync_for_user -from .href_mapper import HrefMapper +from contextlib import contextmanager import etesync as api +import vobject from radicale import pathutils from radicale.item import Item, get_etag from radicale.storage import ( - BaseCollection, BaseStorage, ComponentNotFoundError, - ) -import vobject + BaseCollection, + BaseStorage, + ComponentNotFoundError, +) -from ..local_cache import Etebase, COL_TYPES +from ..local_cache import COL_TYPES, Etebase +from .etesync_cache import etesync_for_user +from .href_mapper import HrefMapper from .storage_etebase_collection import Collection as EtebaseCollection - -logger = logging.getLogger('etesync-dav') +logger = logging.getLogger("etesync-dav") # How often we should sync automatically, in seconds @@ -96,8 +96,8 @@ def run(self): class MetaMapping: # Mappings between etesync meta and radicale _mappings = { - "D:displayname": ("displayName", None, None), - } + "D:displayname": ("displayName", None, None), + } @classmethod def _reverse_mapping(cls, mappings): @@ -112,8 +112,8 @@ def map_get(self, info, key): if get_transform is not None: value = get_transform(value) - if key == 'C:supported-calendar-component-set': - return key, getattr(self, 'supported_calendar_component', 'none') + if key == "C:supported-calendar-component-set": + return key, getattr(self, "supported_calendar_component", "none") return key, value @@ -151,35 +151,39 @@ def IntToRgb(color): red = (color >> 16) & 0xFF alpha = (color >> 24) & 0xFF - return '#%02x%02x%02x%02x' % (red, green, blue, alpha or 0xFF) + return "#%02x%02x%02x%02x" % (red, green, blue, alpha or 0xFF) class MetaMappingCalendar(MetaMapping): - supported_calendar_component = 'VEVENT' + supported_calendar_component = "VEVENT" _mappings = MetaMapping._mappings.copy() - _mappings.update({ + _mappings.update( + { "C:calendar-description": ("description", None, None), "ICAL:calendar-color": ("color", IntToRgb, RgbToInt), - }) + } + ) MetaMapping._reverse_mapping(_mappings) class MetaMappingTaskList(MetaMappingCalendar): - supported_calendar_component = 'VTODO' + supported_calendar_component = "VTODO" class MetaMappingContacts(MetaMapping): _mappings = MetaMapping._mappings.copy() - _mappings.update({ + _mappings.update( + { "CR:addressbook-description": ("description", None, None), - }) + } + ) MetaMapping._reverse_mapping(_mappings) def _trim_suffix(path, suffixes): for suffix in suffixes: if path.endswith(suffix): - path = path[:-len(suffix)] + path = path[: -len(suffix)] break return path @@ -204,8 +208,8 @@ def _get_attributes_from_path(path): return attributes -VCARD_4_TO_3_PHOTO_URI_REGEX = re.compile(r'^(PHOTO|LOGO):http', re.MULTILINE) -VCARD_4_TO_3_PHOTO_INLINE_REGEX = re.compile(r'^(PHOTO|LOGO):data:image/([^;]*);base64,', re.MULTILINE) +VCARD_4_TO_3_PHOTO_URI_REGEX = re.compile(r"^(PHOTO|LOGO):http", re.MULTILINE) +VCARD_4_TO_3_PHOTO_INLINE_REGEX = re.compile(r"^(PHOTO|LOGO):data:image/([^;]*);base64,", re.MULTILINE) class EteSyncItem(Item): @@ -239,7 +243,7 @@ def __init__(self, *args, **kwargs): See ``find_tag_and_time_range``. """ - self.etesync_item = kwargs.pop('etesync_item') + self.etesync_item = kwargs.pop("etesync_item") super().__init__(*args, **kwargs) @property @@ -263,15 +267,15 @@ def __init__(self, storage_, path): self.collection = self.journal.collection if isinstance(self.collection, api.Calendar): self.meta_mappings = MetaMappingCalendar() - self.set_meta({'tag': 'VCALENDAR'}) + self.set_meta({"tag": "VCALENDAR"}) self.content_suffix = ".ics" elif isinstance(self.collection, api.TaskList): self.meta_mappings = MetaMappingTaskList() - self.set_meta({'tag': 'VCALENDAR'}) + self.set_meta({"tag": "VCALENDAR"}) self.content_suffix = ".ics" elif isinstance(self.collection, api.AddressBook): self.meta_mappings = MetaMappingContacts() - self.set_meta({'tag': 'VADDRESSBOOK'}) + self.set_meta({"tag": "VADDRESSBOOK"}) self.content_suffix = ".vcf" else: @@ -307,10 +311,10 @@ def sync(self, old_token=None): delta update. If sync token is missing, all items are returned. ValueError is raised for invalid or old tokens. """ - token_prefix = 'http://radicale.org/ns/sync/' + token_prefix = "http://radicale.org/ns/sync/" token = None # XXX "{}{}".format(token_prefix, self.etag.strip('"')) if old_token is not None and old_token.startswith(token_prefix): - old_token = old_token[len(token_prefix):] + old_token = old_token[len(token_prefix) :] # FIXME: actually implement filtering by token return token, self._list() @@ -372,32 +376,32 @@ def _get(self, href): try: item = vobject.readOne(etesync_item.content) # XXX Hack: fake transform 4.0 vCards to 3.0 as 4.0 is not yet widely supported - if item.name == 'VCARD' and item.contents['version'][0].value == '4.0': + if item.name == "VCARD" and item.contents["version"][0].value == "4.0": # Don't do anything for groups as transforming them won't help anyway. - if hasattr(item, 'kind') and item.kind.value.lower() == 'group': + if hasattr(item, "kind") and item.kind.value.lower() == "group": pass else: # XXX must be first because we are editing the content and reparsing - if 'photo' in item.contents: + if "photo" in item.contents: content = etesync_item.content - content = VCARD_4_TO_3_PHOTO_URI_REGEX.sub(r'\1;VALUE=uri:', content) - content = VCARD_4_TO_3_PHOTO_INLINE_REGEX.sub(r'\1;ENCODING=b;TYPE=\2:', content) + content = VCARD_4_TO_3_PHOTO_URI_REGEX.sub(r"\1;VALUE=uri:", content) + content = VCARD_4_TO_3_PHOTO_INLINE_REGEX.sub(r"\1;ENCODING=b;TYPE=\2:", content) item = vobject.readOne(content) if content == etesync_item.content: # Delete the PHOTO if we haven't managed to convert it - del item.contents['photo'] + del item.contents["photo"] - item.contents['version'][0].value = '3.0' + item.contents["version"][0].value = "3.0" # XXX Hack: add missing FN - if item.name == 'VCARD' and not hasattr(item, 'fn'): - item.add('fn').value = str(item.n) + if item.name == "VCARD" and not hasattr(item, "fn"): + item.add("fn").value = str(item.n) except Exception as e: - raise RuntimeError("Failed to parse item %r in %r" % - (href, self.path)) from e - last_modified = '' + raise RuntimeError("Failed to parse item %r in %r" % (href, self.path)) from e + last_modified = "" - return EteSyncItem(collection=self, vobject_item=item, href=href, last_modified=last_modified, - etesync_item=etesync_item) + return EteSyncItem( + collection=self, vobject_item=item, href=href, last_modified=last_modified, etesync_item=etesync_item + ) def upload(self, href, vobject_item): """Upload a new or replace an existing item.""" @@ -481,7 +485,7 @@ def set_meta(self, _props): @property def last_modified(self): """Get the HTTP-datetime of when the collection was modified.""" - return ' ' + return " " class Storage(BaseStorage): @@ -520,13 +524,13 @@ def discover(self, path, depth="0"): # Path should already be sanitized attributes = _get_attributes_from_path(path) if len(attributes) == 3: - if path.endswith('/'): + if path.endswith("/"): # XXX Workaround UIDs with slashes in them - just continue as if path was one step above path = posixpath.join("/", attributes[0], attributes[1], "") attributes = _get_attributes_from_path(path) else: # XXX We would rather not rewrite urls, but we do it if urls contain / - attributes[-1] = attributes[-1].replace('/', ',') + attributes[-1] = attributes[-1].replace("/", ",") path = posixpath.join("/", *attributes) try: @@ -615,7 +619,7 @@ def acquire_lock(self, mode, user=None): with etesync_for_user(user) as (etesync, _): with self.__class__._sync_thread_lock: - if not hasattr(etesync, 'sync_thread'): + if not hasattr(etesync, "sync_thread"): etesync.sync_thread = SyncThread(user, daemon=True) etesync.sync_thread.start() else: diff --git a/etesync_dav/radicale/storage_etebase_collection.py b/etesync_dav/radicale/storage_etebase_collection.py index a6ad8ec5..1ccc6dd4 100644 --- a/etesync_dav/radicale/storage_etebase_collection.py +++ b/etesync_dav/radicale/storage_etebase_collection.py @@ -1,11 +1,12 @@ import re +import vobject from radicale import pathutils from radicale.item import Item from radicale.storage import ( - BaseCollection, ComponentNotFoundError, - ) -import vobject + BaseCollection, + ComponentNotFoundError, +) from ..local_cache.models import HrefMapper @@ -13,8 +14,8 @@ class MetaMapping: # Mappings between etesync meta and radicale _mappings = { - "D:displayname": ("name", None, None), - } + "D:displayname": ("name", None, None), + } @classmethod def _reverse_mapping(cls, mappings): @@ -29,8 +30,8 @@ def map_get(self, info, key): if get_transform is not None: value = get_transform(value) - if key == 'C:supported-calendar-component-set': - return key, getattr(self, 'supported_calendar_component', 'none') + if key == "C:supported-calendar-component-set": + return key, getattr(self, "supported_calendar_component", "none") return key, value @@ -43,31 +44,35 @@ def map_set(self, key, value): class MetaMappingCalendar(MetaMapping): - supported_calendar_component = 'VEVENT' + supported_calendar_component = "VEVENT" _mappings = MetaMapping._mappings.copy() - _mappings.update({ + _mappings.update( + { "C:calendar-description": ("description", None, None), "ICAL:calendar-color": ("color", None, None), - }) + } + ) MetaMapping._reverse_mapping(_mappings) class MetaMappingTaskList(MetaMappingCalendar): - supported_calendar_component = 'VTODO' + supported_calendar_component = "VTODO" class MetaMappingContacts(MetaMapping): _mappings = MetaMapping._mappings.copy() - _mappings.update({ + _mappings.update( + { "CR:addressbook-description": ("description", None, None), - }) + } + ) MetaMapping._reverse_mapping(_mappings) def _trim_suffix(path, suffixes): for suffix in suffixes: if path.endswith(suffix): - path = path[:-len(suffix)] + path = path[: -len(suffix)] break return path @@ -82,8 +87,8 @@ def _get_attributes_from_path(path): return attributes -VCARD_4_TO_3_PHOTO_URI_REGEX = re.compile(r'^(PHOTO|LOGO):http', re.MULTILINE) -VCARD_4_TO_3_PHOTO_INLINE_REGEX = re.compile(r'^(PHOTO|LOGO):data:image/([^;]*);base64,', re.MULTILINE) +VCARD_4_TO_3_PHOTO_URI_REGEX = re.compile(r"^(PHOTO|LOGO):http", re.MULTILINE) +VCARD_4_TO_3_PHOTO_INLINE_REGEX = re.compile(r"^(PHOTO|LOGO):data:image/([^;]*);base64,", re.MULTILINE) class EteSyncItem(Item): @@ -117,7 +122,7 @@ def __init__(self, *args, **kwargs): See ``find_tag_and_time_range``. """ - self.etesync_item = kwargs.pop('etesync_item') + self.etesync_item = kwargs.pop("etesync_item") super().__init__(*args, **kwargs) @property @@ -141,15 +146,15 @@ def __init__(self, storage_, path): col_type = self.collection.col_type if col_type == "etebase.vevent": self.meta_mappings = MetaMappingCalendar() - self.set_meta({'tag': 'VCALENDAR'}) + self.set_meta({"tag": "VCALENDAR"}) self.content_suffix = ".ics" elif col_type == "etebase.vtodo": self.meta_mappings = MetaMappingTaskList() - self.set_meta({'tag': 'VCALENDAR'}) + self.set_meta({"tag": "VCALENDAR"}) self.content_suffix = ".ics" elif col_type == "etebase.vcard": self.meta_mappings = MetaMappingContacts() - self.set_meta({'tag': 'VADDRESSBOOK'}) + self.set_meta({"tag": "VADDRESSBOOK"}) self.content_suffix = ".vcf" else: @@ -181,10 +186,10 @@ def sync(self, old_token=None): delta update. If sync token is missing, all items are returned. ValueError is raised for invalid or old tokens. """ - token_prefix = 'http://radicale.org/ns/sync/' + token_prefix = "http://radicale.org/ns/sync/" token = None # XXX "{}{}".format(token_prefix, self.etag.strip('"')) if old_token is not None and old_token.startswith(token_prefix): - old_token = old_token[len(token_prefix):] + old_token = old_token[len(token_prefix) :] # FIXME: actually implement filtering by token return token, self._list() @@ -249,29 +254,29 @@ def _get(self, href): try: item = vobject.readOne(etesync_item.content) # XXX Hack: fake transform 4.0 vCards to 3.0 as 4.0 is not yet widely supported - if item.name == 'VCARD' and item.contents['version'][0].value == '4.0': + if item.name == "VCARD" and item.contents["version"][0].value == "4.0": # Don't do anything for groups as transforming them won't help anyway. - if hasattr(item, 'kind') and item.kind.value.lower() == 'group': + if hasattr(item, "kind") and item.kind.value.lower() == "group": pass else: # XXX must be first because we are editing the content and reparsing - if 'photo' in item.contents: + if "photo" in item.contents: content = etesync_item.content - content = VCARD_4_TO_3_PHOTO_URI_REGEX.sub(r'\1;VALUE=uri:', content) - content = VCARD_4_TO_3_PHOTO_INLINE_REGEX.sub(r'\1;ENCODING=b;TYPE=\2:', content) + content = VCARD_4_TO_3_PHOTO_URI_REGEX.sub(r"\1;VALUE=uri:", content) + content = VCARD_4_TO_3_PHOTO_INLINE_REGEX.sub(r"\1;ENCODING=b;TYPE=\2:", content) item = vobject.readOne(content) if content == etesync_item.content: # Delete the PHOTO if we haven't managed to convert it - del item.contents['photo'] + del item.contents["photo"] - item.contents['version'][0].value = '3.0' + item.contents["version"][0].value = "3.0" except Exception as e: - raise RuntimeError("Failed to parse item %r in %r" % - (href, self.path)) from e - last_modified = '' + raise RuntimeError("Failed to parse item %r in %r" % (href, self.path)) from e + last_modified = "" - return EteSyncItem(collection=self, vobject_item=item, href=href, last_modified=last_modified, - etesync_item=etesync_item) + return EteSyncItem( + collection=self, vobject_item=item, href=href, last_modified=last_modified, etesync_item=etesync_item + ) def upload(self, href, vobject_item): """Upload a new or replace an existing item.""" @@ -353,4 +358,4 @@ def set_meta(self, _props): @property def last_modified(self): """Get the HTTP-datetime of when the collection was modified.""" - return ' ' + return " " diff --git a/etesync_dav/radicale/web.py b/etesync_dav/radicale/web.py index dd6fb534..6c444f95 100644 --- a/etesync_dav/radicale/web.py +++ b/etesync_dav/radicale/web.py @@ -12,8 +12,8 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -from contextlib import ExitStack import importlib.resources +from contextlib import ExitStack from radicale import web @@ -24,14 +24,15 @@ class Web(web.BaseWeb): def __init__(self, configuration): super().__init__(configuration) self._file_manager = ExitStack() - ref = importlib.resources.files(__name__) / 'web' + ref = importlib.resources.files(__name__) / "web" self.folder = self._file_manager.enter_context(importlib.resources.as_file(ref)) - def __del__ (self): + def __del__(self): self._file_manager.close() def _call(self, environ, base_prefix, path, user): from etesync_dav.webui import app + ret_response = [] def start_response(status, headers): @@ -39,7 +40,7 @@ def start_response(status, headers): ret_response.append(dict(headers)) if has_ssl(): - environ['wsgi.url_scheme'] = 'https' + environ["wsgi.url_scheme"] = "https" body = list(app(environ, start_response))[0] ret_response.append(body) return tuple(ret_response) diff --git a/etesync_dav/radicale_main/__init__.py b/etesync_dav/radicale_main/__init__.py index 3f9d9540..0bc20091 100644 --- a/etesync_dav/radicale_main/__init__.py +++ b/etesync_dav/radicale_main/__init__.py @@ -30,11 +30,11 @@ import socket import sys -from . import server - from radicale import VERSION, config, log, storage from radicale.log import logger +from . import server + def run(passed_args=None): """Run Radicale as a standalone server.""" @@ -44,16 +44,13 @@ def run(passed_args=None): parser = argparse.ArgumentParser(usage="radicale [OPTIONS]") parser.add_argument("--version", action="version", version=VERSION) - parser.add_argument("--verify-storage", action="store_true", - help="check the storage for errors and exit") - parser.add_argument( - "-C", "--config", help="use specific configuration files", nargs="*") - parser.add_argument("-D", "--debug", action="store_true", - help="print debug information") + parser.add_argument("--verify-storage", action="store_true", help="check the storage for errors and exit") + parser.add_argument("-C", "--config", help="use specific configuration files", nargs="*") + parser.add_argument("-D", "--debug", action="store_true", help="print debug information") groups = {} - version_major, version_minor, _ = VERSION.split('.') + version_major, version_minor, _ = VERSION.split(".") for section, values in config.DEFAULT_CONFIG_SCHEMA.items(): if section.startswith("_"): @@ -80,10 +77,8 @@ def run(passed_args=None): opposite_args.append("--no%s" % long_name[1:]) group.add_argument(*args, nargs="?", const="True", **kwargs) # Opposite argument - kwargs["help"] = "do not %s (opposite of %s)" % ( - kwargs["help"], long_name) - group.add_argument(*opposite_args, action="store_const", - const="False", **kwargs) + kwargs["help"] = "do not %s (opposite of %s)" % (kwargs["help"], long_name) + group.add_argument(*opposite_args, action="store_const", const="False", **kwargs) else: del kwargs["type"] kwargs["action"] = "store_const" @@ -93,8 +88,7 @@ def run(passed_args=None): group.add_argument(*args, **kwargs) kwargs["const"] = "False" - kwargs["help"] = "do not %s (opposite of %s)" % ( - kwargs["help"], long_name) + kwargs["help"] = "do not %s (opposite of %s)" % (kwargs["help"], long_name) group.add_argument(*opposite_args, **kwargs) else: del kwargs["type"] @@ -106,8 +100,7 @@ def run(passed_args=None): if args.debug: args.logging_level = "debug" with contextlib.suppress(ValueError): - log.set_level(config.DEFAULT_CONFIG_SCHEMA["logging"]["level"]["type"]( - args.logging_level), True) + log.set_level(config.DEFAULT_CONFIG_SCHEMA["logging"]["level"]["type"](args.logging_level), True) # Update Radicale configuration according to arguments arguments_config = {} @@ -117,15 +110,18 @@ def run(passed_args=None): for action in actions: value = getattr(args, action) if value is not None: - section_config[action.split('_', 1)[1]] = value + section_config[action.split("_", 1)[1]] = value if section_config: arguments_config[section] = section_config try: - configuration = config.load(config.parse_compound_paths( - config.DEFAULT_CONFIG_PATH, - os.environ.get("RADICALE_CONFIG"), - os.pathsep.join(args.config) if args.config else None)) + configuration = config.load( + config.parse_compound_paths( + config.DEFAULT_CONFIG_PATH, + os.environ.get("RADICALE_CONFIG"), + os.pathsep.join(args.config) if args.config else None, + ) + ) if arguments_config: configuration.update(arguments_config, "arguments") except Exception as e: @@ -148,8 +144,7 @@ def run(passed_args=None): logger.fatal("Storage verifcation failed") sys.exit(1) except Exception as e: - logger.fatal("An exception occurred during storage verification: " - "%s", e, exc_info=True) + logger.fatal("An exception occurred during storage verification: " "%s", e, exc_info=True) sys.exit(1) return @@ -159,14 +154,14 @@ def run(passed_args=None): # SIGTERM and SIGINT (aka KeyboardInterrupt) shutdown the server def shutdown(signal_number, stack_frame): shutdown_socket.close() + signal.signal(signal.SIGTERM, shutdown) signal.signal(signal.SIGINT, shutdown) try: server.serve(configuration, shutdown_socket_out) except Exception as e: - logger.fatal("An exception occurred during server startup: %s", e, - exc_info=True) + logger.fatal("An exception occurred during server startup: %s", e, exc_info=True) sys.exit(1) diff --git a/etesync_dav/radicale_main/server.py b/etesync_dav/radicale_main/server.py index 0106b2ed..862cb20d 100644 --- a/etesync_dav/radicale_main/server.py +++ b/etesync_dav/radicale_main/server.py @@ -66,9 +66,7 @@ def format_address(address): return "[%s]:%d" % address[:2] -class ParallelHTTPServer(socketserver.ThreadingMixIn, - wsgiref.simple_server.WSGIServer): - +class ParallelHTTPServer(socketserver.ThreadingMixIn, wsgiref.simple_server.WSGIServer): # We wait for child threads ourself block_on_close = False @@ -109,12 +107,10 @@ def handle_error(self, request, client_address): if issubclass(sys.exc_info()[0], socket.timeout): logger.info("client timed out", exc_info=True) else: - logger.error("An exception occurred during request: %s", - sys.exc_info()[1], exc_info=True) + logger.error("An exception occurred during request: %s", sys.exc_info()[1], exc_info=True) class ParallelHTTPSServer(ParallelHTTPServer): - def server_bind(self): super().server_bind() # Wrap the TCP socket in an SSL socket @@ -122,10 +118,8 @@ def server_bind(self): keyfile = self.configuration.get("server", "key") cafile = self.configuration.get("server", "certificate_authority") # Test if the files can be read - for name, filename in [("certificate", certfile), ("key", keyfile), - ("certificate_authority", cafile)]: - type_name = config.DEFAULT_CONFIG_SCHEMA["server"][name][ - "type"].__name__ + for name, filename in [("certificate", certfile), ("key", keyfile), ("certificate_authority", cafile)]: + type_name = config.DEFAULT_CONFIG_SCHEMA["server"][name]["type"].__name__ source = self.configuration.get_source("server", name) if name == "certificate_authority" and not filename: continue @@ -134,15 +128,14 @@ def server_bind(self): except OSError as e: raise RuntimeError( "Invalid %s value for option %r in section %r in %s: %r " - "(%s)" % (type_name, name, "server", source, filename, - e)) from e + "(%s)" % (type_name, name, "server", source, filename, e) + ) from e context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH) context.load_cert_chain(certfile=certfile, keyfile=keyfile) if cafile: context.load_verify_locations(cafile=cafile) context.verify_mode = ssl.CERT_REQUIRED - self.socket = context.wrap_socket( - self.socket, server_side=True, do_handshake_on_connect=False) + self.socket = context.wrap_socket(self.socket, server_side=True, do_handshake_on_connect=False) def finish_request_locked(self, request, client_address): try: @@ -162,13 +155,11 @@ def finish_request_locked(self, request, client_address): class ServerHandler(wsgiref.simple_server.ServerHandler): - # Don't pollute WSGI environ with OS environment os_environ = {} def log_exception(self, exc_info): - logger.error("An exception occurred during request: %s", - exc_info[1], exc_info=exc_info) + logger.error("An exception occurred during request: %s", exc_info[1], exc_info=exc_info) class RequestHandler(wsgiref.simple_server.WSGIRequestHandler): @@ -203,9 +194,7 @@ def handle(self): if not self.parse_request(): return - handler = ServerHandler( - self.rfile, self.wfile, self.get_stderr(), self.get_environ() - ) + handler = ServerHandler(self.rfile, self.wfile, self.get_stderr(), self.get_environ()) handler.request_handler = self handler.run(self.server.get_app()) @@ -215,8 +204,7 @@ def serve(configuration, shutdown_socket): logger.info("Starting Radicale") # Copy configuration before modifying configuration = configuration.copy() - configuration.update({"server": {"_internal_server": "True"}}, "server", - privileged=True) + configuration.update({"server": {"_internal_server": "True"}}, "server", privileged=True) use_ssl = configuration.get("server", "ssl") server_class = ParallelHTTPSServer if use_ssl else ParallelHTTPServer @@ -230,36 +218,39 @@ def serve(configuration, shutdown_socket): for i, family in enumerate(possible_families): is_last = i == len(possible_families) - 1 try: - server = server_class(configuration, family, address, - RequestHandler) + server = server_class(configuration, family, address, RequestHandler) except OSError as e: # Ignore unsupported families (only one must work) - if ((bind_ok or not is_last) and ( - isinstance(e, socket.gaierror) and ( - # Hostname does not exist or doesn't have - # address for address family - # macOS: IPv6 address for INET address family - e.errno == socket.EAI_NONAME or - # Address not for address family - e.errno == COMPAT_EAI_ADDRFAMILY or - e.errno == COMPAT_EAI_NODATA) or - # Workaround for PyPy - str(e) == "address family mismatched" or - # Address family not available (e.g. IPv6 disabled) - # macOS: IPv4 address for INET6 address family with - # IPV6_V6ONLY set - e.errno == errno.EADDRNOTAVAIL or - # Address family not supported - e.errno == errno.EAFNOSUPPORT)): + if (bind_ok or not is_last) and ( + isinstance(e, socket.gaierror) + and ( + # Hostname does not exist or doesn't have + # address for address family + # macOS: IPv6 address for INET address family + e.errno == socket.EAI_NONAME + or + # Address not for address family + e.errno == COMPAT_EAI_ADDRFAMILY + or e.errno == COMPAT_EAI_NODATA + ) + or + # Workaround for PyPy + str(e) == "address family mismatched" + or + # Address family not available (e.g. IPv6 disabled) + # macOS: IPv4 address for INET6 address family with + # IPV6_V6ONLY set + e.errno == errno.EADDRNOTAVAIL + or + # Address family not supported + e.errno == errno.EAFNOSUPPORT + ): continue - raise RuntimeError("Failed to start server %r: %s" % ( - format_address(address), e)) from e + raise RuntimeError("Failed to start server %r: %s" % (format_address(address), e)) from e servers[server.socket] = server bind_ok = True server.set_app(application) - logger.info("Listening on %r%s", - format_address(server.server_address), - " with SSL" if use_ssl else "") + logger.info("Listening on %r%s", format_address(server.server_address), " with SSL" if use_ssl else "") assert servers, "no servers started" # Mainloop diff --git a/etesync_dav/webui.py b/etesync_dav/webui.py index 903baead..b1701b8c 100644 --- a/etesync_dav/webui.py +++ b/etesync_dav/webui.py @@ -12,52 +12,55 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -import sys import os +import sys from functools import wraps from urllib.parse import urljoin -from flask import Flask, render_template, redirect, url_for, request, session +import etesync as api +from flask import Flask, redirect, render_template, request, session, url_for from flask_wtf import FlaskForm from flask_wtf.csrf import CSRFProtect -from wtforms import StringField, PasswordField, URLField -from wtforms.validators import Optional, DataRequired, url +from wtforms import PasswordField, StringField, URLField +from wtforms.validators import DataRequired, Optional, url -import etesync as api +from etesync_dav.config import ETESYNC_URL, LEGACY_ETESYNC_URL +from etesync_dav.local_cache import Etebase +from etesync_dav.mac_helpers import generate_cert, has_ssl, needs_ssl, trust_cert from etesync_dav.manage import Manager -from etesync_dav.mac_helpers import generate_cert, trust_cert, needs_ssl, has_ssl + from .radicale.etesync_cache import etesync_for_user -from etesync_dav.local_cache import Etebase -from etesync_dav.config import LEGACY_ETESYNC_URL, ETESYNC_URL manager = Manager() PORT = 37359 -BASE_URL = os.environ.get('ETESYNC_DAV_URL', '/') -ETESYNC_LISTEN_ADDRESS = os.environ.get('ETESYNC_LISTEN_ADDRESS', '127.0.0.1') +BASE_URL = os.environ.get("ETESYNC_DAV_URL", "/") +ETESYNC_LISTEN_ADDRESS = os.environ.get("ETESYNC_LISTEN_ADDRESS", "127.0.0.1") -def prefix_route(route_function, prefix='', mask='{0}{1}'): - ''' - Defines a new route function with a prefix. - The mask argument is a `format string` formatted with, in that order: - prefix, route - ''' +def prefix_route(route_function, prefix="", mask="{0}{1}"): + """ + Defines a new route function with a prefix. + The mask argument is a `format string` formatted with, in that order: + prefix, route + """ + def newroute(route, *args, **kwargs): - '''New function to prefix the route''' + """New function to prefix the route""" return route_function(mask.format(prefix, route), *args, **kwargs) + return newroute # Special handling from frozen apps -if getattr(sys, 'frozen', False): - template_folder = os.path.join(sys._MEIPASS, 'etesync_dav', 'templates') +if getattr(sys, "frozen", False): + template_folder = os.path.join(sys._MEIPASS, "etesync_dav", "templates") app = Flask(__name__, template_folder=template_folder) else: app = Flask(__name__) -app.route = prefix_route(app.route, '/.web') +app.route = prefix_route(app.route, "/.web") app.secret_key = os.urandom(32) CSRFProtect(app) @@ -66,19 +69,20 @@ def newroute(route, *args, **kwargs): @app.context_processor def inject_user(): import etesync_dav + return dict(version=etesync_dav.__version__) def login_user(username): - session['username'] = username + session["username"] = username def logout_user(): - session.pop('username', None) + session.pop("username", None) def logged_in(): - return 'username' in session + return "username" in session def login_required(func): @@ -87,29 +91,36 @@ def decorated_view(*args, **kwargs): if not logged_in(): # If we don't have any users, redirect to adding a user. if len(list(manager.list())) > 0: - return redirect(url_for('login')) + return redirect(url_for("login")) else: - return redirect(url_for('add_user')) + return redirect(url_for("add_user")) return func(*args, **kwargs) + return decorated_view -@app.route('/') +@app.route("/") @login_required def account_list(): remove_user_form = UsernameForm(request.form) - username = session['username'] + username = session["username"] password = manager.get(username) server_url_example = "{}://localhost:37358/{}/".format("https" if has_ssl() else "http", username) - return render_template('index.html', username=username, password=password, remove_user_form=remove_user_form, - osx_ssl_warning=needs_ssl(), server_url_example=server_url_example) + return render_template( + "index.html", + username=username, + password=password, + remove_user_form=remove_user_form, + osx_ssl_warning=needs_ssl(), + server_url_example=server_url_example, + ) -@app.route('/user/') +@app.route("/user/") @login_required def user_index(user): - if session['username'] != user: - return redirect(url_for('user_index', user=session['username'])) + if session["username"] != user: + return redirect(url_for("user_index", user=session["username"])) type_name_mapper = { "etebase.vevent": "Calendars", "etebase.vtodo": "Tasks", @@ -132,14 +143,13 @@ def user_index(user): collections[collection.TYPE] = collections.get(collection.TYPE, []) collections[collection.TYPE].append({"name": collection.display_name, "uid": journal.uid}) - return render_template( - 'user_index.html', BASE_URL=urljoin(BASE_URL, "{}/".format(user)), collections=collections) + return render_template("user_index.html", BASE_URL=urljoin(BASE_URL, "{}/".format(user)), collections=collections) -@app.route('/login/', methods=['GET', 'POST']) +@app.route("/login/", methods=["GET", "POST"]) def login(): if logged_in(): - return redirect(url_for('account_list')) + return redirect(url_for("account_list")) errors = None form = LoginForm(request.form) @@ -147,23 +157,23 @@ def login(): try: manager.refresh_token(form.username.data, form.login_password.data) login_user(form.username.data) - return redirect(url_for('account_list')) + return redirect(url_for("account_list")) except Exception as e: errors = str(e) else: errors = form.errors - return render_template('login.html', form=form, errors=errors) + return render_template("login.html", form=form, errors=errors) -@app.route('/logout/', methods=['POST']) +@app.route("/logout/", methods=["POST"]) @login_required def logout(): form = FlaskForm(request.form) if form.validate_on_submit(): logout_user() - return redirect(url_for('login')) + return redirect(url_for("login")) # FIXME: hack to kill server after generation. @@ -176,30 +186,30 @@ def shutdown(): thread = Timer(0.5, shutdown) thread.start() - return redirect(url_for('shutdown_success')) + return redirect(url_for("shutdown_success")) -@app.route('/shutdown/', methods=['POST']) +@app.route("/shutdown/", methods=["POST"]) @login_required def shutdown(): form = FlaskForm(request.form) if form.validate_on_submit(): return shutdown_response() - return redirect(url_for('login')) + return redirect(url_for("login")) -@app.route('/shutdown/success/', methods=['GET']) +@app.route("/shutdown/success/", methods=["GET"]) @login_required def shutdown_success(): - return render_template('shutdown_success.html') + return render_template("shutdown_success.html") -@app.route('/certgen/', methods=['GET', 'POST']) +@app.route("/certgen/", methods=["GET", "POST"]) @login_required def certgen(): - if request.method == 'GET': - return redirect(url_for('account_list')) + if request.method == "GET": + return redirect(url_for("account_list")) form = FlaskForm(request.form) if form.validate_on_submit(): @@ -208,10 +218,10 @@ def certgen(): return shutdown_response() - return redirect(url_for('account_list')) + return redirect(url_for("account_list")) -@app.route('/add/', methods=['GET', 'POST']) +@app.route("/add/", methods=["GET", "POST"]) def add_user(): errors = None form = AddUserForm(request.form) @@ -220,16 +230,16 @@ def add_user(): server_url = form.server_url.data server_url = ETESYNC_URL if server_url == "" else server_url manager.add_etebase(form.username.data, form.login_password.data, server_url) - return redirect(url_for('account_list')) + return redirect(url_for("account_list")) except Exception as e: errors = str(e) else: errors = form.errors - return render_template('add_user.html', form=form, errors=errors) + return render_template("add_user.html", form=form, errors=errors) -@app.route('/add_legacy/', methods=['GET', 'POST']) +@app.route("/add_legacy/", methods=["GET", "POST"]) def add_user_legacy(): errors = None form = AddUserLegacyForm(request.form) @@ -238,34 +248,34 @@ def add_user_legacy(): server_url = form.server_url.data server_url = LEGACY_ETESYNC_URL if server_url == "" else server_url manager.add(form.username.data, form.login_password.data, form.encryption_password.data, server_url) - return redirect(url_for('account_list')) + return redirect(url_for("account_list")) except api.exceptions.IntegrityException: - errors = 'Wrong encryption password (failed to decrypt data)' + errors = "Wrong encryption password (failed to decrypt data)" except Exception as e: errors = str(e) else: errors = form.errors - return render_template('add_user_legacy.html', form=form, errors=errors) + return render_template("add_user_legacy.html", form=form, errors=errors) -@app.route('/remove_user/', methods=['GET', 'POST']) +@app.route("/remove_user/", methods=["GET", "POST"]) @login_required def remove_user(): form = UsernameForm(request.form) if form.validate_on_submit(): manager.delete(form.username.data) - return redirect(url_for('account_list')) + return redirect(url_for("account_list")) class UsernameForm(FlaskForm): - username = StringField('Username', validators=[DataRequired()]) + username = StringField("Username", validators=[DataRequired()]) class LoginForm(UsernameForm): - server_url = URLField('Server URL (Leave Empty for Default)', validators=[Optional(), url(require_tld=False)]) - login_password = PasswordField('Account Password', validators=[DataRequired()]) + server_url = URLField("Server URL (Leave Empty for Default)", validators=[Optional(), url(require_tld=False)]) + login_password = PasswordField("Account Password", validators=[DataRequired()]) class AddUserForm(LoginForm): @@ -273,7 +283,7 @@ class AddUserForm(LoginForm): class AddUserLegacyForm(LoginForm): - encryption_password = PasswordField('Encryption Password', validators=[DataRequired()]) + encryption_password = PasswordField("Encryption Password", validators=[DataRequired()]) def run(debug=False): diff --git a/pyinstaller/hooks/hook-dateutil.py b/pyinstaller/hooks/hook-dateutil.py index e34c250f..7690442d 100644 --- a/pyinstaller/hooks/hook-dateutil.py +++ b/pyinstaller/hooks/hook-dateutil.py @@ -1,3 +1,3 @@ from PyInstaller.utils.hooks import copy_metadata -datas = copy_metadata('python_dateutil') +datas = copy_metadata("python_dateutil") diff --git a/pyinstaller/hooks/hook-etesync_dav.py b/pyinstaller/hooks/hook-etesync_dav.py index 12823255..4a3a9bd6 100644 --- a/pyinstaller/hooks/hook-etesync_dav.py +++ b/pyinstaller/hooks/hook-etesync_dav.py @@ -1,6 +1,6 @@ -from PyInstaller.utils.hooks import copy_metadata, collect_data_files, collect_submodules +from PyInstaller.utils.hooks import collect_data_files, collect_submodules, copy_metadata -datas = copy_metadata('etesync_dav') -datas += collect_data_files('etesync_dav') +datas = copy_metadata("etesync_dav") +datas += collect_data_files("etesync_dav") -hiddenimports = collect_submodules('pkg_resources') +hiddenimports = collect_submodules("pkg_resources") diff --git a/pyinstaller/hooks/hook-vobject.py b/pyinstaller/hooks/hook-vobject.py index 2d4ba951..41648a9f 100644 --- a/pyinstaller/hooks/hook-vobject.py +++ b/pyinstaller/hooks/hook-vobject.py @@ -1,3 +1,3 @@ from PyInstaller.utils.hooks import copy_metadata -datas = copy_metadata('vobject') +datas = copy_metadata("vobject") diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..2cb4e1b3 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,28 @@ +[tool.black] +line-length = 120 + +[build-system] +requires = ["setuptools>=42"] +build-backend = "setuptools.build_meta" + +[tool.ruff] +line-length = 120 +exclude = [ + ".git", + ".git-rewrite", + ".mypy_cache", + ".pytype", + ".ruff_cache", + ".venv", + "build", + "dist", + "node_modules", + "migrations", # Alembic migrations +] + +[tool.ruff.lint] +select = ["E", "F", "I", "N", "T20", "W"] +ignore = ["E203", "E501", "E711", "E712", "E721", "N802", "N803", "N806", "N812", "N815", "N818", "T201"] + +[tool.ruff.lint.isort] +combine-as-imports = true diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 00000000..2f01a1ed --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,26 @@ +# +# This file is autogenerated by pip-compile with Python 3.12 +# by the following command: +# +# pip-compile --output-file=requirements-dev.txt requirements.in/development.txt +# +build==1.2.1 + # via pip-tools +click==8.1.7 + # via pip-tools +packaging==24.1 + # via build +pip-tools==7.4.1 + # via -r requirements.in/development.txt +pyproject-hooks==1.1.0 + # via + # build + # pip-tools +ruff==0.6.3 + # via -r requirements.in/development.txt +wheel==0.44.0 + # via pip-tools + +# The following packages are considered to be unsafe in a requirements file: +# pip +# setuptools diff --git a/requirements.in/development.txt b/requirements.in/development.txt new file mode 100644 index 00000000..44769198 --- /dev/null +++ b/requirements.in/development.txt @@ -0,0 +1,2 @@ +pip-tools +ruff diff --git a/setup.py b/setup.py index d81ee396..cd6c2e8c 100644 --- a/setup.py +++ b/setup.py @@ -23,83 +23,78 @@ def read_file(filepath): """Read content from a UTF-8 encoded text file.""" - with codecs.open(filepath, 'rb', 'utf-8') as file_handle: + with codecs.open(filepath, "rb", "utf-8") as file_handle: return file_handle.read() -PKG_NAME = 'etesync-dav' +PKG_NAME = "etesync-dav" PKG_DIR = path.abspath(path.dirname(__file__)) -META_PATH = path.join(PKG_DIR, PKG_NAME.replace('-', '_', 1), '__init__.py') +META_PATH = path.join(PKG_DIR, PKG_NAME.replace("-", "_", 1), "__init__.py") META_CONTENTS = read_file(META_PATH) def load_long_description(): """Load long description from file DESCRIPTION.rst.""" + def changes(): - changelog = path.join(PKG_DIR, 'ChangeLog.md') + changelog = path.join(PKG_DIR, "ChangeLog.md") log = r"(## Version \d+.\d+.\d+\r?\n.*?)## Version" result = re.search(log, read_file(changelog), re.S) - return result.group(1).strip() if result else '' + return result.group(1).strip() if result else "" try: title = f"{PKG_NAME}: {find_meta('description')}" - head = '=' * (len(title.strip(' .'))) + head = "=" * (len(title.strip(" ."))) contents = ( head, - format(title.strip(' .')), + format(title.strip(" .")), head, - '', - read_file(path.join(PKG_DIR, 'DESCRIPTION.rst')), - '' - 'Release Information', - '===================\n', + "", + read_file(path.join(PKG_DIR, "DESCRIPTION.rst")), + "" "Release Information", + "===================\n", changes(), - '', - f"`Full changelog <{find_meta('url')}/blob/master/ChangeLog.md>`_." + "", + f"`Full changelog <{find_meta('url')}/blob/master/ChangeLog.md>`_.", ) - return '\n'.join(contents) + return "\n".join(contents) except (RuntimeError, FileNotFoundError) as read_error: - message = 'Long description could not be obtained' - raise RuntimeError(f'{message}: {read_error}') from read_error + message = "Long description could not be obtained" + raise RuntimeError(f"{message}: {read_error}") from read_error def find_meta(meta): """Extract __*meta*__ from META_CONTENTS.""" - meta_match = re.search( - r"^__{meta}__\s+=\s+['\"]([^'\"]*)['\"]".format(meta=meta), - META_CONTENTS, - re.M - ) + meta_match = re.search(r"^__{meta}__\s+=\s+['\"]([^'\"]*)['\"]".format(meta=meta), META_CONTENTS, re.M) if meta_match: return meta_match.group(1) - raise RuntimeError( - f'Unable to find __{meta}__ string in package meta file' - ) + raise RuntimeError(f"Unable to find __{meta}__ string in package meta file") def is_canonical_version(version): """Check if a version string is in the canonical format of PEP 440.""" pattern = ( - r'^([1-9][0-9]*!)?(0|[1-9][0-9]*)(\.(0|[1-9][0-9]*))' - r'*((a|b|rc)(0|[1-9][0-9]*))?(\.post(0|[1-9][0-9]*))' - r'?(\.dev(0|[1-9][0-9]*))?$') + r"^([1-9][0-9]*!)?(0|[1-9][0-9]*)(\.(0|[1-9][0-9]*))" + r"*((a|b|rc)(0|[1-9][0-9]*))?(\.post(0|[1-9][0-9]*))" + r"?(\.dev(0|[1-9][0-9]*))?$" + ) return re.match(pattern, version) is not None def get_version_string(): """Return package version as listed in `__version__` in meta file.""" # Parse version string - version_string = find_meta('version') + version_string = find_meta("version") # Check validity if not is_canonical_version(version_string): - message = ( - 'The detected version string "{}" is not in canonical ' - 'format as defined in PEP 440.'.format(version_string)) + message = 'The detected version string "{}" is not in canonical ' "format as defined in PEP 440.".format( + version_string + ) raise ValueError(message) return version_string @@ -107,54 +102,47 @@ def get_version_string(): # Dependencies that are downloaded by pip on installation and why. INSTALL_REQUIRES = [ - 'appdirs>=1.4.3', - 'etesync>=0.12.1', - 'etebase>=0.30.0', - 'msgpack>=1.0.0', - 'Radicale>=3.0.3,<3.2.0', - 'Flask>=1.1.1', - 'Flask-WTF>=0.14.2,<1.0.0', - 'requests[socks]>=2.21', + "appdirs>=1.4.3", + "etesync>=0.12.1", + "etebase>=0.30.0", + "msgpack>=1.0.0", + "Radicale>=3.0.3,<3.2.0", + "Flask>=1.1.1", + "Flask-WTF>=0.14.2,<1.0.0", + "requests[socks]>=2.21", 'pyobjc-framework-Cocoa>=7.0.0 ; sys_platform=="darwin"', ] -KEYWORDS = [ - 'etesync', - 'encryption', - 'sync', - 'pim', - 'caldav', - 'carddav' -] +KEYWORDS = ["etesync", "encryption", "sync", "pim", "caldav", "carddav"] # Project's URLs PROJECT_URLS = { - 'Documentation': f"{find_meta('url')}/blob/master/README.md#installation", - 'Changelog': f"{find_meta('url')}/blob/master/ChangeLog.md", - 'Bug Tracker': f"{find_meta('url')}/issues", - 'Source Code': find_meta('url'), + "Documentation": f"{find_meta('url')}/blob/master/README.md#installation", + "Changelog": f"{find_meta('url')}/blob/master/ChangeLog.md", + "Bug Tracker": f"{find_meta('url')}/issues", + "Source Code": find_meta("url"), } -if __name__ == '__main__': +if __name__ == "__main__": setup( name=PKG_NAME, version=get_version_string(), - author=find_meta('author'), - author_email=find_meta('author_email'), - license=find_meta('license'), - description=find_meta('description'), + author=find_meta("author"), + author_email=find_meta("author_email"), + license=find_meta("license"), + description=find_meta("description"), long_description=load_long_description(), - long_description_content_type='text/x-rst', + long_description_content_type="text/x-rst", keywords=KEYWORDS, - url=find_meta('url'), + url=find_meta("url"), project_urls=PROJECT_URLS, packages=find_packages(exclude=["tests"]), - platforms='any', + platforms="any", include_package_data=True, zip_safe=False, - python_requires='>=3', + python_requires=">=3", install_requires=INSTALL_REQUIRES, scripts=[ - 'scripts/etesync-dav', + "scripts/etesync-dav", ], )