Skip to content

Commit 02049ca

Browse files
authored
Add Extractors utility class and ... (#45)
* Add Extractors utility class Improve test coverage - all modules >= 90%
1 parent b72d701 commit 02049ca

File tree

10 files changed

+214
-24
lines changed

10 files changed

+214
-24
lines changed

Makefile

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,8 +118,9 @@ generate-proto: ## Generate Proto Files
118118
# ----------------------------------------------------------------------------------------------------------------------
119119
.PHONY: test
120120
test: ##
121-
pytest -W error --cov src/coherence --capture=tee-sys --cov-report=term --cov-report=html \
121+
pytest -W error --cov src/coherence --cov-report=term --cov-report=html \
122122
tests/test_serialization.py \
123+
tests/test_extractors.py \
123124
tests/test_session.py \
124125
tests/test_client.py \
125126
tests/test_events.py \

sonar-project.properties

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
sonar.python.version=3.11

src/coherence/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from .client import Options as Options
1717
from .client import Session as Session
1818
from .client import TlsOptions as TlsOptions
19+
from .extractor import Extractors as Extractors
1920
from .filter import Filters as Filters
2021
from .processor import Processors as Processors
2122

src/coherence/aggregator.py

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from typing import Any, Dict, Generic, List, Optional, TypeAlias, TypeVar
1111

1212
from .comparator import Comparator, InverseComparator, SafeComparator
13-
from .extractor import ExtractorExpression, IdentityExtractor, ValueExtractor, extract
13+
from .extractor import ExtractorExpression, Extractors, ValueExtractor
1414
from .filter import Filter
1515
from .serialization import proxy
1616

@@ -45,7 +45,7 @@ def __init__(self, extractor_or_property: Optional[ExtractorExpression[T, E]] =
4545
if isinstance(extractor_or_property, ValueExtractor):
4646
self.extractor = extractor_or_property
4747
else:
48-
self.extractor = extract(extractor_or_property)
48+
self.extractor = Extractors.extract(extractor_or_property)
4949

5050
def and_then(self, aggregator: EntryAggregator[R]) -> EntryAggregator[List[R]]:
5151
"""
@@ -241,7 +241,7 @@ def __init__(
241241
self,
242242
number: int = 0,
243243
inverse: bool = False,
244-
extractor: ValueExtractor[Any, Any] = IdentityExtractor(),
244+
extractor: ValueExtractor[Any, Any] = Extractors.identity(),
245245
comparator: Optional[Comparator] = None,
246246
property_name: Optional[str] = None,
247247
):
@@ -304,8 +304,7 @@ def extract(self, property_name: str) -> TopAggregator[E, R]:
304304
:param property_name: the property name
305305
:return:
306306
"""
307-
self.inverse = True # TODO why is this True?
308-
self.extractor = ValueExtractor.extract(property_name)
307+
self.extractor = Extractors.extract(property_name)
309308
return self
310309

311310

src/coherence/extractor.py

Lines changed: 57 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -263,13 +263,63 @@ def __init__(self, method: str) -> None:
263263
self.name = method
264264

265265

266-
def extract(expression: str) -> ValueExtractor[T, E]:
267-
if "." in expression:
268-
return ChainedExtractor(expression)
269-
elif "," in expression:
270-
return MultiExtractor(expression)
271-
else:
272-
return UniversalExtractor(expression)
266+
class Extractors:
267+
"""
268+
A Utility class for creating extractors.
269+
"""
270+
271+
@classmethod
272+
def extract(cls, expression: str, params: Optional[list[Any]] = None) -> ValueExtractor[T, E]:
273+
"""
274+
If providing only an expression, the following rules apply:
275+
- if the expression contains multiple values separated by a period,
276+
the expression will be treated as a chained expression. E.g.,
277+
the expression 'a.b.c' would be treated as extract the 'a'
278+
property, from that result, extract the 'b' property, and finally
279+
from that result, extract the 'c' property.
280+
- if the expression contains multiple values separated by a comma,
281+
the expression will be treated as a multi expression. E.g.,
282+
the expression 'a,b,c' would be treated as extract the 'a', 'b',
283+
and 'c' properties from the same object.
284+
- for either case, the params argument is ignored.
285+
286+
It is also possible to invoke, and pass arguments to, arbitrary methods.
287+
For example, if the target object of the extraction is a String, it's
288+
possible to call the length() function by passing an expression of
289+
"length()". If the target method accepts arguments, provide a list
290+
of one or more arguments to be passed.
291+
292+
:param expression: the extractor expression
293+
:param params: the params to pass to the method invocation
294+
:return: a ValueExtractor based on the provided inputs
295+
"""
296+
if expression is None:
297+
raise ValueError("An expression must be provided")
298+
299+
if params is None or len(params) == 0:
300+
if "." in expression:
301+
return ChainedExtractor(expression)
302+
elif "," in expression:
303+
return MultiExtractor(expression)
304+
else:
305+
return UniversalExtractor(expression)
306+
307+
expr: str = expression
308+
if not expr.endswith("()"):
309+
expr = expr + "()"
310+
311+
return UniversalExtractor(expr, params)
312+
313+
@classmethod
314+
def identity(cls) -> IdentityExtractor[Any]:
315+
"""
316+
Returns an extractor that does not actually extract anything
317+
from the passed value, but returns the value itself.
318+
319+
:return: an extractor that does not actually extract anything
320+
from the passed value, but returns the value itself
321+
"""
322+
return IdentityExtractor()
273323

274324

275325
ExtractorExpression: TypeAlias = ValueExtractor[T, E] | str

src/coherence/filter.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from abc import ABC
88
from typing import Any, Generic, Set, TypeVar
99

10-
from .extractor import ExtractorExpression, ValueExtractor, extract
10+
from .extractor import ExtractorExpression, Extractors, ValueExtractor
1111
from .serialization import proxy
1212

1313
E = TypeVar("E")
@@ -80,7 +80,7 @@ def __init__(self, extractor: ExtractorExpression[T, E]):
8080
if isinstance(extractor, ValueExtractor):
8181
self.extractor = extractor
8282
elif type(extractor) == str:
83-
self.extractor = extract(extractor)
83+
self.extractor = Extractors.extract(extractor)
8484
else:
8585
raise ValueError("extractor cannot be any other type")
8686

src/coherence/processor.py

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,15 +11,14 @@
1111
from .extractor import (
1212
CompositeUpdater,
1313
ExtractorExpression,
14-
IdentityExtractor,
14+
Extractors,
1515
ManipulatorExpression,
1616
UniversalExtractor,
1717
UniversalUpdater,
1818
UpdaterExpression,
1919
ValueExtractor,
2020
ValueManipulator,
2121
ValueUpdater,
22-
extract,
2322
)
2423
from .filter import Filter
2524
from .serialization import mappings, proxy
@@ -91,12 +90,12 @@ def __init__(self, value_extractor: ExtractorExpression[V, R]):
9190
"""
9291
super().__init__()
9392
if value_extractor is None:
94-
self.extractor: ValueExtractor[V, R] = IdentityExtractor()
93+
self.extractor: ValueExtractor[V, R] = Extractors.identity()
9594
else:
9695
if isinstance(value_extractor, ValueExtractor):
9796
self.extractor = value_extractor
9897
elif type(value_extractor) == str:
99-
self.extractor = extract(value_extractor)
98+
self.extractor = Extractors.extract(value_extractor)
10099
else:
101100
raise ValueError("value_extractor cannot be any other type")
102101

@@ -615,7 +614,7 @@ def extract(extractor: Optional[ExtractorExpression[T, E]] = None) -> EntryProce
615614
processor or the name of the method to invoke via java reflection. If `None`, an
616615
:class:`coherence.extractor.IdentityExtractor` will be used.
617616
"""
618-
ext: ExtractorExpression[T, E] = extractor if extractor is not None else IdentityExtractor()
617+
ext: ExtractorExpression[T, E] = extractor if extractor is not None else Extractors.identity()
619618
return ExtractorProcessor(ext)
620619

621620
@staticmethod

tests/test_events.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,22 @@ async def setup_and_teardown() -> AsyncGenerator[NamedCache[Any, Any], None]:
308308
# ----- test functions ------------------------------------------------------
309309

310310

311+
@pytest.mark.asyncio
312+
async def test_add_no_listener(setup_and_teardown: NamedCache[str, str]) -> None:
313+
cache: NamedCache[str, str] = setup_and_teardown
314+
315+
with pytest.raises(ValueError):
316+
await cache.add_map_listener(None)
317+
318+
319+
@pytest.mark.asyncio
320+
async def test_remove_no_listener(setup_and_teardown: NamedCache[str, str]) -> None:
321+
cache: NamedCache[str, str] = setup_and_teardown
322+
323+
with pytest.raises(ValueError):
324+
await cache.remove_map_listener(None)
325+
326+
311327
# noinspection PyShadowingNames
312328
@pytest.mark.asyncio
313329
async def test_all(setup_and_teardown: NamedCache[str, str]) -> None:

tests/test_extractors.py

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
# Copyright (c) 2023, Oracle and/or its affiliates.
2+
# Licensed under the Universal Permissive License v 1.0 as shown at
3+
# https://oss.oracle.com/licenses/upl.
4+
5+
from typing import Any, Optional, cast
6+
7+
import pytest
8+
9+
from coherence import Extractors
10+
from coherence.extractor import (
11+
AbstractCompositeExtractor,
12+
ChainedExtractor,
13+
MultiExtractor,
14+
UniversalExtractor,
15+
ValueExtractor,
16+
)
17+
18+
19+
def test_extract_simple() -> None:
20+
result = Extractors.extract("simple")
21+
_validate_universal(result, "simple")
22+
23+
24+
def test_extract_chained() -> None:
25+
result = Extractors.extract("prop1.prop2.prop3")
26+
_validate_composite(result, ChainedExtractor, 3)
27+
28+
_validate_universal(result.extractors[0], "prop1")
29+
_validate_universal(result.extractors[1], "prop2")
30+
_validate_universal(result.extractors[2], "prop3")
31+
32+
33+
def test_extract_multi() -> None:
34+
result = Extractors.extract("prop1,prop2,prop3")
35+
_validate_composite(result, MultiExtractor, 3)
36+
37+
_validate_universal(result.extractors[0], "prop1")
38+
_validate_universal(result.extractors[1], "prop2")
39+
_validate_universal(result.extractors[2], "prop3")
40+
41+
42+
def test_extract_method_no_parens() -> None:
43+
args = ["arg1", 10]
44+
result = Extractors.extract("length", args)
45+
_validate_universal(result, "length()", args)
46+
47+
48+
def test_extract_method_with_parens() -> None:
49+
args = ["arg1", 10]
50+
result = Extractors.extract("length()", args)
51+
_validate_universal(result, "length()", args)
52+
53+
54+
def test_extract_no_expression() -> None:
55+
with pytest.raises(ValueError):
56+
Extractors.extract(None)
57+
58+
59+
def test_compose() -> None:
60+
result = Extractors.extract("prop1")
61+
result = result.compose(Extractors.extract("prop2"))
62+
63+
_validate_composite(result, ChainedExtractor, 2)
64+
65+
_validate_universal(result.extractors[0], "prop2")
66+
_validate_universal(result.extractors[1], "prop1")
67+
68+
69+
def test_compose_no_extractor_arg() -> None:
70+
result = Extractors.extract("prop1")
71+
with pytest.raises(ValueError):
72+
result.compose(None)
73+
74+
75+
def test_and_then_no_extractor_arg() -> None:
76+
result = Extractors.extract("prop1")
77+
with pytest.raises(ValueError):
78+
result.and_then(None)
79+
80+
81+
def test_and_then() -> None:
82+
result = Extractors.extract("prop1")
83+
result = result.and_then(Extractors.extract("prop2"))
84+
85+
_validate_composite(result, ChainedExtractor, 2)
86+
87+
_validate_universal(result.extractors[0], "prop1")
88+
_validate_universal(result.extractors[1], "prop2")
89+
90+
91+
def _validate_universal(extractor: ValueExtractor[Any, Any], expr: str, params: Optional[list[Any]] = None) -> None:
92+
assert extractor is not None
93+
assert isinstance(extractor, UniversalExtractor)
94+
95+
extractor_local = cast(UniversalExtractor, extractor)
96+
assert extractor_local.name == expr
97+
assert extractor_local.params == params
98+
99+
100+
def _validate_composite(extractor: ValueExtractor[Any, Any], expected_type: type, length: int) -> None:
101+
assert extractor is not None
102+
assert isinstance(extractor, expected_type)
103+
104+
extractor_local = cast(AbstractCompositeExtractor, extractor)
105+
assert extractor_local.extractors is not None
106+
assert len(extractor_local.extractors) == length

tests/test_session.py

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
import pytest
1515

1616
import tests
17-
from coherence import NamedCache, Options, Session, TlsOptions
17+
from coherence import NamedCache, NamedMap, Options, Session, TlsOptions
1818
from coherence.event import MapLifecycleEvent, SessionLifecycleEvent
1919
from tests import CountingMapListener
2020

@@ -62,6 +62,14 @@ def check_basics() -> None:
6262
assert session.is_ready()
6363
assert not session.closed
6464

65+
cache = await session.get_cache("cache")
66+
assert cache is not None
67+
assert isinstance(cache, NamedCache)
68+
69+
map_local = await session.get_map("map")
70+
assert map_local is not None
71+
assert isinstance(map_local, NamedMap)
72+
6573
await session.close()
6674
await asyncio.sleep(0.1)
6775

@@ -70,10 +78,19 @@ def check_basics() -> None:
7078
assert not session.is_ready()
7179
assert session.closed
7280

73-
with pytest.raises(Exception):
74-
await session.get_cache("test")
81+
with pytest.raises(RuntimeError):
82+
await cache.size()
83+
84+
with pytest.raises(RuntimeError):
85+
await map_local.size()
86+
87+
with pytest.raises(RuntimeError):
88+
await session.get_cache("cache")
89+
90+
with pytest.raises(RuntimeError):
91+
await session.get_map("map")
7592

76-
with pytest.raises(Exception):
93+
with pytest.raises(RuntimeError):
7794
session.on(SessionLifecycleEvent.CLOSED, lambda: None)
7895

7996

0 commit comments

Comments
 (0)