Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,4 @@ mutants
CLAUDE.md
AGENTS.md
.qwen
coverage.xml
96 changes: 79 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ Keep all your project's settings in one place. Ensure type safety, thread safety
## Table of contents

- [**Quick start**](#quick-start)
- [**Classes and their fields**](#classes-and-their-fields)
- [**Default values**](#default-values)
- [**Documenting fields**](#documenting-fields)
- [**Secret fields**](#secret-fields)
Expand Down Expand Up @@ -53,13 +54,13 @@ pip install skelet

You can also quickly try this package and others without installing them via [instld](https://github.com/pomponchik/instld).

Now let's create our first storage class. To do this, we need to inherit from the base class `Storage` and define fields using `Field`:
Now let's create our first [storage class](#classes-and-their-fields). To do this, we need to inherit from the base class `Storage` and define fields as class attributes. Use `Field` only when a field needs additional settings:

```python
from skelet import Storage, Field, NonNegativeInt

class ManDescription(Storage):
name: str = Field()
name: str
age: NonNegativeInt = Field(validation={'You must be 18 or older to feel important': lambda x: x >= 18})
```

Expand Down Expand Up @@ -87,11 +88,47 @@ description.name = 3.14
That is already useful, but the rest of this guide covers more advanced features.


## Classes and their fields

The main "player" in `skelet` is the `Storage` class, which you must inherit from while adding attributes. Attributes can have values or be value-less, and they can include type hints or be without them:

```python
class SoccerTeam(Storage):
name: str
number_of_players: int = 11
```

> ⚠️ An attribute name cannot begin with an underscore (_). Doing so will result in an exception being raised.

In most cases, a simple field form, as shown in the last example, will suffice. However, sometimes you need to further configure a field to give it "capabilities" such as automatic [validation of individual values](#validation-of-values) or checking adjacent fields for conflicts. To do this, instead of a "raw" value, you need to assign a value of type `Field`:

```python
...
number_of_players: int = Field(11, validation={'A team must have at least 7 players.': lambda x: x > 6, 'A team cannot have more than 11 players.': lambda x: x < 12})
```

`Field` also has a shortened alias, `F`:

```python
from skelet import F

...
number_of_players: int = F(...)
```


## Default values

A default value is used when no other source provides one. It will be used until you override it.

You do not have to define a default value, but in this case you need to pass the value when creating the storage object. If you do set a default value, there are two ways to do this:
You do not have to define a default value, but in this case you need to pass the value when creating the storage object:

```python
class UnremarkableSettingsStorage(Storage):
required_field: str
```

If you do set a default value, there are two ways to do this:

- **Ordinary**.
- **Lazy** (deferred).
Expand All @@ -100,12 +137,19 @@ You can already see examples of ordinary default values above. Here's another on

```python
class UnremarkableSettingsStorage(Storage):
ordinary_field: str = Field('I am the ordinary default value!')
ordinary_field: str = 'I am the ordinary default value!'

print(UnremarkableSettingsStorage())
#> UnremarkableSettingsStorage(ordinary_field='I am the ordinary default value!')
```

`None` is also an ordinary default value when you write it explicitly:

```python
class UnremarkableSettingsStorage(Storage):
optional_field: str | None = None
```

You can also pass a factory function via `default_factory` — it will be called each time a new object is created:

```python
Expand All @@ -118,14 +162,30 @@ print(UnremarkableSettingsStorage())

Use this option when the default value is mutable, such as a `list` or `dict`. A new object will be created for this field every time a new storage object is created, so the same mutable object will not be shared between instances.

If you write a public class attribute without a type hint, it is still a field, but runtime type checking is disabled for it:

```python
class UnremarkableSettingsStorage(Storage):
ordinary_field = 'I am a field without runtime type checking.'
```

Use `ClassVar` for public class-level constants that should not become fields:

```python
from typing import ClassVar

class UnremarkableSettingsStorage(Storage):
tool_name: ClassVar[str] = 'my-tool'
```


## Documenting fields

You might be tempted to document a field with a comment:

```python
class TheSecretFormula(Storage):
the_secret_ingredient: str = Field() # frogs' paws or something else nasty
the_secret_ingredient: str # frogs' paws or something else nasty
...
```

Expand All @@ -152,14 +212,14 @@ Some field values should not appear in logs or string representations. Secret fi

```python
class TopStateSecrets(Storage):
who_killed_kennedy: str = Field('aliens', validation=lambda x: x != 'russians', secret=True)
red_buttons_password: str = Field('1234', secret=True)
who_killed_kennedy: str = Field('aliens', validation=lambda x: x != 'russians', hide=True)
red_buttons_password: str = Field('1234', hide=True)

print(TopStateSecrets())
#> TopStateSecrets(who_killed_kennedy=***, red_buttons_password=***)
```

If you mark a field with the `secret` flag, as in this example, its contents will be hidden in string representations and exception messages:
If you mark a field with the `hide` flag, as in this example, its contents will be hidden in string representations and exception messages:

```python
secrets = TopStateSecrets()
Expand All @@ -168,7 +228,7 @@ secrets.who_killed_kennedy = 'russians'
#> ValueError: The value *** (str) of the "who_killed_kennedy" field does not match the validation.
```

In all other respects, "secret" fields behave the same as regular ones, you can read values and write new ones.
In all other respects, hidden fields behave the same as regular ones, you can read values and write new ones.


## Type checking
Expand All @@ -177,8 +237,8 @@ Type hints are optional. When specified, all values are checked against the hint

```python
class HumanMeasurements(Storage):
number_of_legs: int = Field(2)
number_of_hands: int = Field(2)
number_of_legs: int = 2
number_of_hands: int = 2

measurements = HumanMeasurements()

Expand All @@ -195,6 +255,8 @@ The library supports only a runtime-checkable subset of typing constructs. Check

The library deliberately does not attempt to implement full runtime type checking. If you need more powerful verification, it's better to rely on static tools like `mypy`.

Runtime type checking depends on type hints. For example, `field = 'abc'` may be treated as a `str` by static type checkers, but at runtime `skelet` will accept any value for this field because no type hint was provided.

The library also supports two additional types that allow you to narrow down the behavior of the basic int type:

- `NaturalNumber` — as the name implies, only objects of type `int` greater than zero will be checked for this type.
Expand Down Expand Up @@ -265,7 +327,7 @@ Sometimes, individual field values are [acceptable](#validation-of-values), but

```python
class Dossier(Storage):
name: str = Field()
name: str
is_jew: bool | None = Field(None, doc='Jews do not eat pork')
eats_pork: bool | None = Field(
None,
Expand Down Expand Up @@ -416,7 +478,7 @@ Read more about the available types of sources below.
from skelet import EnvSource

class MyClass(Storage, sources=[EnvSource()]):
some_field = Field('some_value')
some_field: str = 'some_value'
```

By default, environment variables are searched for by key in the form of an attribute name, but the case is ignored. If you want to make the search case-sensitive, pass `True` as the `case_sensitive` parameter:
Expand Down Expand Up @@ -521,9 +583,9 @@ class MyClass(Storage, sources=[
positional_arguments=['third_field'],
),
]):
first_field: str = Field('default')
second_field: str = Field('default')
third_field: str = Field('default')
first_field: str = 'default'
second_field: str = 'default'
third_field: str = 'default'
```

Now we can run our script, and the arguments will automatically populate the corresponding fields of our class:
Expand Down Expand Up @@ -662,7 +724,7 @@ You can use `asdict()` to convert a storage object to a standard Python dictiona
from skelet import asdict

class FlyingConfig(Storage):
some_field: int = Field(42)
some_field: int = 42

data = asdict(FlyingConfig())
print(data)
Expand Down
4 changes: 3 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "skelet"
version = "0.0.20"
version = "0.0.21"
authors = [{ name = "Evgeniy Blinov", email = "zheni-b@yandex.ru" }]
description = 'Collect all the settings in one place'
readme = "README.md"
Expand All @@ -15,6 +15,7 @@ dependencies = [
'simtypes>=0.0.13',
'denial>=0.0.13',
'sigmatch>=0.0.10',
'pristan>=0.0.15',
'tomli==2.3.0 ; python_version <= "3.12"',
'pyyaml==6.0.3',
'types-pyyaml==6.0.12.20241230',
Expand All @@ -39,6 +40,7 @@ classifiers = [
'License :: OSI Approved :: MIT License',
'Intended Audience :: Developers',
'Topic :: Software Development :: Libraries',
'Typing :: Typed',
]
keywords = ['settings', 'configs']

Expand Down
2 changes: 2 additions & 0 deletions skelet/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,5 @@
from skelet.sources.yaml import YAMLSource as YAMLSource
from skelet.storage import Storage as Storage
from skelet.sources.getter_for_libraries import for_tool as for_tool

F = Field
16 changes: 8 additions & 8 deletions skelet/fields/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ def __init__( # noqa: PLR0913
read_only: bool = False,
validation: Optional[Union[Dict[str, Callable[[ValueType], bool]], Callable[[ValueType], bool]]] = None,
validate_default: bool = True,
secret: bool = False,
hide: bool = False,
action: Optional[ChangeAction[ValueType, StorageType]] = None,
read_lock: bool = False,
conflicts: Optional[Dict[str, Callable[[ValueType, ValueType, Any, Any], bool]]] = None,
Expand Down Expand Up @@ -95,7 +95,7 @@ def __init__( # noqa: PLR0913
self.sources = sources
self.validation = validation
self.validate_default = validate_default
self.secret = secret
self.hide = hide
self.change_action: Optional[ChangeAction[ValueType, StorageType]] = action
self.conflicts = conflicts
self.reverse_conflicts_on = reverse_conflicts
Expand Down Expand Up @@ -205,7 +205,7 @@ def set_field_names(self, owner: Type[Storage], name: str) -> None:
continue
if parent is Storage:
break
for field_name in cast(Storage, parent).__field_names__:
for field_name in getattr(parent, '__field_names__', ()):
if field_name not in known_names:
known_names.add(field_name)
owner.__field_names__.append(field_name)
Expand Down Expand Up @@ -273,7 +273,7 @@ def get_field_lock(self, instance: Storage) -> ContextLockProtocol:
return instance.__locks__[cast(str, self.name)]

def get_value_representation(self, value: ValueType) -> str:
base = '***' if self.secret else f'{value!r}'
base = '***' if self.hide else f'{value!r}'
return f'{base} ({type(value).__name__})'

def raise_exception_in_storage(self, exception: BaseException, raising_on: bool) -> None:
Expand Down Expand Up @@ -338,7 +338,7 @@ def Field(
read_only: bool = False,
validation: Optional[Union[Dict[str, Callable[[ValueType], bool]], Callable[[ValueType], bool]]] = None,
validate_default: bool = True,
secret: bool = False,
hide: bool = False,
action: Optional[ChangeAction[ValueType, StorageType]] = None,
read_lock: bool = False,
conflicts: Optional[Dict[str, Callable[[ValueType, ValueType, Any, Any], bool]]] = None,
Expand All @@ -360,7 +360,7 @@ def Field(
read_only: bool = False,
validation: Optional[Union[Dict[str, Callable[[ValueType], bool]], Callable[[ValueType], bool]]] = None,
validate_default: bool = True,
secret: bool = False,
hide: bool = False,
action: Optional[ChangeAction[ValueType, StorageType]] = None,
read_lock: bool = False,
conflicts: Optional[Dict[str, Callable[[ValueType, ValueType, Any, Any], bool]]] = None,
Expand All @@ -382,7 +382,7 @@ def Field( # noqa: PLR0913, N802
read_only: bool = False,
validation: Optional[Union[Dict[str, Callable[[Any], bool]], Callable[[Any], bool]]] = None,
validate_default: bool = True,
secret: bool = False,
hide: bool = False,
action: Optional[ChangeAction[Any, StorageType]] = None,
read_lock: bool = False,
conflicts: Optional[Dict[str, Callable[[Any, Any, Any, Any], bool]]] = None,
Expand All @@ -399,7 +399,7 @@ def Field( # noqa: PLR0913, N802
read_only=read_only,
validation=validation,
validate_default=validate_default,
secret=secret,
hide=hide,
action=action,
read_lock=read_lock,
conflicts=conflicts,
Expand Down
67 changes: 64 additions & 3 deletions skelet/sources/getter_for_libraries.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,69 @@
from typing import List

from skelet import EnvSource, JSONSource, TOMLSource, YAMLSource
from pristan import slot

from skelet.sources.abstract import AbstractSource, ExpectedType
from skelet.sources.env import EnvSource
from skelet.sources.json import JSONSource
from skelet.sources.toml import TOMLSource
from skelet.sources.yaml import YAMLSource


@slot(entrypoint_group='skelet')
def for_tool(tool_name: str) -> List[AbstractSource[ExpectedType]]: # noqa: ARG001
return [] # pragma: no cover


def validate_tool_name(tool_name: str) -> str:
if not tool_name.isidentifier():
raise ValueError('The library name can only be a valid Python identifier.')

return tool_name


@for_tool.plugin
def env(tool_name: str) -> EnvSource[ExpectedType]:
tool_name = validate_tool_name(tool_name)
return EnvSource(prefix=f'{tool_name}_'.upper())


@for_tool.plugin
def toml(tool_name: str) -> TOMLSource[ExpectedType]:
tool_name = validate_tool_name(tool_name)
return TOMLSource(f'{tool_name}.toml')


@for_tool.plugin
def hidden_toml(tool_name: str) -> TOMLSource[ExpectedType]:
tool_name = validate_tool_name(tool_name)
return TOMLSource(f'.{tool_name}.toml')


@for_tool.plugin
def pyproject_toml(tool_name: str) -> TOMLSource[ExpectedType]:
tool_name = validate_tool_name(tool_name)
return TOMLSource('pyproject.toml', table=f'tool.{tool_name}')


@for_tool.plugin
def yaml(tool_name: str) -> YAMLSource[ExpectedType]:
tool_name = validate_tool_name(tool_name)
return YAMLSource(f'{tool_name}.yaml')


@for_tool.plugin
def hidden_yaml(tool_name: str) -> YAMLSource[ExpectedType]:
tool_name = validate_tool_name(tool_name)
return YAMLSource(f'.{tool_name}.yaml')


@for_tool.plugin
def json(tool_name: str) -> JSONSource[ExpectedType]:
tool_name = validate_tool_name(tool_name)
return JSONSource(f'{tool_name}.json')


def for_tool(tool_name: str) -> List[AbstractSource[ExpectedType]]:
return EnvSource.for_library(tool_name) + TOMLSource.for_library(tool_name) + YAMLSource.for_library(tool_name) + JSONSource.for_library(tool_name) # type: ignore[return-value, operator]
@for_tool.plugin
def hidden_json(tool_name: str) -> JSONSource[ExpectedType]:
tool_name = validate_tool_name(tool_name)
return JSONSource(f'.{tool_name}.json')
Loading
Loading