Skip to content

Commit 61955d4

Browse files
committed
Allow configuration to be marked immutable
1 parent 51bf38e commit 61955d4

File tree

25 files changed

+336
-14
lines changed

25 files changed

+336
-14
lines changed

betty/app/__init__.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010

1111
import aiohttp
1212
from aiofiles.tempfile import TemporaryDirectory
13+
from typing_extensions import override
14+
1315
from betty import fs
1416
from betty.app import config
1517
from betty.app.config import AppConfiguration
@@ -30,7 +32,6 @@
3032
from betty.plugin.proxy import ProxyPluginRepository
3133
from betty.service import ServiceProvider, service, ServiceFactory, StaticService
3234
from betty.typing import processsafe
33-
from typing_extensions import override
3435

3536
if TYPE_CHECKING:
3637
from concurrent import futures

betty/app/config.py

+4
Original file line numberDiff line numberDiff line change
@@ -49,14 +49,18 @@ def locale(self) -> str | None:
4949

5050
@locale.setter
5151
def locale(self, locale: str) -> None:
52+
self.assert_mutable()
5253
self._locale = assert_locale()(locale)
5354

5455
@override
5556
def load(self, dump: Dump) -> None:
57+
self.assert_mutable()
5658
assert_record(
5759
OptionalField("locale", assert_str() | assert_setattr(self, "locale"))
5860
)(dump)
5961

6062
@override
6163
def dump(self) -> DumpMapping[Dump]:
64+
if self.locale is None:
65+
return {}
6266
return {"locale": self.locale}

betty/cli/commands/config.py

+5-2
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from typing_extensions import override
88

99
from betty.app import config as app_config
10+
from betty.app.config import AppConfiguration
1011
from betty.app.factory import AppDependentFactory
1112
from betty.cli.commands import command, Command
1213
from betty.config import write_configuration_file
@@ -55,7 +56,9 @@ async def click_command(self) -> click.Command:
5556
)
5657
async def config(*, locale: str) -> None:
5758
logger = getLogger(__name__)
58-
self._app.configuration.locale = locale
59+
updated_configuration = AppConfiguration()
60+
updated_configuration.update(self._app.configuration)
61+
updated_configuration.locale = locale
5962
new_localizer = await self._app.localizers.get(locale)
6063
logger.info(
6164
new_localizer._("Betty will talk to you in {locale}").format(
@@ -64,7 +67,7 @@ async def config(*, locale: str) -> None:
6467
)
6568

6669
await write_configuration_file(
67-
self._app.configuration, app_config.CONFIGURATION_FILE_PATH
70+
updated_configuration, app_config.CONFIGURATION_FILE_PATH
6871
)
6972

7073
return config

betty/config/__init__.py

+3-1
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from betty.assertion import AssertionChain, assert_file_path
1616
from betty.assertion.error import AssertionFailedGroup
1717
from betty.locale.localizable import plain
18+
from betty.mutability import Mutable
1819
from betty.serde.dump import Dumpable
1920
from betty.serde.format import FORMAT_REPOSITORY, format_for
2021
from betty.serde.load import Loadable
@@ -27,7 +28,7 @@
2728
ConfigurationListener: TypeAlias = "Configuration | _ConfigurationListener"
2829

2930

30-
class Configuration(Loadable, Dumpable):
31+
class Configuration(Loadable, Dumpable, Mutable):
3132
"""
3233
Any configuration object.
3334
"""
@@ -36,6 +37,7 @@ def update(self, other: Self) -> None:
3637
"""
3738
Update this configuration with the values from ``other``.
3839
"""
40+
self.assert_mutable()
3941
self.load(other.dump())
4042

4143

betty/config/collections/__init__.py

+6-1
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,9 @@
2626
from betty.repr import repr_instance
2727

2828
if TYPE_CHECKING:
29+
from betty.mutability import Mutable
2930
from betty.serde.dump import Dump
3031

31-
3232
_ConfigurationT = TypeVar("_ConfigurationT", bound=Configuration)
3333
ConfigurationKey: TypeAlias = SupportsIndex | Hashable | type[Any]
3434
_ConfigurationKeyT = TypeVar("_ConfigurationKeyT", bound=ConfigurationKey)
@@ -86,6 +86,7 @@ def remove(self, *configuration_keys: _ConfigurationKeyT) -> None:
8686
"""
8787
Remove the given keys from the collection.
8888
"""
89+
self.assert_mutable()
8990
for configuration_key in configuration_keys:
9091
try:
9192
configuration = self._configurations[configuration_key] # type: ignore[call-overload]
@@ -150,3 +151,7 @@ def insert(self, index: int, *configurations: _ConfigurationT) -> None:
150151
Insert the given values at the given index.
151152
"""
152153
pass
154+
155+
@override
156+
def get_mutable_instances(self) -> Iterable[Mutable]:
157+
return self.values()

betty/config/collections/mapping.py

+6
Original file line numberDiff line numberDiff line change
@@ -60,15 +60,18 @@ def values(self) -> Iterator[_ConfigurationT]:
6060

6161
@override
6262
def replace(self, *configurations: _ConfigurationT) -> None:
63+
self.assert_mutable()
6364
self.clear()
6465
self.append(*configurations)
6566

6667
@override
6768
def prepend(self, *configurations: _ConfigurationT) -> None:
69+
self.assert_mutable()
6870
self.insert(0, *configurations)
6971

7072
@override
7173
def append(self, *configurations: _ConfigurationT) -> None:
74+
self.assert_mutable()
7275
for configuration in configurations:
7376
configuration_key = self._get_key(configuration)
7477
with suppress(KeyError):
@@ -77,6 +80,7 @@ def append(self, *configurations: _ConfigurationT) -> None:
7780

7881
@override
7982
def insert(self, index: int, *configurations: _ConfigurationT) -> None:
83+
self.assert_mutable()
8084
self.remove(*map(self._get_key, configurations))
8185
existing_configurations = list(self.values())
8286
self._configurations = {
@@ -117,6 +121,7 @@ def __load_item_key(self, value_dump: DumpMapping[Dump], key_dump: str) -> Dump:
117121

118122
@override
119123
def load(self, dump: Dump) -> None:
124+
self.assert_mutable()
120125
self.clear()
121126
self.replace(
122127
*assert_mapping(self._load_item)(
@@ -151,6 +156,7 @@ class OrderedConfigurationMapping(
151156

152157
@override
153158
def load(self, dump: Dump) -> None:
159+
self.assert_mutable()
154160
self.replace(*assert_sequence(self._load_item)(dump))
155161

156162
@override

betty/config/collections/sequence.py

+5
Original file line numberDiff line numberDiff line change
@@ -76,11 +76,13 @@ def values(self) -> Iterator[_ConfigurationT]:
7676

7777
@override
7878
def replace(self, *configurations: _ConfigurationT) -> None:
79+
self.assert_mutable()
7980
self.clear()
8081
self.append(*configurations)
8182

8283
@override
8384
def load(self, dump: Dump) -> None:
85+
self.assert_mutable()
8486
self.replace(*assert_sequence(self._load_item)(dump))
8587

8688
@override
@@ -89,18 +91,21 @@ def dump(self) -> DumpSequence[Dump]:
8991

9092
@override
9193
def prepend(self, *configurations: _ConfigurationT) -> None:
94+
self.assert_mutable()
9295
for configuration in configurations:
9396
self._pre_add(configuration)
9497
self._configurations.insert(0, configuration)
9598

9699
@override
97100
def append(self, *configurations: _ConfigurationT) -> None:
101+
self.assert_mutable()
98102
for configuration in configurations:
99103
self._pre_add(configuration)
100104
self._configurations.append(configuration)
101105

102106
@override
103107
def insert(self, index: int, *configurations: _ConfigurationT) -> None:
108+
self.assert_mutable()
104109
for configuration in reversed(configurations):
105110
self._pre_add(configuration)
106111
self._configurations.insert(index, configuration)

betty/mutability.py

+122
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
"""
2+
The mutability API.
3+
4+
This provides tools to mark objects as mutable or immutable, and to guard against mutations.
5+
"""
6+
7+
from __future__ import annotations
8+
9+
from typing import Any, TYPE_CHECKING
10+
11+
from betty.typing import internal
12+
13+
if TYPE_CHECKING:
14+
from collections.abc import Iterable
15+
16+
17+
class MutabilityError(Exception):
18+
"""
19+
A generic mutability API error.
20+
"""
21+
22+
pass
23+
24+
25+
@internal
26+
class MutableError(MutabilityError, RuntimeError):
27+
"""
28+
An error raised because something was unexpectedly mutable.
29+
"""
30+
31+
pass
32+
33+
34+
@internal
35+
class ImmutableError(MutabilityError, RuntimeError):
36+
"""
37+
An error raised because something was unexpectedly immutable.
38+
"""
39+
40+
pass
41+
42+
43+
class Mutable:
44+
"""
45+
A generic mutable type that can be marked immutable.
46+
"""
47+
48+
def __init__(self, *args: Any, mutable: bool = True, **kwargs: Any):
49+
super().__init__(*args, **kwargs)
50+
self._mutable = mutable
51+
52+
def get_mutable_instances(self) -> Iterable[Mutable]:
53+
"""
54+
Get any other :py:class:`betty.mutability.Mutable` instances contained by this one.
55+
"""
56+
return ()
57+
58+
@property
59+
def is_mutable(self) -> bool:
60+
"""
61+
Whether the instance is mutable.
62+
"""
63+
return self._mutable
64+
65+
def mutable(self) -> None:
66+
"""
67+
Mark the instance mutable.
68+
"""
69+
self._mutable = True
70+
for instance in self.get_mutable_instances():
71+
instance.mutable()
72+
73+
@property
74+
def is_immutable(self) -> bool:
75+
"""
76+
Whether the instance is immutable.
77+
"""
78+
return not self._mutable
79+
80+
def immutable(self) -> None:
81+
"""
82+
Mark the instance immutable.
83+
"""
84+
self._mutable = False
85+
for instance in self.get_mutable_instances():
86+
instance.immutable()
87+
88+
def assert_mutable(self) -> None:
89+
"""
90+
Assert that the instance is mutable.
91+
92+
:raise ImmutableError: if the instance is immutable.
93+
"""
94+
if not self._mutable:
95+
raise ImmutableError(
96+
f"{self} was unexpectedly immutable, and cannot be modified."
97+
)
98+
99+
def assert_immutable(self) -> None:
100+
"""
101+
Assert that the instance is immutable.
102+
103+
:raise MutableError: if the instance is mutable.
104+
"""
105+
if self._mutable:
106+
raise MutableError(f"{self} was unexpectedly mutable, and can be modified.")
107+
108+
109+
def mutable(*instances: Mutable) -> None:
110+
"""
111+
Mark the given instances mutable.
112+
"""
113+
for instance in instances:
114+
instance.mutable()
115+
116+
117+
def immutable(*instances: Mutable) -> None:
118+
"""
119+
Mark the given instances immutable.
120+
"""
121+
for instance in instances:
122+
instance.immutable()

betty/plugin/config.py

+2
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ def id(self) -> str:
9393

9494
@override
9595
def load(self, dump: Dump) -> None:
96+
self.assert_mutable()
9697
assert_record(
9798
RequiredField("id", assert_machine_name() | assert_setattr(self, "_id")),
9899
RequiredField("label", self.label.load),
@@ -235,6 +236,7 @@ async def new_plugin_instance(
235236

236237
@override
237238
def load(self, dump: Dump) -> None:
239+
self.assert_mutable()
238240
id_assertion = assert_machine_name() | assert_setattr(self, "_id")
239241
assert_or(
240242
id_assertion,

betty/project/config.py

+8
Original file line numberDiff line numberDiff line change
@@ -709,6 +709,7 @@ def configuration_file_path(self) -> Path:
709709

710710
@configuration_file_path.setter
711711
def configuration_file_path(self, configuration_file_path: Path) -> None:
712+
self.assert_mutable()
712713
if configuration_file_path == self._configuration_file_path:
713714
return
714715
format_for(self._available_formats, configuration_file_path.suffix)
@@ -723,6 +724,7 @@ def name(self) -> MachineName | None:
723724

724725
@name.setter
725726
def name(self, name: MachineName) -> None:
727+
self.assert_mutable()
726728
self._name = assert_machine_name()(name)
727729

728730
@property
@@ -773,6 +775,7 @@ def url(self) -> str:
773775

774776
@url.setter
775777
def url(self, url: str) -> None:
778+
self.assert_mutable()
776779
url_parts = urlparse(url)
777780
if not url_parts.scheme:
778781
raise AssertionFailed(
@@ -815,6 +818,7 @@ def clean_urls(self) -> bool:
815818

816819
@clean_urls.setter
817820
def clean_urls(self, clean_urls: bool) -> None:
821+
self.assert_mutable()
818822
self._clean_urls = clean_urls
819823

820824
@property
@@ -854,6 +858,7 @@ def debug(self) -> bool:
854858

855859
@debug.setter
856860
def debug(self, debug: bool) -> None:
861+
self.assert_mutable()
857862
self._debug = debug
858863

859864
@property
@@ -870,6 +875,7 @@ def lifetime_threshold(self) -> int:
870875

871876
@lifetime_threshold.setter
872877
def lifetime_threshold(self, lifetime_threshold: int) -> None:
878+
self.assert_mutable()
873879
assert_positive_number()(lifetime_threshold)
874880
self._lifetime_threshold = lifetime_threshold
875881

@@ -882,6 +888,7 @@ def logo(self) -> Path | None:
882888

883889
@logo.setter
884890
def logo(self, logo: Path | None) -> None:
891+
self.assert_mutable()
885892
self._logo = logo
886893

887894
@property
@@ -936,6 +943,7 @@ def genders(
936943

937944
@override
938945
def load(self, dump: Dump) -> None:
946+
self.assert_mutable()
939947
assert_record(
940948
OptionalField(
941949
"name",

0 commit comments

Comments
 (0)