Drop support for Python 3.5 and 3.6

This commit is contained in:
Hugo Osvaldo Barrera 2020-06-09 14:33:14 +02:00
parent 56b1fc2187
commit 9cb1f8d704
30 changed files with 77 additions and 135 deletions

View file

@ -19,75 +19,23 @@
"include": [
{
"env": "BUILD=style",
"python": "3.6"
"python": "3.7"
},
{
"env": "BUILD=test REQUIREMENTS=release",
"python": "3.5"
},
{
"dist": "trusty",
"env": "BUILD=test-storage DAV_SERVER=radicale REQUIREMENTS=release ",
"python": "3.5"
},
{
"dist": "trusty",
"env": "BUILD=test-storage DAV_SERVER=xandikos REQUIREMENTS=release ",
"python": "3.5"
},
{
"env": "BUILD=test REQUIREMENTS=minimal",
"python": "3.5"
},
{
"dist": "trusty",
"env": "BUILD=test-storage DAV_SERVER=radicale REQUIREMENTS=minimal ",
"python": "3.5"
},
{
"dist": "trusty",
"env": "BUILD=test-storage DAV_SERVER=xandikos REQUIREMENTS=minimal ",
"python": "3.5"
},
{
"env": "BUILD=test REQUIREMENTS=release",
"python": "3.6"
"python": "3.7"
},
{
"env": "BUILD=test-storage DAV_SERVER=radicale REQUIREMENTS=release ",
"python": "3.6"
"python": "3.7"
},
{
"env": "BUILD=test-storage DAV_SERVER=xandikos REQUIREMENTS=release ",
"python": "3.6"
"python": "3.7"
},
{
"env": "BUILD=test-storage DAV_SERVER=fastmail REQUIREMENTS=release ",
"if": "NOT (type IN (pull_request))",
"python": "3.6"
},
{
"env": "BUILD=test REQUIREMENTS=minimal",
"python": "3.6"
},
{
"env": "BUILD=test-storage DAV_SERVER=radicale REQUIREMENTS=minimal ",
"python": "3.6"
},
{
"env": "BUILD=test-storage DAV_SERVER=xandikos REQUIREMENTS=minimal ",
"python": "3.6"
},
{
"env": "BUILD=test REQUIREMENTS=release",
"python": "3.7"
},
{
"env": "BUILD=test-storage DAV_SERVER=radicale REQUIREMENTS=release ",
"python": "3.7"
},
{
"env": "BUILD=test-storage DAV_SERVER=xandikos REQUIREMENTS=release ",
"python": "3.7"
},
{
@ -128,7 +76,7 @@
},
{
"env": "BUILD=test ETESYNC_TESTS=true REQUIREMENTS=latest",
"python": "3.6"
"python": "3.7"
}
]
},

View file

@ -65,7 +65,7 @@ def github_issue_role(name, rawtext, text, lineno, inliner,
if issue_num <= 0:
raise ValueError()
except ValueError:
msg = inliner.reporter.error('Invalid GitHub issue: {}'.format(text),
msg = inliner.reporter.error(f'Invalid GitHub issue: {text}',
line=lineno)
prb = inliner.problematic(rawtext, rawtext, msg)
return [prb], [msg]

View file

@ -41,7 +41,7 @@ If your distribution doesn't provide a package for vdirsyncer, you still can
use Python's package manager "pip". First, you'll have to check that the
following things are installed:
- Python 3.5+ and pip.
- Python 3.7+ and pip.
- ``libxml`` and ``libxslt``
- ``zlib``
- Linux or OS X. **Windows is not supported**, see :gh:`535`.

View file

@ -3,8 +3,7 @@
import itertools
import json
python_versions = ("3.5", "3.6", "3.7", "3.8")
latest_python = "3.6"
python_versions = ["3.7", "3.8"]
cfg = {}
@ -34,7 +33,7 @@ matrix = []
cfg['matrix'] = {'include': matrix, 'fast_finish': True}
matrix.append({
'python': latest_python,
'python': python_versions[0],
'env': 'BUILD=style'
})
@ -51,7 +50,7 @@ for python, requirements in itertools.product(
'env': f"BUILD=test REQUIREMENTS={requirements}",
})
if python == latest_python and requirements == "release":
if python == python_versions[0] and requirements == "release":
dav_servers += ("fastmail",)
for dav_server in dav_servers:
@ -61,8 +60,6 @@ for python, requirements in itertools.product(
f"DAV_SERVER={dav_server} "
f"REQUIREMENTS={requirements} ")
}
if python == '3.5':
job['dist'] = 'trusty'
build_prs = dav_server not in ("fastmail", "davical", "icloud")
if not build_prs:
@ -71,7 +68,7 @@ for python, requirements in itertools.product(
matrix.append(job)
matrix.append({
'python': latest_python,
'python': python_versions[0],
'env': ("BUILD=test "
"ETESYNC_TESTS=true "
"REQUIREMENTS=latest")

View file

@ -87,8 +87,6 @@ setup(
'License :: OSI Approved :: BSD License',
'Operating System :: POSIX',
'Programming Language :: Python :: 3',
'Programming Language :: Python :: 3.5',
'Programming Language :: Python :: 3.6',
'Programming Language :: Python :: 3.7',
'Programming Language :: Python :: 3.8',
'Topic :: Internet',

View file

@ -19,7 +19,7 @@ from .. import EVENT_TEMPLATE, TASK_TEMPLATE, VCARD_TEMPLATE, \
def get_server_mixin(server_name):
from . import __name__ as base
x = __import__('{}.servers.{}'.format(base, server_name), fromlist=[''])
x = __import__(f'{base}.servers.{server_name}', fromlist=[''])
return x.ServerMixin
@ -183,7 +183,7 @@ class StorageTests:
def test_discover(self, requires_collections, get_storage_args, get_item):
collections = set()
for i in range(1, 5):
collection = 'test{}'.format(i)
collection = f'test{i}'
s = self.storage_class(**get_storage_args(collection=collection))
assert not list(s.list())
s.upload(get_item())

View file

@ -19,7 +19,7 @@ def storage(tmpdir, runner):
def test_basic(storage, runner, collection):
if collection is not None:
storage = storage.mkdir(collection)
collection_arg = 'foo/{}'.format(collection)
collection_arg = f'foo/{collection}'
else:
collection_arg = 'foo'

View file

@ -461,7 +461,7 @@ def test_partial_sync(tmpdir, runner, partial_sync):
fileext = ".txt"
path = "{base}/bar"
'''.format(
partial_sync=('partial_sync = "{}"\n'.format(partial_sync)
partial_sync=(f'partial_sync = "{partial_sync}"\n'
if partial_sync else ''),
base=str(tmpdir)
)))

View file

@ -47,7 +47,7 @@ def test_key_conflict(monkeypatch, mystrategy):
@given(s=st.text(), t=st.text(min_size=1))
def test_fuzzing(s, t, mystrategy):
config = expand_fetch_params({
'{}.fetch'.format(s): ['mystrategy', t]
f'{s}.fetch': ['mystrategy', t]
})
assert config[s] == t

View file

@ -253,7 +253,7 @@ def test_conflict_resolution_both_etags_new(winning_storage):
b.update(href_b, item_b, etag_b)
with pytest.raises(SyncConflict):
sync(a, b, status)
sync(a, b, status, conflict_resolution='{} wins'.format(winning_storage))
sync(a, b, status, conflict_resolution=f'{winning_storage} wins')
assert items(a) == items(b) == {
item_a.raw if winning_storage == 'a' else item_b.raw
}
@ -563,7 +563,7 @@ class SyncMachine(RuleBasedStateMachine):
uid=uid_strategy,
etag=st.text())
def upload(self, storage, uid, etag):
item = Item('UID:{}'.format(uid))
item = Item(f'UID:{uid}')
storage.items[uid] = (etag, item)
@rule(storage=Storage, href=st.text())

View file

@ -18,11 +18,11 @@ def test_repair_uids(uid):
s.items = {
'one': (
'asdf',
Item('BEGIN:VCARD\nFN:Hans\nUID:{}\nEND:VCARD'.format(uid))
Item(f'BEGIN:VCARD\nFN:Hans\nUID:{uid}\nEND:VCARD')
),
'two': (
'asdf',
Item('BEGIN:VCARD\nFN:Peppi\nUID:{}\nEND:VCARD'.format(uid))
Item(f'BEGIN:VCARD\nFN:Peppi\nUID:{uid}\nEND:VCARD')
)
}
@ -40,7 +40,7 @@ def test_repair_uids(uid):
@settings(suppress_health_check=HealthCheck.all())
def test_repair_unsafe_uids(uid):
s = MemoryStorage()
item = Item('BEGIN:VCARD\nUID:{}\nEND:VCARD'.format(uid))
item = Item(f'BEGIN:VCARD\nUID:{uid}\nEND:VCARD')
href, etag = s.upload(item)
assert s.get(href)[0].uid == uid
assert not href_safe(uid)
@ -58,7 +58,7 @@ def test_repair_unsafe_uids(uid):
('perfectly-fine', 'b@dh0mbr3')
])
def test_repair_unsafe_href(uid, href):
item = Item('BEGIN:VCARD\nUID:{}\nEND:VCARD'.format(uid))
item = Item(f'BEGIN:VCARD\nUID:{uid}\nEND:VCARD')
new_item = repair_item(href, item, set(), True)
assert new_item.raw != item.raw
assert new_item.uid != item.uid

View file

@ -221,7 +221,7 @@ def test_replace_uid(template, uid):
item = vobject.Item(template.format(r=123, uid=123)).with_uid(uid)
assert item.uid == uid
if uid:
assert item.raw.count('\nUID:{}'.format(uid)) == 1
assert item.raw.count(f'\nUID:{uid}') == 1
else:
assert '\nUID:' not in item.raw
@ -317,7 +317,7 @@ class VobjectMachine(RuleBasedStateMachine):
params=st.lists(st.tuples(value_strategy, value_strategy)))
def add_prop_raw(self, c, key, value, params):
params_str = ','.join(k + '=' + v for k, v in params)
c.props.insert(0, '{};{}:{}'.format(key, params_str, value))
c.props.insert(0, f'{key};{params_str}:{value}')
assert c[key] == value
assert key in c
assert c.get(key) == value

View file

@ -19,8 +19,8 @@ except ImportError: # pragma: no cover
def _check_python_version(): # pragma: no cover
import sys
if sys.version_info < (3, 4, 0):
print('vdirsyncer requires at least Python 3.5.')
if sys.version_info < (3, 7, 0):
print('vdirsyncer requires at least Python 3.7.')
sys.exit(1)

View file

@ -65,7 +65,7 @@ def max_workers_callback(ctx, param, value):
if value == 0 and logging.getLogger('vdirsyncer').level == logging.DEBUG:
value = 1
cli_logger.debug('Using {} maximal workers.'.format(value))
cli_logger.debug(f'Using {value} maximal workers.')
return value
@ -75,7 +75,7 @@ def max_workers_option(default=0):
help += 'The default is 0, which means "as many as necessary". ' \
'With -vdebug enabled, the default is 1.'
else:
help += 'The default is {}.'.format(default)
help += f'The default is {default}.'
return click.option(
'--max-workers', default=default, type=click.IntRange(min=0, max=None),

View file

@ -101,7 +101,7 @@ class _ConfigReader:
def _parse_section(self, section_type, name, options):
validate_section_name(name, section_type)
if name in self._seen_names:
raise ValueError('Name "{}" already used.'.format(name))
raise ValueError(f'Name "{name}" already used.')
self._seen_names.add(name)
if section_type == 'general':
@ -163,7 +163,7 @@ class Config:
try:
self.pairs[name] = PairConfig(self, name, options)
except ValueError as e:
raise exceptions.UserError('Pair {}: {}'.format(name, e))
raise exceptions.UserError(f'Pair {name}: {e}')
@classmethod
def from_fileobject(cls, f):

View file

@ -211,7 +211,7 @@ def _print_collections(instance_name, get_discovered):
logger.warning('Failed to discover collections for {}, use `-vdebug` '
'to see the full traceback.'.format(instance_name))
return
logger.info('{}:'.format(instance_name))
logger.info(f'{instance_name}:')
for args in discovered.values():
collection = args['collection']
if collection is None:
@ -226,7 +226,7 @@ def _print_collections(instance_name, get_discovered):
logger.info(' - {}{}'.format(
json.dumps(collection),
' ("{}")'.format(displayname)
f' ("{displayname}")'
if displayname and displayname != collection
else ''
))

View file

@ -19,7 +19,7 @@ def expand_fetch_params(config):
newkey = key[:-len(SUFFIX)]
if newkey in config:
raise ValueError('Can\'t set {} and {}.'.format(key, newkey))
raise ValueError(f'Can\'t set {key} and {newkey}.')
config[newkey] = _fetch_value(config[key], key)
del config[key]
@ -45,7 +45,7 @@ def _fetch_value(opts, key):
cache_key = tuple(opts)
if cache_key in password_cache:
rv = password_cache[cache_key]
logger.debug('Found cached value for {!r}.'.format(opts))
logger.debug(f'Found cached value for {opts!r}.')
if isinstance(rv, BaseException):
raise rv
return rv
@ -54,7 +54,7 @@ def _fetch_value(opts, key):
try:
strategy_fn = STRATEGIES[strategy]
except KeyError:
raise exceptions.UserError('Unknown strategy: {}'.format(strategy))
raise exceptions.UserError(f'Unknown strategy: {strategy}')
logger.debug('Fetching value for {} with {} strategy.'
.format(key, strategy))

View file

@ -45,7 +45,7 @@ def sync_collection(wq, collection, general, force_delete):
status_name = get_status_name(pair.name, collection.name)
try:
cli_logger.info('Syncing {}'.format(status_name))
cli_logger.info(f'Syncing {status_name}')
a = storage_instance_from_config(collection.config_a)
b = storage_instance_from_config(collection.config_b)
@ -110,7 +110,7 @@ def repair_collection(config, collection, repair_unsafe_uid):
config['type'] = storage_type
storage = storage_instance_from_config(config)
cli_logger.info('Repairing {}/{}'.format(storage_name, collection))
cli_logger.info(f'Repairing {storage_name}/{collection}')
cli_logger.warning('Make sure no other program is talking to the server.')
repair_storage(storage, repair_unsafe_uid=repair_unsafe_uid)
@ -121,7 +121,7 @@ def metasync_collection(wq, collection, general):
status_name = get_status_name(pair.name, collection.name)
try:
cli_logger.info('Metasyncing {}'.format(status_name))
cli_logger.info(f'Metasyncing {status_name}')
status = load_status(general['status_path'], pair.name,
collection.name, data_type='metadata') or {}

View file

@ -144,11 +144,11 @@ def handle_cli_error(status_name=None, e=None):
import traceback
tb = traceback.format_tb(tb)
if status_name:
msg = 'Unknown error occurred for {}'.format(status_name)
msg = f'Unknown error occurred for {status_name}'
else:
msg = 'Unknown error occurred'
msg += ': {}\nUse `-vdebug` to see the full traceback.'.format(e)
msg += f': {e}\nUse `-vdebug` to see the full traceback.'
cli_logger.error(msg)
cli_logger.debug(''.join(tb))
@ -210,8 +210,7 @@ def manage_sync_status(base_path, pair_name, collection_name):
with open(path, 'rb') as f:
if f.read(1) == b'{':
f.seek(0)
# json.load doesn't work on binary files for Python 3.5
legacy_status = dict(json.loads(f.read().decode('utf-8')))
legacy_status = dict(json.load(f))
except (OSError, ValueError):
pass
@ -247,7 +246,7 @@ def storage_class_from_config(config):
cls = storage_names[storage_name]
except KeyError:
raise exceptions.UserError(
'Unknown storage type: {}'.format(storage_name))
f'Unknown storage type: {storage_name}')
return cls, config
@ -399,7 +398,7 @@ def handle_collection_not_found(config, collection, e=None):
storage_name = config.get('instance_name', None)
cli_logger.warning('{}No collection {} found for storage {}.'
.format('{}\n'.format(e) if e else '',
.format(f'{e}\n' if e else '',
json.dumps(collection), storage_name))
if click.confirm('Should vdirsyncer attempt to create it?'):

View file

@ -10,7 +10,7 @@ class Error(Exception):
def __init__(self, *args, **kwargs):
for key, value in kwargs.items():
if getattr(self, key, object()) is not None: # pragma: no cover
raise TypeError('Invalid argument: {}'.format(key))
raise TypeError(f'Invalid argument: {key}')
setattr(self, key, value)
super().__init__(*args)
@ -25,7 +25,7 @@ class UserError(Error, ValueError):
def __str__(self):
msg = Error.__str__(self)
for problem in self.problems or ():
msg += '\n - {}'.format(problem)
msg += f'\n - {problem}'
return msg

View file

@ -7,7 +7,7 @@ from . import DOCS_HOME, exceptions, __version__
logger = logging.getLogger(__name__)
USERAGENT = 'vdirsyncer/{}'.format(__version__)
USERAGENT = f'vdirsyncer/{__version__}'
def _detect_faulty_requests(): # pragma: no cover
@ -133,7 +133,7 @@ def request(method, url, session=None, latin1_fallback=True,
func = session.request
logger.debug('{} {}'.format(method, url))
logger.debug(f'{method} {url}')
logger.debug(kwargs.get('headers', {}))
logger.debug(kwargs.get('data', None))
logger.debug('Sending request...')

View file

@ -16,12 +16,12 @@ class MetaSyncConflict(MetaSyncError):
def metasync(storage_a, storage_b, status, keys, conflict_resolution=None):
def _a_to_b():
logger.info('Copying {} to {}'.format(key, storage_b))
logger.info(f'Copying {key} to {storage_b}')
storage_b.set_meta(key, a)
status[key] = a
def _b_to_a():
logger.info('Copying {} to {}'.format(key, storage_a))
logger.info(f'Copying {key} to {storage_a}')
storage_a.set_meta(key, b)
status[key] = b
@ -45,10 +45,10 @@ def metasync(storage_a, storage_b, status, keys, conflict_resolution=None):
a = storage_a.get_meta(key)
b = storage_b.get_meta(key)
s = normalize_meta_value(status.get(key))
logger.debug('Key: {}'.format(key))
logger.debug('A: {}'.format(a))
logger.debug('B: {}'.format(b))
logger.debug('S: {}'.format(s))
logger.debug(f'Key: {key}')
logger.debug(f'A: {a}')
logger.debug(f'B: {b}')
logger.debug(f'S: {s}')
if a != s and b != s:
_resolve_conflict()

View file

@ -25,7 +25,7 @@ def repair_storage(storage, repair_unsafe_uid):
'The PRODID property may indicate which software '
'created this item.'
.format(href))
logger.error('Item content: {!r}'.format(item.raw))
logger.error(f'Item content: {item.raw!r}')
continue
seen_uids.add(new_item.uid)

View file

@ -71,7 +71,7 @@ class Storage(metaclass=StorageMeta):
self.read_only = bool(read_only)
if collection and instance_name:
instance_name = '{}/{}'.format(instance_name, collection)
instance_name = f'{instance_name}/{collection}'
self.instance_name = instance_name
self.collection = collection

View file

@ -34,7 +34,7 @@ del _generate_path_reserved_chars
def _contains_quoted_reserved_chars(x):
for y in _path_reserved_chars:
if y in x:
dav_logger.debug('Unsafe character: {!r}'.format(y))
dav_logger.debug(f'Unsafe character: {y!r}')
return True
return False
@ -52,7 +52,7 @@ def _assert_multistatus_success(r):
except (ValueError, IndexError):
continue
if st < 200 or st >= 400:
raise HTTPError('Server error: {}'.format(st))
raise HTTPError(f'Server error: {st}')
def _normalize_href(base, href):
@ -78,7 +78,7 @@ def _normalize_href(base, href):
x = urlparse.quote(x, '/@%:')
if orig_href == x:
dav_logger.debug('Already normalized: {!r}'.format(x))
dav_logger.debug(f'Already normalized: {x!r}')
else:
dav_logger.debug('Normalized URL from {!r} to {!r}'
.format(orig_href, x))
@ -459,7 +459,7 @@ class DAVStorage(Storage):
for href in hrefs:
if href != self._normalize_href(href):
raise exceptions.NotFoundError(href)
href_xml.append('<D:href>{}</D:href>'.format(href))
href_xml.append(f'<D:href>{href}</D:href>')
if not href_xml:
return ()
@ -591,7 +591,7 @@ class DAVStorage(Storage):
props = _merge_xml(props)
if props.find('{DAV:}resourcetype/{DAV:}collection') is not None:
dav_logger.debug('Skipping {!r}, is collection.'.format(href))
dav_logger.debug(f'Skipping {href!r}, is collection.')
continue
etag = getattr(props.find('{DAV:}getetag'), 'text', '')
@ -641,7 +641,7 @@ class DAVStorage(Storage):
except KeyError:
raise exceptions.UnsupportedMetadataError()
xpath = '{{{}}}{}'.format(namespace, tagname)
xpath = f'{{{namespace}}}{tagname}'
data = '''<?xml version="1.0" encoding="utf-8" ?>
<D:propfind xmlns:D="DAV:">
<D:prop>
@ -674,7 +674,7 @@ class DAVStorage(Storage):
except KeyError:
raise exceptions.UnsupportedMetadataError()
lxml_selector = '{{{}}}{}'.format(namespace, tagname)
lxml_selector = f'{{{namespace}}}{tagname}'
element = etree.Element(lxml_selector)
element.text = normalize_meta_value(value)

View file

@ -66,7 +66,7 @@ class _Session:
key = self._get_key()
if not key:
password = click.prompt('Enter key password', hide_input=True)
click.echo('Deriving key for {}'.format(self.email))
click.echo(f'Deriving key for {self.email}')
self.etesync.derive_key(password)
self._set_key(self.etesync.cipher_key)
else:
@ -134,7 +134,7 @@ class EtesyncStorage(Storage):
**kwargs
)
else:
logger.debug('Skipping collection: {!r}'.format(entry))
logger.debug(f'Skipping collection: {entry!r}')
@classmethod
def create_collection(cls, collection, email, secrets_dir, server_url=None,

View file

@ -80,7 +80,7 @@ class GoogleSession(dav.DAVSession):
# access_type and approval_prompt are Google specific
# extra parameters.
access_type='offline', approval_prompt='force')
click.echo('Opening {} ...'.format(authorization_url))
click.echo(f'Opening {authorization_url} ...')
try:
open_graphical_browser(authorization_url)
except Exception as e:

View file

@ -6,7 +6,7 @@ from .. import exceptions
def _random_string():
return '{:.9f}'.format(random.random())
return f'{random.random():.9f}'
class MemoryStorage(Storage):

View file

@ -73,7 +73,7 @@ def get_etag_from_file(f):
mtime = getattr(stat, 'st_mtime_ns', None)
if mtime is None:
mtime = stat.st_mtime
return '{:.9f};{}'.format(mtime, stat.st_ino)
return f'{mtime:.9f};{stat.st_ino}'
def get_storage_init_specs(cls, stop_at=object):
@ -125,7 +125,7 @@ def checkdir(path, create=False, mode=0o750):
if not os.path.isdir(path):
if os.path.exists(path):
raise OSError('{} is not a directory.'.format(path))
raise OSError(f'{path} is not a directory.')
if create:
os.makedirs(path, mode)
else:
@ -143,7 +143,7 @@ def checkfile(path, create=False):
checkdir(os.path.dirname(path), create=create)
if not os.path.isfile(path):
if os.path.exists(path):
raise OSError('{} is not a file.'.format(path))
raise OSError(f'{path} is not a file.')
if create:
with open(path, 'wb'):
pass

View file

@ -205,14 +205,14 @@ def join_collection(items, wrappers=_default_join_wrappers):
if wrapper_type is not None:
lines = chain(*(
['BEGIN:{}'.format(wrapper_type)],
[f'BEGIN:{wrapper_type}'],
# XXX: wrapper_props is a list of lines (with line-wrapping), so
# filtering out duplicate lines will almost certainly break
# multiline-values. Since the only props we usually need to
# support are PRODID and VERSION, I don't care.
uniq(wrapper_props),
lines,
['END:{}'.format(wrapper_type)]
[f'END:{wrapper_type}']
))
return ''.join(line + '\r\n' for line in lines)
@ -299,14 +299,14 @@ class _Component:
return rv[0]
def dump_lines(self):
yield 'BEGIN:{}'.format(self.name)
yield f'BEGIN:{self.name}'
yield from self.props
for c in self.subcomponents:
yield from c.dump_lines()
yield 'END:{}'.format(self.name)
yield f'END:{self.name}'
def __delitem__(self, key):
prefix = ('{}:'.format(key), '{};'.format(key))
prefix = (f'{key}:', f'{key};')
new_lines = []
lineiter = iter(self.props)
while True:
@ -329,7 +329,7 @@ class _Component:
assert isinstance(val, str)
assert '\n' not in val
del self[key]
line = '{}:{}'.format(key, val)
line = f'{key}:{val}'
self.props.append(line)
def __contains__(self, obj):
@ -342,8 +342,8 @@ class _Component:
raise ValueError(obj)
def __getitem__(self, key):
prefix_without_params = '{}:'.format(key)
prefix_with_params = '{};'.format(key)
prefix_without_params = f'{key}:'
prefix_with_params = f'{key};'
iterlines = iter(self.props)
for line in iterlines:
if line.startswith(prefix_without_params):