Skip to content

Commit d4f90c3

Browse files
id: make Tokens objects immutable
* Having Tokens objects mutable seemed like a good idea at the time. * But in practice, Tokens are rarely modified. * And their mutability is a barrier to use as dictionary keys and caching. * Implement the `__hash__` interface and block the `__setitem__` and `update` interfaces.
1 parent 8a583b3 commit d4f90c3

File tree

8 files changed

+69
-71
lines changed

8 files changed

+69
-71
lines changed

cylc/flow/id.py

Lines changed: 28 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ class Tokens(dict):
7474
<id: w//c>
7575
>>> Tokens(workflow='w', cycle='c')['job']
7676
77-
# Make a copy (note Tokens are mutable):
77+
# Make a copy (note Tokens are immutable):
7878
>>> tokens.duplicate()
7979
<id: ~u/w//c/t/01>
8080
>>> tokens.duplicate(job='02') # make changes at the same time
@@ -118,9 +118,10 @@ def __init__(
118118
dict.__init__(self, **kwargs)
119119

120120
def __setitem__(self, key, value):
121-
if key not in self._KEYS:
122-
raise ValueError(f'Invalid token: {key}')
123-
dict.__setitem__(self, key, value)
121+
raise Exception('Tokens objects are not mutable')
122+
123+
def update(self, other):
124+
raise Exception('Tokens objects are not mutable')
124125

125126
def __getitem__(self, key):
126127
try:
@@ -151,6 +152,9 @@ def __repr__(self):
151152
id_ = self.id
152153
return f'<id: {id_}>'
153154

155+
def __hash__(self):
156+
return hash(tuple(self.values()))
157+
154158
def __eq__(self, other):
155159
if not isinstance(other, self.__class__):
156160
return False
@@ -336,62 +340,20 @@ def is_null(self) -> bool:
336340
>>> tokens = Tokens()
337341
>>> tokens.is_null
338342
True
339-
>>> tokens['job_sel'] = 'x'
340-
>>> tokens.is_null
343+
>>> tokens.duplicate(job_sel='x').is_null
341344
True
342-
>>> tokens['job'] = '01'
343-
>>> tokens.is_null
345+
>>> tokens.duplicate(job='01').is_null
344346
False
345347
346348
"""
347349
return not any(
348350
self[key] for key in self._REGULAR_KEYS
349351
)
350352

351-
def update_tokens(
352-
self,
353-
tokens: 'Optional[Tokens]' = None,
354-
**kwargs
355-
) -> None:
356-
"""Update the tokens dictionary.
357-
358-
Similar to dict.update but with an optional Tokens argument.
359-
360-
Examples:
361-
>>> tokens = Tokens('x')
362-
>>> tokens.update_tokens(workflow='y')
363-
>>> tokens
364-
<id: y>
365-
>>> tokens.update_tokens(Tokens('z'))
366-
>>> tokens
367-
<id: z>
368-
>>> tokens.update_tokens(Tokens('a'), cycle='b')
369-
>>> tokens
370-
<id: a//b>
371-
372-
"""
373-
if tokens:
374-
for key, value in tokens.items():
375-
self[key] = value
376-
for key, value in kwargs.items():
377-
self[key] = value
378-
379-
def update(self, other):
380-
"""dict.update.
381-
382-
Example:
383-
>>> tokens = Tokens(workflow='w')
384-
>>> tokens.update({'cycle': 'c'})
385-
>>> tokens.id
386-
'w//c'
387-
388-
"""
389-
return self.update_tokens(**other)
390-
391353
def duplicate(
392354
self,
393-
tokens: 'Optional[Tokens]' = None,
394-
**kwargs
355+
*tokens_list,
356+
**kwargs,
395357
) -> 'Tokens':
396358
"""Duplicate a tokens object.
397359
@@ -408,17 +370,28 @@ def duplicate(
408370
>>> id(tokens1) == id(tokens2)
409371
False
410372
411-
Make a copy and modify it:
373+
Make a copy with a modification:
412374
>>> tokens1.duplicate(cycle='1').id
413375
'~u/w//1'
414376
415-
Original not changed
377+
The Original is not changed:
416378
>>> tokens1.id
417379
'~u/w'
380+
381+
Arguments override in definition order:
382+
>>> Tokens.duplicate(
383+
... tokens1,
384+
... Tokens(cycle='c', task='a', job='01'),
385+
... task='b'
386+
... ).id
387+
'~u/w//c/b/01'
388+
418389
"""
419-
ret = Tokens(self)
420-
ret.update_tokens(tokens, **kwargs)
421-
return ret
390+
_kwargs = {}
391+
for tokens in (self, *tokens_list):
392+
_kwargs.update(tokens)
393+
_kwargs.update(kwargs)
394+
return Tokens(**_kwargs)
422395

423396

424397
# //cycle[:sel][/task[:sel][/job[:sel]]]

cylc/flow/id_cli.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -294,7 +294,7 @@ async def parse_ids_async(
294294

295295
# infer the run number if not specified the ID (and if possible)
296296
if infer_latest_runs:
297-
_infer_latest_runs(*tokens_list, src_path=src_path)
297+
_infer_latest_runs(tokens_list, src_path=src_path)
298298

299299
_validate_number(
300300
*tokens_list,
@@ -409,12 +409,14 @@ def _validate_workflow_ids(*tokens_list, src_path):
409409
detect_both_flow_and_suite(src_path)
410410

411411

412-
def _infer_latest_runs(*tokens_list, src_path):
412+
def _infer_latest_runs(tokens_list, src_path):
413413
for ind, tokens in enumerate(tokens_list):
414414
if ind == 0 and src_path:
415415
# source workflow passed in as a path
416416
continue
417-
tokens['workflow'] = infer_latest_run_from_id(tokens['workflow'])
417+
tokens_list[ind] = tokens.duplicate(
418+
workflow=infer_latest_run_from_id(tokens['workflow'])
419+
)
418420
pass
419421

420422

cylc/flow/scripts/completion_server.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -390,7 +390,6 @@ async def list_in_workflow(tokens: Tokens, infer_run=True) -> t.List[str]:
390390
# list possible IDs
391391
cli_detokenise(
392392
tokens.duplicate(
393-
tokens=None,
394393
# use the workflow ID provided on the CLI to allow
395394
# run name inference
396395
workflow=input_workflow,

cylc/flow/task_id.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -107,5 +107,6 @@ def get_standardised_point(
107107
def get_standardised_taskid(cls, task_id):
108108
"""Return task ID with standardised cycle point."""
109109
tokens = Tokens(task_id, relative=True)
110-
tokens['cycle'] = cls.get_standardised_point_string(tokens['cycle'])
111-
return tokens.relative_id
110+
return tokens.duplicate(
111+
cycle=cls.get_standardised_point_string(tokens['cycle'])
112+
).relative_id

cylc/flow/task_pool.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1917,7 +1917,7 @@ def match_future_tasks(
19171917
# Glob or task state was not matched by active tasks
19181918
if not tokens['task']:
19191919
# make task globs explicit to make warnings clearer
1920-
tokens['task'] = '*'
1920+
tokens = tokens.duplicate(task='*')
19211921
LOG.warning(
19221922
'No active tasks matching:'
19231923
# preserve :selectors when logging the id
@@ -1985,7 +1985,7 @@ def match_taskdefs(
19851985
point_str = tokens['cycle']
19861986
if not tokens['task']:
19871987
# make task globs explicit to make warnings clearer
1988-
tokens['task'] = '*'
1988+
tokens = tokens.duplicate(task='*')
19891989
name_str = tokens['task']
19901990
try:
19911991
point_str = standardise_point_string(point_str)

tests/integration/test_task_pool.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -216,7 +216,8 @@ async def test_filter_task_proxies(
216216
expected_bad_items: List[str],
217217
expected_warnings: List[str],
218218
mod_example_flow: Scheduler,
219-
caplog: pytest.LogCaptureFixture
219+
caplog: pytest.LogCaptureFixture,
220+
monkeypatch,
220221
) -> None:
221222
"""Test TaskPool.filter_task_proxies().
222223
@@ -256,7 +257,8 @@ async def test_filter_task_proxies_hidden(
256257
expected_bad_items: List[str],
257258
expected_warnings: List[str],
258259
mod_example_flow: Scheduler,
259-
caplog: pytest.LogCaptureFixture
260+
caplog: pytest.LogCaptureFixture,
261+
monkeypatch,
260262
) -> None:
261263
"""Test TaskPool.filter_task_proxies().
262264
@@ -277,6 +279,13 @@ async def test_filter_task_proxies_hidden(
277279
expected_bad_items: Expected to be returned.
278280
expected_warnings: Expected to be logged.
279281
"""
282+
monkeypatch.setattr(
283+
# make Tokens objects mutable to allow deepcopy to work on TaskProxy
284+
# objects
285+
'cylc.flow.id.Tokens.__setitem__',
286+
lambda self, key, value: dict.__setitem__(self, key, value),
287+
)
288+
280289
caplog.set_level(logging.WARNING, CYLC_LOG)
281290
task_pool = mod_example_flow.pool
282291

tests/unit/test_id.py

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -316,9 +316,9 @@ def test_tokens():
316316
with pytest.raises(ValueError):
317317
Tokens(foo='a')
318318

319-
Tokens()['cycle'] = 'a'
319+
Tokens().duplicate(cycle='a')
320320
with pytest.raises(ValueError):
321-
Tokens()['foo'] = 'a'
321+
Tokens(foo='a')
322322

323323
# test equality
324324
assert Tokens('a') == Tokens('a')
@@ -335,10 +335,24 @@ def test_tokens():
335335
assert not Tokens('a') == 1
336336

337337
tokens = Tokens('a//b')
338-
tokens.update({'cycle': 'c', 'task': 'd'})
339-
assert tokens == Tokens('a//c/d')
340-
with pytest.raises(ValueError):
338+
new_tokens = tokens.duplicate(cycle='c', task='d')
339+
assert new_tokens == Tokens('a//c/d')
340+
with pytest.raises(Exception):
341341
tokens.update({'foo': 'c'})
342+
with pytest.raises(Exception):
343+
tokens['cycle'] = 'a'
344+
345+
# test gt/lt
346+
assert sorted(
347+
tokens.id
348+
for tokens in [
349+
Tokens('~u/c'),
350+
Tokens('~u/b//1'),
351+
Tokens('~u/a'),
352+
Tokens('~u/b'),
353+
Tokens('~u/b//2'),
354+
]
355+
) == ['~u/a', '~u/b', '~u/b//1', '~u/b//2', '~u/c']
342356

343357

344358
def test_no_look_behind():

tests/unit/test_id_cli.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -576,6 +576,6 @@ async def test_expand_workflow_tokens_impl_selector(no_scan):
576576
"""It should reject filters it can't handle."""
577577
tokens = tokenise('~user/*')
578578
await _expand_workflow_tokens([tokens])
579-
tokens['workflow_sel'] = 'stopped'
579+
tokens = tokens.duplicate(workflow_sel='stopped')
580580
with pytest.raises(InputError):
581581
await _expand_workflow_tokens([tokens])

0 commit comments

Comments
 (0)