experimaestro 1.8.0rc5__py3-none-any.whl → 1.8.0rc7__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of experimaestro might be problematic. Click here for more details.

@@ -0,0 +1,58 @@
1
+ from contextlib import contextmanager
2
+ from typing import Any, Dict, Set
3
+
4
+
5
+ def getqualattr(module, qualname):
6
+ """Get a qualified attributed value"""
7
+ cls = module
8
+ for part in qualname.split("."):
9
+ cls = getattr(cls, part)
10
+ return cls
11
+
12
+
13
+ @contextmanager
14
+ def add_to_path(p):
15
+ """Temporarily add a path to sys.path"""
16
+ import sys
17
+
18
+ old_path = sys.path
19
+ sys.path = sys.path[:]
20
+ sys.path.insert(0, p)
21
+ try:
22
+ yield
23
+ finally:
24
+ sys.path = old_path
25
+
26
+
27
+ class ObjectStore:
28
+ def __init__(self):
29
+ self.store: Dict[int, Any] = {}
30
+ self.constructed: Set[int] = set()
31
+
32
+ def set_constructed(self, identifier: int):
33
+ self.constructed.add(identifier)
34
+
35
+ def is_constructed(self, identifier: int):
36
+ return identifier in self.constructed
37
+
38
+ def retrieve(self, identifier: int):
39
+ return self.store.get(identifier, None)
40
+
41
+ def add_stub(self, identifier: int, stub: Any):
42
+ self.store[identifier] = stub
43
+
44
+
45
+ class SealedError(Exception):
46
+ """Exception when trying to modify a sealed configuration"""
47
+
48
+ pass
49
+
50
+
51
+ class TaggedValue:
52
+ def __init__(self, value):
53
+ self.value = value
54
+
55
+
56
+ class classproperty(property):
57
+ def __get__(self, owner_self, owner_cls):
58
+ return self.fget(owner_cls)
@@ -0,0 +1,150 @@
1
+ from pathlib import Path
2
+ from enum import Enum
3
+ from typing import (
4
+ Any,
5
+ Dict,
6
+ Tuple,
7
+ )
8
+ from contextlib import contextmanager
9
+
10
+
11
+ class ConfigWalkContext:
12
+ """Context when generating values in configurations"""
13
+
14
+ @property
15
+ def path(self):
16
+ """Returns the path of the job directory"""
17
+ raise NotImplementedError()
18
+
19
+ def __init__(self):
20
+ self._configpath = None
21
+
22
+ @property
23
+ def task(self):
24
+ return None
25
+
26
+ def currentpath(self) -> Path:
27
+ """Returns the configuration folder"""
28
+ if self._configpath:
29
+ return self.path / self._configpath
30
+ return self.path
31
+
32
+ @contextmanager
33
+ def push(self, key: str):
34
+ """Push a new key to contextualize paths"""
35
+ p = self._configpath
36
+ try:
37
+ self._configpath = (Path("out") if p is None else p) / key
38
+ yield key
39
+ finally:
40
+ self._configpath = p
41
+
42
+
43
+ class ConfigWalk:
44
+ """Allows to perform an operation on all nested configurations"""
45
+
46
+ def __init__(self, context: ConfigWalkContext = None, recurse_task=False):
47
+ """
48
+
49
+ :param recurse_task: Recurse into linked tasks
50
+ :param context: The context, by default only tracks the position in the
51
+ config tree
52
+ """
53
+ self.recurse_task = recurse_task
54
+ self.context = ConfigWalkContext() if context is None else context
55
+
56
+ # Stores already visited nodes
57
+ self.visited = {}
58
+
59
+ def preprocess(self, config) -> Tuple[bool, Any]:
60
+ """Returns a tuple boolean/value
61
+
62
+ The boolean value is used to stop the processing if False.
63
+ The value is returned
64
+ """
65
+ return True, None
66
+
67
+ def postprocess(self, stub, config, values: Dict[str, Any]):
68
+ return stub
69
+
70
+ def list(self, i: int):
71
+ return self.context.push(str(i))
72
+
73
+ def map(self, k: str):
74
+ return self.context.push(k)
75
+
76
+ def stub(self, config):
77
+ return config
78
+
79
+ def __call__(self, x):
80
+ from experimaestro.core.objects import Config
81
+ from experimaestro.core.objects import ConfigInformation # noqa: F401
82
+
83
+ if isinstance(x, Config):
84
+ info = x.__xpm__ # type: ConfigInformation
85
+
86
+ # Avoid loops
87
+ xid = id(x)
88
+ if xid in self.visited:
89
+ return self.visited[xid]
90
+
91
+ # Get a stub
92
+ stub = self.stub(x)
93
+ self.visited[xid] = stub
94
+
95
+ # Pre-process
96
+ flag, value = self.preprocess(x)
97
+
98
+ if not flag:
99
+ # Stop processing and returns value
100
+ return value
101
+
102
+ # Process all the arguments
103
+ result = {}
104
+ for arg, v in info.xpmvalues():
105
+ if v is not None:
106
+ with self.map(arg.name):
107
+ result[arg.name] = self(v)
108
+ else:
109
+ result[arg.name] = None
110
+
111
+ # Deals with pre-tasks
112
+ if info.pre_tasks:
113
+ with self.map("__pre_tasks__"):
114
+ self(info.pre_tasks)
115
+
116
+ if info.init_tasks:
117
+ with self.map("__init_tasks__"):
118
+ self(info.init_tasks)
119
+
120
+ # Process task if different
121
+ if (
122
+ x.__xpm__.task is not None
123
+ and self.recurse_task
124
+ and x.__xpm__.task is not x
125
+ ):
126
+ self(x.__xpm__.task)
127
+
128
+ processed = self.postprocess(stub, x, result)
129
+ self.visited[xid] = processed
130
+ return processed
131
+
132
+ if isinstance(x, list):
133
+ result = []
134
+ for i, sv in enumerate(x):
135
+ with self.list(i):
136
+ result.append(self(sv))
137
+ return result
138
+
139
+ if isinstance(x, dict):
140
+ result = {}
141
+ for key, value in x.items():
142
+ assert isinstance(key, (str, float, int))
143
+ with self.map(key):
144
+ result[key] = self(value)
145
+ return result
146
+
147
+ if isinstance(x, (float, int, str, Path, Enum)):
148
+ return x
149
+
150
+ raise NotImplementedError(f"Cannot handle a value of type {type(x)}")
@@ -41,40 +41,8 @@ from typing_extensions import Self
41
41
 
42
42
  TConfig = TypeVar("TConfig", bound="Config")
43
43
 
44
- class Identifier:
45
- main: Incomplete
46
- sub: Incomplete
47
- def __init__(self, main: bytes, sub: Optional[bytes] = ...) -> None: ...
48
- def all(self): ...
49
- def state_dict(self): ...
50
- @staticmethod
51
- def from_state_dict(data: Union[Dict[str, str], str]): ...
52
-
53
- def is_ignored(value): ...
54
- def remove_meta(value): ...
55
-
56
44
  class ObjectStore: ...
57
45
 
58
- class HashComputer:
59
- OBJECT_ID: bytes
60
- INT_ID: bytes
61
- FLOAT_ID: bytes
62
- STR_ID: bytes
63
- PATH_ID: bytes
64
- NAME_ID: bytes
65
- NONE_ID: bytes
66
- LIST_ID: bytes
67
- TASK_ID: bytes
68
- DICT_ID: bytes
69
- ENUM_ID: bytes
70
- def __init__(self) -> None: ...
71
- def identifier(self) -> Identifier: ...
72
- def update(self, value, myself: bool = ...): ...
73
-
74
- def updatedependencies(
75
- dependencies, value: Config, path: List[str], taskids: Set[int]
76
- ): ...
77
-
78
46
  class TaggedValue:
79
47
  value: Incomplete
80
48
  def __init__(self, value) -> None: ...
@@ -122,7 +90,7 @@ class ConfigInformation:
122
90
  job: Job
123
91
  dependencies: Incomplete
124
92
  watched_outputs: List[WatchedOutput]
125
- def __init__(self, pyobject: TypeConfig) -> None: ...
93
+ def __init__(self, pyobject: ConfigMixin) -> None: ...
126
94
  def set_meta(self, value: Optional[bool]): ...
127
95
  @property
128
96
  def meta(self): ...
@@ -162,7 +130,7 @@ class ConfigInformation:
162
130
  definitions: List[Dict],
163
131
  as_instance: bool = ...,
164
132
  save_directory: Optional[Path] = ...,
165
- ) -> TypeConfig: ...
133
+ ) -> ConfigMixin: ...
166
134
  @overload
167
135
  @staticmethod
168
136
  def fromParameters(
@@ -183,7 +151,7 @@ class ConfigInformation:
183
151
  def clone(v): ...
184
152
  def cache(fn, name: str): ...
185
153
 
186
- class TypeConfig:
154
+ class ConfigMixin:
187
155
  __xpmtype__: ObjectType
188
156
  __xpm__: Incomplete
189
157
  def __init__(self, **kwargs) -> None: ...
@@ -217,8 +185,8 @@ class Config:
217
185
  __use_xpmobject__: ClassVar[bool]
218
186
 
219
187
  XPMValue: Type[Self]
220
- XPMConfig: Union[Type[Self], Type[TypeConfig[Self]]]
221
- C: Union[Type[Self], Type[TypeConfig[Self]]]
188
+ XPMConfig: Union[Type[Self], Type[ConfigMixin[Self]]]
189
+ C: Union[Type[Self], Type[ConfigMixin[Self]]]
222
190
 
223
191
  @classmethod
224
192
  def __getxpmtype__(cls) -> ObjectType: ...
@@ -252,6 +220,6 @@ class Task(LightweightTask):
252
220
  def copyconfig(config_or_output: TConfig, **kwargs) -> TConfig: ...
253
221
  def setmeta(config: TConfig, flag: bool) -> TConfig: ...
254
222
 
255
- class TypeConfig(Generic[T]):
223
+ class ConfigMixin(Generic[T]):
256
224
  def __validate__(self):
257
225
  pass
@@ -17,7 +17,7 @@ def json_object(context: SerializationContext, value: Any, objects=[]):
17
17
 
18
18
  if isinstance(value, Config):
19
19
  value.__xpm__.__get_objects__(objects, context)
20
- elif isinstance(value, list):
20
+ elif isinstance(value, (list, tuple)):
21
21
  for el in value:
22
22
  ConfigInformation.__collect_objects__(el, objects, context)
23
23
  elif isinstance(value, dict):
@@ -1,7 +1,7 @@
1
1
  from abc import ABC, abstractmethod
2
2
  import inspect
3
3
  import sys
4
- from typing import Set, Union, Dict, Iterator, List, get_args, get_origin
4
+ from typing import Set, TypeVar, Union, Dict, Iterator, List, get_args, get_origin
5
5
  from collections import ChainMap
6
6
  from pathlib import Path
7
7
  import typing
@@ -130,10 +130,13 @@ class Type:
130
130
  if union_t := typingutils.get_union(key):
131
131
  return UnionType([Type.fromType(t) for t in union_t])
132
132
 
133
- # Takes care of generics
133
+ # Takes care of generics, like List[int], not List
134
134
  if get_origin(key):
135
135
  return GenericType(key)
136
136
 
137
+ if isinstance(key, TypeVar):
138
+ return TypeVarType(key)
139
+
137
140
  raise Exception("No type found for %s", key)
138
141
 
139
142
 
@@ -214,7 +217,7 @@ class ObjectType(Type):
214
217
  identifier: Union[str, Identifier] = None,
215
218
  ):
216
219
  """Creates a type"""
217
- from .objects import Config, TypeConfig
220
+ from .objects import Config, ConfigMixin
218
221
 
219
222
  # Task related attributes
220
223
  self.taskcommandfactory = None
@@ -268,7 +271,7 @@ class ObjectType(Type):
268
271
  s.__getxpmtype__().configtype
269
272
  for s in tp.__bases__
270
273
  if issubclass(s, Config) and (s is not Config)
271
- ) or (TypeConfig,)
274
+ ) or (ConfigMixin,)
272
275
 
273
276
  *tp_qual, tp_name = self.basetype.__qualname__.split(".")
274
277
  self.configtype = type(
@@ -597,6 +600,23 @@ class AnyType(Type):
597
600
  return value
598
601
 
599
602
 
603
+ class TypeVarType(Type):
604
+ def __init__(self, typevar: TypeVar):
605
+ self.typevar = typevar
606
+
607
+ def name(self):
608
+ return str(self.typevar)
609
+
610
+ def validate(self, value):
611
+ return value
612
+
613
+ def __str__(self):
614
+ return f"TypeVar({self.typevar})"
615
+
616
+ def __repr__(self):
617
+ return f"TypeVar({self.typevar})"
618
+
619
+
600
620
  Any = AnyType()
601
621
 
602
622
 
@@ -698,6 +718,10 @@ class GenericType(Type):
698
718
  def __repr__(self):
699
719
  return repr(self.type)
700
720
 
721
+ def identifier(self):
722
+ """Returns the identifier of the type"""
723
+ return Identifier(f"{self.origin}.{self.type}")
724
+
701
725
  def validate(self, value):
702
726
  # Now, let's check generics...
703
727
  mros = typingutils.generic_mro(type(value))
@@ -109,6 +109,7 @@ class Reporter(threading.Thread):
109
109
  return any(level.modified(self) for level in self.levels)
110
110
 
111
111
  def check_urls(self):
112
+ """Check whether we have new schedulers to notify"""
112
113
  mtime = os.path.getmtime(self.path)
113
114
  if mtime > self.lastcheck:
114
115
  for f in self.path.iterdir():
@@ -515,6 +515,7 @@ class Scheduler:
515
515
  return other
516
516
 
517
517
  job._future = asyncio.run_coroutine_threadsafe(self.aio_submit(job), self.loop)
518
+ return None
518
519
 
519
520
  def prepare(self, job: Job):
520
521
  """Prepares the job for running"""
File without changes
@@ -0,0 +1,206 @@
1
+ """Tests for the use of generics in configurations"""
2
+
3
+ from typing import Generic, Optional, TypeVar
4
+
5
+ import pytest
6
+ from experimaestro import Config, Param
7
+ from experimaestro.core.arguments import Argument
8
+ from experimaestro.core.types import TypeVarType
9
+
10
+ T = TypeVar("T")
11
+
12
+
13
+ class SimpleConfig(Config):
14
+ pass
15
+
16
+
17
+ class SimpleConfigChild(SimpleConfig):
18
+ pass
19
+
20
+
21
+ class SimpleGenericConfig(Config, Generic[T]):
22
+ x: Param[T]
23
+
24
+
25
+ class SimpleGenericConfigChild(SimpleGenericConfig, Generic[T]):
26
+ """A child class of SimpleGenericConfig that also uses generics"""
27
+
28
+ pass
29
+
30
+
31
+ def test_core_generics_typevar():
32
+ a = SimpleGenericConfig.C(x=1)
33
+
34
+ x_arg = a.__xpmtype__.arguments["x"]
35
+
36
+ # Check correct interpretation of typevar
37
+ assert type(x_arg) is Argument
38
+ assert isinstance(x_arg.type, TypeVarType)
39
+ assert x_arg.type.typevar == T
40
+
41
+ assert isinstance(a.x, int)
42
+
43
+
44
+ def test_core_generics_simple():
45
+ a = SimpleGenericConfig.C(x=2)
46
+
47
+ # OK
48
+ a.x = 3
49
+
50
+ # Fails: changing generics is not allowed
51
+ with pytest.raises(TypeError):
52
+ a.x = "a string"
53
+
54
+ # typevar bindings are local to the instance,
55
+ # so we can create a new instance with a different type
56
+ SimpleGenericConfig.C(x="a string")
57
+
58
+
59
+ class DoubleGenericConfig(Config, Generic[T]):
60
+ x: Param[T]
61
+ y: Param[T]
62
+
63
+
64
+ def test_core_generics_double():
65
+ # OK
66
+ DoubleGenericConfig.C(x=1, y=1)
67
+
68
+ # Fails
69
+ with pytest.raises(TypeError):
70
+ DoubleGenericConfig.C(x=1, y="a")
71
+
72
+ a = DoubleGenericConfig.C(x=1, y=1)
73
+ a.y = 2
74
+ with pytest.raises(TypeError):
75
+ a.x = "b"
76
+
77
+
78
+ def test_core_generics_double_rebind():
79
+ a = DoubleGenericConfig.C(x=1, y=1)
80
+ # Rebinding to a different type should not work
81
+ with pytest.raises(TypeError):
82
+ a.x, a.y = "some", "string"
83
+
84
+
85
+ def test_core_generics_double_plus():
86
+ # Testing with inheritance
87
+ # We allow subclasses of the typevar binding
88
+ # We also allow generalizing up the typevar binding
89
+ # This means that we can use a super class of the typevar binding
90
+
91
+ # Works
92
+ a = DoubleGenericConfig.C(x=SimpleConfigChild.C())
93
+ a.y = SimpleConfig.C()
94
+
95
+ # Works also
96
+ b = DoubleGenericConfig.C(x=SimpleConfig.C())
97
+ b.y = SimpleConfigChild.C()
98
+
99
+ a.x = SimpleConfigChild.C()
100
+
101
+ with pytest.raises(TypeError):
102
+ a.x = "a string"
103
+
104
+
105
+ def test_core_generics_double_type_escalation():
106
+ a = DoubleGenericConfig.C(x=SimpleConfigChild.C())
107
+ a.y = SimpleConfigChild.C()
108
+ # T is now bound to SimpleConfigChild
109
+
110
+ a.y = SimpleConfig.C()
111
+ # T is now bound to SimpleConfig
112
+
113
+ a.y = object()
114
+ # T is now bound to object, which is a super class of SimpleConfigChild
115
+
116
+ # This is allowed, since we are not changing the typevar binding
117
+ a.x = "a string"
118
+
119
+ a.y = dict()
120
+ # This is allowed, since we are not changing the typevar binding
121
+
122
+
123
+ def test_core_generics_double_deep_bind():
124
+ # Since we are deep binding the typevar T to a specific type,
125
+ # we should not be able to have coherent *local-only* type bindings
126
+ # The type bindings are transient
127
+
128
+ with pytest.raises(TypeError):
129
+ DoubleGenericConfig.C(
130
+ x=DoubleGenericConfig.C(x=1, y=2), y=DoubleGenericConfig.C(x=3, y=4)
131
+ )
132
+
133
+
134
+ class NestedConfig(Config, Generic[T]):
135
+ x: Param[DoubleGenericConfig[T]]
136
+ y: Param[SimpleGenericConfig[T]]
137
+
138
+
139
+ def test_core_generics_nested():
140
+ # OK
141
+ NestedConfig.C(x=DoubleGenericConfig.C(x=1, y=1), y=SimpleGenericConfig.C(x=2))
142
+
143
+ # Not OK
144
+ with pytest.raises(TypeError):
145
+ NestedConfig.C(
146
+ x=DoubleGenericConfig.C(x=1, y=1), y=SimpleGenericConfig.C(x="b")
147
+ )
148
+
149
+ with pytest.raises(TypeError):
150
+ a = NestedConfig.C(
151
+ x=DoubleGenericConfig.C(x=1, y=1), y=SimpleGenericConfig.C(x=1)
152
+ )
153
+ a.x.x = "a string"
154
+
155
+
156
+ class TreeGenericConfig(Config, Generic[T]):
157
+ x: Param[T]
158
+ left: Optional["TreeGenericConfig[T]"] = None
159
+ right: Optional["TreeGenericConfig[T]"] = None
160
+
161
+
162
+ class TagTreeGenericConfig(TreeGenericConfig[T], Generic[T]):
163
+ """A tagged version of TreeGenericConfig to test recursive generics"""
164
+
165
+ tag: Param[str] = "default"
166
+
167
+
168
+ def test_core_generics_recursive():
169
+ a = TreeGenericConfig.C(x=1)
170
+ a.left = TreeGenericConfig.C(x=2)
171
+ a.right = TreeGenericConfig.C(x=3)
172
+
173
+ with pytest.raises(TypeError):
174
+ a.left.x = "a string"
175
+
176
+ # OK to use a child class
177
+ a.left = TagTreeGenericConfig.C(x=4, tag="left")
178
+
179
+ with pytest.raises(TypeError):
180
+ a.left.x = "a string"
181
+
182
+
183
+ def test_core_generics_recursive_child():
184
+ # Testing with a child class on the generic value
185
+ a = TreeGenericConfig.C(x=SimpleConfig.C())
186
+ a.left = TreeGenericConfig.C(x=SimpleConfig.C())
187
+ a.right = TreeGenericConfig.C(x=SimpleConfig.C())
188
+
189
+ a.left.x = SimpleConfigChild.C()
190
+
191
+ with pytest.raises(TypeError):
192
+ a.left.x = "a string"
193
+
194
+
195
+ U = TypeVar("U", bound=SimpleConfigChild)
196
+
197
+
198
+ class BoundGenericConfig(Config, Generic[U]):
199
+ x: Param[U]
200
+
201
+
202
+ def test_core_generics_bound_typevar():
203
+ a = BoundGenericConfig.C(x=SimpleConfigChild.C())
204
+ assert isinstance(a.x, SimpleConfigChild)
205
+ with pytest.raises(TypeError):
206
+ a.x = SimpleConfig.C()
@@ -27,6 +27,8 @@ TERMINATES_FUNC = [terminate]
27
27
  if is_posix():
28
28
  TERMINATES_FUNC.append(sigint)
29
29
 
30
+ MAX_RESTART_WAIT = 50 # 5 seconds
31
+
30
32
 
31
33
  class Restart(Task):
32
34
  touch: Meta[Path] = field(default_factory=PathGenerator("touch"))
@@ -81,7 +83,7 @@ def restart(terminate: Callable, experiment):
81
83
  while not task.touch.is_file():
82
84
  time.sleep(0.1)
83
85
  counter += 1
84
- if counter >= 20:
86
+ if counter >= MAX_RESTART_WAIT:
85
87
  terminate(xpmprocess)
86
88
  assert False, "Timeout waiting for task to be executed"
87
89
 
@@ -1,6 +1,6 @@
1
1
  from typing import Optional
2
2
  from experimaestro import Param, Config
3
- from experimaestro.core.objects import TypeConfig
3
+ from experimaestro.core.objects import ConfigMixin
4
4
  from experimaestro.core.serializers import SerializationLWTask
5
5
 
6
6
 
@@ -21,10 +21,10 @@ def test_simple_instance():
21
21
  b = B(a=a)
22
22
  b = b.instance()
23
23
 
24
- assert not isinstance(b, TypeConfig)
24
+ assert not isinstance(b, ConfigMixin)
25
25
  assert isinstance(b, B.__xpmtype__.objecttype)
26
26
 
27
- assert not isinstance(b.a, TypeConfig)
27
+ assert not isinstance(b.a, ConfigMixin)
28
28
  assert isinstance(b.a, A1.__xpmtype__.objecttype)
29
29
  assert isinstance(b.a, A.__xpmtype__.basetype)
30
30
 
@@ -1,9 +1,10 @@
1
+ import logging
1
2
  from pathlib import Path
2
3
 
3
4
  import pytest
4
5
  from experimaestro import Config, Task, Annotated, copyconfig, default
5
6
  from experimaestro.core.arguments import Param
6
- from experimaestro.core.objects import TypeConfig
7
+ from experimaestro.core.objects import ConfigMixin
7
8
  from experimaestro.core.types import XPMValue
8
9
  from experimaestro.generators import pathgenerator
9
10
  from experimaestro.scheduler.workspace import RunMode
@@ -65,9 +66,9 @@ def test_hierarchy():
65
66
  assert issubclass(B, Config)
66
67
  assert issubclass(C, Config)
67
68
 
68
- assert not issubclass(OA, TypeConfig)
69
- assert not issubclass(OB, TypeConfig)
70
- assert not issubclass(OC, TypeConfig)
69
+ assert not issubclass(OA, ConfigMixin)
70
+ assert not issubclass(OB, ConfigMixin)
71
+ assert not issubclass(OC, ConfigMixin)
71
72
 
72
73
  assert issubclass(C, B)
73
74
 
@@ -91,3 +92,18 @@ def test_copyconfig(xp):
91
92
 
92
93
  assert copy_b.x == b.x
93
94
  assert "path" not in copy_b.__xpm__.values
95
+
96
+
97
+ def test_direct_config_warns(caplog):
98
+ """Test that using a building Config directly raises a warning"""
99
+ message = "Config.__new__ is deprecated"
100
+
101
+ with caplog.at_level(logging.WARNING):
102
+ A(x=3)
103
+ assert message in caplog.text
104
+
105
+ caplog.clear()
106
+
107
+ with caplog.at_level(logging.WARNING):
108
+ A.C(x=3)
109
+ assert message not in caplog.text