Compare commits

...

8 commits

16 changed files with 269 additions and 42 deletions

View file

@ -42,7 +42,6 @@ def calc_disk_usage(path, size='bytes'):
# do not calc if path is None # do not calc if path is None
if path is None: if path is None:
return None return None
if size == 'bytes': if size == 'bytes':
div = 1 div = 1
else: else:
@ -60,12 +59,13 @@ usage = FloatOption('usage', 'Disk usage', Calculation(calc_disk_usage,
Finally add those options in option description and a Config: Finally add those options in option description and a Config:
``` ```python
disk = OptionDescription('disk', 'Verify disk usage', [filename, usage]) disk = OptionDescription('disk', 'Verify disk usage', [filename, usage])
root = OptionDescription('root', 'root', [disk]) root = OptionDescription('root', 'root', [disk])
async def main(): async def main():
config = await Config(root) config = await Config(root)
await config.property.read_write() await config.property.read_write()
return config
config = run(main()) config = run(main())
``` ```
@ -109,26 +109,29 @@ returns:
When you enter a value it is validated: When you enter a value it is validated:
>>> try: ```python
>>> config.option('disk.path').value.set('/unknown') async def main():
>>> except ValueError as err: try:
>>> print(err) await config.option('disk.path').value.set('/unknown')
except ValueError as err:
print(err)
run(main())
```
returns:
```
"/unknown" is an invalid file name for "Path", this directory does not exist "/unknown" is an invalid file name for "Path", this directory does not exist
```
We can also set a :doc:`calculation` as value. For example, we want to launch previous function but with in_gb to True as second argument:
>>> calc = Calculation(calc_disk_usage, Params((ParamOption(filename),
... ParamValue('gigabytes'))))
>>> config.option('disk.usage').value.set(calc)
>>> config.option('disk.usage').value.get()
622.6080360412598
#### Is value is valid? #### Is value is valid?
To check is a value is valid: To check is a value is valid:
>>> config.option('disk.path').value.valid() ```python
True await config.option('disk.path').value.valid()
```
#### Display the default value #### Display the default value

View file

@ -471,6 +471,22 @@ from tiramisu import FilenameOption
FilenameOption('file', 'file', '/etc/tiramisu/tiramisu.conf') FilenameOption('file', 'file', '/etc/tiramisu/tiramisu.conf')
``` ```
## Unix file permissions: PermissionsOption
Valid the representing Unix permissions is an octal (base-8) notation.
```python
from tiramisu import PermissionsOption
PermissionsOption('perms', 'perms', 755)
PermissionsOption('perms', 'perms', 1755)
```
This option doesn't allow (or display a warning with warnings_only):
- 777 (two weak value)
- others have more right than group
- group has more right than user
# Date option # Date option
## Date option: DateOption ## Date option: DateOption

View file

@ -436,6 +436,7 @@ async def test_config_od_type(config_type):
o2 = OptionDescription('val', '', [o]) o2 = OptionDescription('val', '', [o])
async with await Config(o2) as cfg: async with await Config(o2) as cfg:
cfg = await get_config(cfg, config_type) cfg = await get_config(cfg, config_type)
assert await cfg.option('val').option.type() == 'optiondescription'
assert await cfg.option('val.i').option.type() == 'integer' assert await cfg.option('val.i').option.type() == 'integer'
assert not await list_sessions() assert not await list_sessions()

View file

@ -881,6 +881,51 @@ async def test_requires_dyndescription_in_dyn():
assert not await list_sessions() assert not await list_sessions()
def calc_value_not_same(param, condition, expected, default, suffix):
if suffix == 'val1':
index = 0
else:
index = 1
return calc_value(param, condition=condition[index], expected=expected, default=default)
@pytest.mark.asyncio
async def test_requires_dyndescription_in_dyn_not_same():
boolean = BoolOption('boolean', '', True)
disabled_property = Calculation(calc_value_not_same,
Params(ParamValue('disabled'),
kwargs={'condition': ParamOption(boolean, raisepropertyerror=True),
'expected': ParamValue(False),
'default': ParamValue(None),
'suffix': ParamSuffix()}))
st = StrOption('st', '', properties=(disabled_property,))
dod1 = DynOptionDescription('dod1', '', [boolean], suffixes=Calculation(return_list))
dod2 = DynOptionDescription('dod2', '', [st], suffixes=Calculation(return_list))
od = OptionDescription('od', '', [dod1, dod2])
od2 = OptionDescription('od', '', [od])
async with await Config(od2) as cfg:
await cfg.property.read_write()
assert await cfg.option('od.dod2val1.stval1').value.get() is None
assert await cfg.option('od.dod2val2.stval2').value.get() is None
#
await cfg.option('od.dod1val1.booleanval1').value.set(False)
props = []
try:
await cfg.option('od.dod2val1.stval1').value.get()
except PropertiesOptionError as err:
props = err.proptype
assert props == frozenset(['disabled'])
props = []
await cfg.option('od.dod2val2.stval2').value.get()
#
await cfg.option('od.dod1val1.booleanval1').value.set(True)
assert await cfg.option('od.dod2val1.stval1').value.get() is None
assert await cfg.option('od.dod2val2.stval2').value.get() is None
assert not await list_sessions()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_requires_dyndescription2(): async def test_requires_dyndescription2():
boolean = BoolOption('boolean', '', True) boolean = BoolOption('boolean', '', True)

View file

@ -1074,3 +1074,16 @@ async def test_follower_properties():
await cfg.option('ip_admin_eth0.netmask_admin_eth0', 0).property.get() == ('aproperty', 'newproperty', 'newproperty1') await cfg.option('ip_admin_eth0.netmask_admin_eth0', 0).property.get() == ('aproperty', 'newproperty', 'newproperty1')
await cfg.option('ip_admin_eth0.netmask_admin_eth0', 1).property.get() == ('aproperty', 'newproperty1') await cfg.option('ip_admin_eth0.netmask_admin_eth0', 1).property.get() == ('aproperty', 'newproperty1')
assert not await list_sessions() assert not await list_sessions()
@pytest.mark.asyncio
async def test_api_get_leader(config_type):
ip_admin_eth0 = StrOption('ip_admin_eth0', "ip réseau autorisé", multi=True)
netmask_admin_eth0 = StrOption('netmask_admin_eth0', "masque du sous-réseau", multi=True)
interface1 = Leadership('ip_admin_eth0', '', [ip_admin_eth0, netmask_admin_eth0])
maconfig = OptionDescription('conf', '', [interface1])
async with await Config(maconfig) as cfg:
option = await cfg.option('ip_admin_eth0.netmask_admin_eth0').option.leader()
assert await option.option.get() == ip_admin_eth0
assert not await list_sessions()

View file

@ -1514,6 +1514,18 @@ async def test_calc_value_remove_duplicate(config_type):
assert not await list_sessions() assert not await list_sessions()
@pytest.mark.asyncio
async def test_calc_value_remove_duplicate2(config_type):
val1 = StrOption('val1', "", ['val1', 'val1'], multi=True, properties=('notunique',))
val2 = StrOption('val2', "", ['val1', 'val1'], multi=True, properties=('notunique',))
val3 = StrOption('val3', "", Calculation(calc_value, Params((ParamOption(val1), ParamOption(val2)), multi=ParamValue(True), remove_duplicate_value=ParamValue(True), join=ParamValue('-'))), multi=True)
od = OptionDescription('root', '', [val1, val2, val3])
async with await Config(od) as cfg:
cfg = await get_config(cfg, config_type)
assert await cfg.value.dict() == {'val1': ['val1', 'val1'], 'val2': ['val1', 'val1'], 'val3': ['val1-val1']}
assert not await list_sessions()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_calc_value_join(config_type): async def test_calc_value_join(config_type):
val1 = StrOption('val1', "", 'val1') val1 = StrOption('val1', "", 'val1')

View file

@ -0,0 +1,36 @@
"configuration objects global API"
from .autopath import do_autopath
do_autopath()
import pytest
from tiramisu import PermissionsOption
def test_permissions():
PermissionsOption('a', '', 640)
PermissionsOption('a', '', 642)
PermissionsOption('a', '', 751)
PermissionsOption('a', '', 753)
PermissionsOption('a', '', 7555)
PermissionsOption('a', '', 1755)
with pytest.raises(ValueError):
PermissionsOption('a', '', 800)
with pytest.raises(ValueError):
PermissionsOption('a', '', 75)
with pytest.raises(ValueError):
PermissionsOption('a', '', 77775)
with pytest.raises(ValueError):
PermissionsOption('a', '', '755')
with pytest.raises(ValueError):
PermissionsOption('a', '', 'string')
with pytest.raises(ValueError):
PermissionsOption('a', '', 800)
with pytest.raises(ValueError):
PermissionsOption('a', '', 1575)
with pytest.raises(ValueError):
PermissionsOption('a', '', 1557)
with pytest.raises(ValueError):
PermissionsOption('a', '', 777)
with pytest.raises(ValueError):
PermissionsOption('a', '', 1777)

View file

@ -407,6 +407,8 @@ class TiramisuOptionOption(_TiramisuOptionOptionDescription):
@option_and_connection @option_and_connection
async def type(self): async def type(self):
if self._option_bag.option.impl_is_optiondescription():
return 'optiondescription'
return self._option_bag.option.get_type() return self._option_bag.option.get_type()
@option_and_connection @option_and_connection
@ -437,6 +439,12 @@ class TiramisuOptionOption(_TiramisuOptionOptionDescription):
self._option_bag.config_bag, self._option_bag.config_bag,
) )
@option_and_connection
async def leader(self):
return TiramisuOption(self._option_bag.option.impl_get_leadership().get_leader().impl_getpath(),
None,
self._option_bag.config_bag)
class TiramisuOptionOwner(CommonTiramisuOption): class TiramisuOptionOwner(CommonTiramisuOption):
#FIXME optiondescription must not have Owner! #FIXME optiondescription must not have Owner!
@ -699,7 +707,8 @@ class TiramisuOptionValue(CommonTiramisuOption):
flatten=False, flatten=False,
withwarning: bool=False, withwarning: bool=False,
fullpath=False, fullpath=False,
leader_to_list=False): leader_to_list=False,
):
"""Dict with path as key and value""" """Dict with path as key and value"""
name = self._option_bag.option.impl_getname() name = self._option_bag.option.impl_getname()
subconfig = await self._subconfig.get_subconfig(self._option_bag) subconfig = await self._subconfig.get_subconfig(self._option_bag)
@ -768,6 +777,7 @@ class TiramisuOptionValue(CommonTiramisuOption):
option = self._option_bag.option option = self._option_bag.option
values = self._option_bag.config_bag.context.cfgimpl_get_values() values = self._option_bag.config_bag.context.cfgimpl_get_values()
if option.impl_is_follower() and self._option_bag.index is None: if option.impl_is_follower() and self._option_bag.index is None:
# IF OU PAS IF ?? if self._option_bag.option.impl_is_symlinkoption():
value = [] value = []
length = await self._subconfig.cfgimpl_get_length_leadership(self._option_bag) length = await self._subconfig.cfgimpl_get_length_leadership(self._option_bag)
settings = self._option_bag.config_bag.context.cfgimpl_get_settings() settings = self._option_bag.config_bag.context.cfgimpl_get_settings()
@ -779,6 +789,7 @@ class TiramisuOptionValue(CommonTiramisuOption):
soption_bag.properties = await settings.getproperties(soption_bag) soption_bag.properties = await settings.getproperties(soption_bag)
value.append(await values.getdefaultvalue(soption_bag)) value.append(await values.getdefaultvalue(soption_bag))
return value return value
# raise APIError('index must be set with a follower option')
else: else:
return await values.getdefaultvalue(self._option_bag) return await values.getdefaultvalue(self._option_bag)

View file

@ -90,6 +90,7 @@ class ParamDynOption(ParamOption):
dynoptiondescription: 'DynOptionDescription', dynoptiondescription: 'DynOptionDescription',
notraisepropertyerror: bool=False, notraisepropertyerror: bool=False,
raisepropertyerror: bool=False, raisepropertyerror: bool=False,
optional: bool=False,
todict: bool=False, todict: bool=False,
) -> None: ) -> None:
super().__init__(option, super().__init__(option,
@ -99,6 +100,7 @@ class ParamDynOption(ParamOption):
) )
self.suffix = suffix self.suffix = suffix
self.dynoptiondescription = dynoptiondescription self.dynoptiondescription = dynoptiondescription
self.optional = optional
class ParamSelfOption(Param): class ParamSelfOption(Param):
@ -273,13 +275,19 @@ async def manager_callback(callbk: Param,
except PropertiesOptionError as err: except PropertiesOptionError as err:
# raise PropertiesOptionError (which is catched) because must not add value None in carry_out_calculation # raise PropertiesOptionError (which is catched) because must not add value None in carry_out_calculation
if callbk.notraisepropertyerror or callbk.raisepropertyerror: if callbk.notraisepropertyerror or callbk.raisepropertyerror:
raise err raise err from err
raise ConfigError(_('unable to carry out a calculation for "{}"' raise ConfigError(_('unable to carry out a calculation for "{}"'
', {}').format(option.impl_get_display_name(), err), err) ', {}').format(option.impl_get_display_name(), err), err) from err
except ValueError as err: except ValueError as err:
raise ValueError(_('the option "{0}" is used in a calculation but is invalid ({1})').format(option_bag.option.impl_get_display_name(), err)) raise ValueError(_('the option "{0}" is used in a calculation but is invalid ({1})').format(option_bag.option.impl_get_display_name(), err)) from err
except AttributeError as err: except AttributeError as err:
raise ConfigError(_(f'unable to get value for calculating "{option_bag.option.impl_get_display_name()}", {err}')) if isinstance(callbk, ParamDynOption) and callbk.optional:
# cannot acces, simulate a propertyerror
raise PropertiesOptionError(option_bag,
['configerror'],
config_bag.context.cfgimpl_get_settings(),
)
raise ConfigError(_(f'unable to get value for calculating "{option_bag.option.impl_get_display_name()}", {err}')) from err
return value return value
async def get_option_bag(config_bag, async def get_option_bag(config_bag,
@ -351,6 +359,7 @@ async def manager_callback(callbk: Param,
callbk_option = callbk.option callbk_option = callbk.option
callbk_options = None callbk_options = None
if callbk_option.issubdyn(): if callbk_option.issubdyn():
found = False
if isinstance(callbk, ParamDynOption): if isinstance(callbk, ParamDynOption):
subdyn = callbk.dynoptiondescription subdyn = callbk.dynoptiondescription
rootpath = subdyn.impl_getpath() + callbk.suffix rootpath = subdyn.impl_getpath() + callbk.suffix
@ -358,7 +367,22 @@ async def manager_callback(callbk: Param,
callbk_option = callbk_option.to_dynoption(rootpath, callbk_option = callbk_option.to_dynoption(rootpath,
suffix, suffix,
subdyn) subdyn)
elif not option.impl_is_dynsymlinkoption(): found = True
elif option.impl_is_dynsymlinkoption():
rootpath = option.rootpath
call_path = callbk_option.impl_getpath()
if call_path.startswith(option.opt.impl_getpath().rsplit('.', 1)[0]):
# in same dynoption
if len(callbk_option.impl_getpath().split('.')) == len(rootpath.split('.')):
rootpath = rootpath.rsplit('.', 1)[0]
suffix = option.impl_getsuffix()
subdyn = callbk_option.getsubdyn()
callbk_option = callbk_option.to_dynoption(rootpath,
suffix,
subdyn,
)
found = True
if not found:
callbk_options = [] callbk_options = []
dynopt = callbk_option.getsubdyn() dynopt = callbk_option.getsubdyn()
rootpath = dynopt.impl_getpath() rootpath = dynopt.impl_getpath()
@ -370,16 +394,6 @@ async def manager_callback(callbk: Param,
suffix, suffix,
dynopt) dynopt)
callbk_options.append(doption) callbk_options.append(doption)
else:
#FIXME in same dynamic option?
rootpath = option.rootpath
if len(callbk_option.impl_getpath().split('.')) == len(rootpath.split('.')):
rootpath = rootpath.rsplit('.', 1)[0]
suffix = option.impl_getsuffix()
subdyn = callbk_option.getsubdyn()
callbk_option = callbk_option.to_dynoption(rootpath,
suffix,
subdyn)
if leadership_must_have_index and callbk_option.impl_is_follower() and index is None: if leadership_must_have_index and callbk_option.impl_is_follower() and index is None:
raise Break() raise Break()
if config_bag is undefined: if config_bag is undefined:

View file

@ -361,7 +361,7 @@ class CalcValue:
value = [] value = []
elif None in value and not allow_none: elif None in value and not allow_none:
value = [] value = []
elif remove_duplicate_value: if remove_duplicate_value:
new_value = [] new_value = []
for val in value: for val in value:
if val not in new_value: if val not in new_value:

View file

@ -24,6 +24,7 @@ from .dateoption import DateOption
from .filenameoption import FilenameOption from .filenameoption import FilenameOption
from .passwordoption import PasswordOption from .passwordoption import PasswordOption
from .macoption import MACOption from .macoption import MACOption
from .permissionsoption import PermissionsOption
__all__ = ('Leadership', 'OptionDescription', 'DynOptionDescription', __all__ = ('Leadership', 'OptionDescription', 'DynOptionDescription',
@ -33,4 +34,4 @@ __all__ = ('Leadership', 'OptionDescription', 'DynOptionDescription',
'IPOption', 'PortOption', 'NetworkOption', 'NetmaskOption', 'IPOption', 'PortOption', 'NetworkOption', 'NetmaskOption',
'BroadcastOption', 'DomainnameOption', 'EmailOption', 'URLOption', 'BroadcastOption', 'DomainnameOption', 'EmailOption', 'URLOption',
'UsernameOption', 'GroupnameOption', 'FilenameOption', 'PasswordOption', 'submulti', 'UsernameOption', 'GroupnameOption', 'FilenameOption', 'PasswordOption', 'submulti',
'RegexpOption', 'MACOption') 'RegexpOption', 'MACOption', 'PermissionsOption')

View file

@ -74,12 +74,10 @@ class DomainnameOption(StrOption):
else: else:
min_time = 1 min_time = 1
regexp = r'((?!-)[a-z0-9-]{{{1},{0}}}\.){{{1},}}[a-z0-9-]{{1,{0}}}'.format(self._get_len(type), min_time) regexp = r'((?!-)[a-z0-9-]{{{1},{0}}}\.){{{1},}}[a-z0-9-]{{1,{0}}}'.format(self._get_len(type), min_time)
msg = _('only lowercase, number, "-" and "." characters are allowed')
msg_warning = _('only lowercase, number, "-" and "." characters are recommanded')
else: else:
regexp = r'((?!-)[a-z0-9-]{{1,{0}}})'.format(self._get_len(type)) regexp = r'((?!-)[a-z0-9-]{{1,{0}}})'.format(self._get_len(type))
msg = _('only lowercase, number and "-" characters are allowed') msg = _('must start with lowercase characters followed by lowercase characters, number, "-" and "." characters are allowed')
msg_warning = _('only lowercase, number and "-" characters are recommanded') msg_warning = _('must start with lowercase characters followed by lowercase characters, number, "-" and "." characters are recommanded')
if allow_ip: if allow_ip:
msg = _('could be a IP, otherwise {}').format(msg) msg = _('could be a IP, otherwise {}').format(msg)
msg_warning = _('could be a IP, otherwise {}').format(msg_warning) msg_warning = _('could be a IP, otherwise {}').format(msg_warning)

View file

@ -0,0 +1,75 @@
# -*- coding: utf-8 -*-
# Copyright (C) 2023 Team tiramisu (see AUTHORS for all contributors)
#
# This program is free software: you can redistribute it and/or modify it
# under the terms of the GNU Lesser General Public License as published by the
# Free Software Foundation, either version 3 of the License, or (at your
# option) any later version.
#
# This program is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more
# details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
# The original `Config` design model is unproudly borrowed from
# the rough pypy's guys: http://codespeak.net/svn/pypy/dist/pypy/config/
# the whole pypy projet is under MIT licence
# ____________________________________________________________
import re
import sys
from ..setting import undefined, Undefined, OptionBag
from ..i18n import _
from .option import Option
from .intoption import IntOption
class PermissionsOption(IntOption):
"""Unix file permissions
Valid the representing Unix permissions is an octal (base-8) notation.
This notation consists of at least three digits (owner, group, and others).
If a fourth digit is present to the setuid bit, the setgid bit and the sticky bit attributes.
This option is an integer value.
"""
__slots__ = tuple()
perm_re = re.compile(r"^[0-7]{3,4}$")
_type = 'permissions'
_display_name = _('unix file permissions')
def __init__(self,
*args,
**kwargs,
) -> None:
#do not display intoption attributs
super().__init__(*args,
**kwargs)
def validate(self,
value: str) -> None:
super().validate(value)
if not self.perm_re.search(str(value)):
raise ValueError(_('only 3 or 4 octal digits are allowed'))
def second_level_validation(self,
value: str,
warnings_only: bool) -> None:
old_digit = 7
str_value = str(value)
if len(str_value) == 4:
str_value = str_value[1:]
for idx, digit in enumerate(str_value):
new_digit = int(digit)
if old_digit < new_digit:
if idx == 1:
old = _('user')
new = _('group')
else:
old = _('group')
new = _('other')
raise ValueError(_(f'{new} has more right than {old}'))
old_digit = new_digit
if str_value == '777':
raise ValueError(_(f'too weak'))

View file

@ -19,11 +19,8 @@
# the whole pypy projet is under MIT licence # the whole pypy projet is under MIT licence
# ____________________________________________________________ # ____________________________________________________________
import re import re
import sys
from ..setting import undefined, Undefined, OptionBag
from ..i18n import _ from ..i18n import _
from .option import Option
from .stroption import StrOption from .stroption import StrOption

View file

@ -117,6 +117,7 @@ FORBIDDEN_SET_PERMISSIVES = frozenset(['force_default_on_freeze',
'force_metaconfig_on_freeze', 'force_metaconfig_on_freeze',
'force_store_value']) 'force_store_value'])
ALLOWED_LEADER_PROPERTIES = frozenset(['empty', ALLOWED_LEADER_PROPERTIES = frozenset(['empty',
'notempty',
'notunique', 'notunique',
'unique', 'unique',
'force_store_value', 'force_store_value',

View file

@ -175,7 +175,11 @@ class Values:
value, value,
reset_cache=True): reset_cache=True):
if isinstance(value, Calculation): if isinstance(value, Calculation):
try:
value = await value.execute(option_bag) value = await value.execute(option_bag)
except ConfigError as err:
msg = _(f'error when calculating "{option_bag.option.impl_get_display_name()}": {err} : {option_bag.path}')
raise ConfigError(msg) from err
elif isinstance(value, (list, tuple)): elif isinstance(value, (list, tuple)):
value = await self._do_value_list(value, option_bag) value = await self._do_value_list(value, option_bag)
if reset_cache: if reset_cache: