Skip to content

Commit 2976c73

Browse files
committed
add Entity.retrieve_list()
1 parent 2696b6c commit 2976c73

File tree

6 files changed

+249
-44
lines changed

6 files changed

+249
-44
lines changed

amatino/entity.py

Lines changed: 94 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -13,18 +13,17 @@
1313
from amatino.internal.url_parameters import UrlParameters
1414
from amatino.internal.constrained_string import ConstrainedString
1515
from amatino.internal.encodable import Encodable
16-
from amatino.amatino_error import AmatinoError
17-
from typing import TypeVar
18-
from typing import Optional
19-
from typing import Type
20-
from typing import Dict
21-
from typing import Any
16+
from amatino.state import State
17+
from typing import TypeVar, Optional, Type, Dict, Any, List
2218
from amatino.internal.immutable import Immutable
19+
from amatino.internal.session_decodable import SessionDecodable
20+
from amatino.internal.disposition import Disposition
21+
from amatino.internal.url_target import UrlTarget
2322

2423
T = TypeVar('T', bound='Entity')
2524

2625

27-
class Entity:
26+
class Entity(SessionDecodable):
2827
"""
2928
An Amatino entity is a economic unit to be described by accounting
3029
information. Entities are described by accounts, transactions, and entries.
@@ -33,8 +32,11 @@ class Entity:
3332
of companies, a project, or even a person.
3433
"""
3534
PATH = '/entities'
35+
LIST_PATH = '/entities/list'
3636
MAX_NAME_LENGTH = 1024
3737
MAX_DESCRIPTION_LENGTH = 4096
38+
MAX_NAME_SEARCH_LENGTH = 64
39+
MIN_NAME_SEARCH_LENGTH = 3
3840

3941
def __init__(
4042
self,
@@ -44,7 +46,8 @@ def __init__(
4446
description: str,
4547
region_id: int,
4648
owner_id: int,
47-
permissions_graph: PermissionsGraph
49+
permissions_graph: PermissionsGraph,
50+
disposition: Disposition
4851
) -> None:
4952

5053
self._session = session
@@ -54,6 +57,7 @@ def __init__(
5457
self._region_id = region_id
5558
self._owner_id = owner_id
5659
self._permissions_graph = permissions_graph
60+
self._disposition = disposition
5761

5862
return
5963

@@ -64,6 +68,7 @@ def __init__(
6468
region_id = Immutable(lambda s: s._region_id)
6569
owner_id = Immutable(lambda s: s._owner_id)
6670
permissions_graph = Immutable(lambda s: s._permissions_graph)
71+
disposition = Immutable(lambda s: s._disposition)
6772

6873
@classmethod
6974
def create(
@@ -91,52 +96,38 @@ def create(
9196
False
9297
)
9398

94-
created_entity = cls._decode(request.response_data, session)
99+
created_entity = cls.decode(request.response_data, session)
95100

96101
return created_entity
97102

98103
@classmethod
99-
def _decode(cls: Type[T], data: list, session: Session) -> T:
104+
def decode(cls: Type[T], data: Any, session: Session) -> T:
100105
"""
101106
Return an Entity instance decoded from API response data
102107
"""
103-
assert isinstance(session, Session)
108+
if isinstance(data, list):
109+
data = data[0]
104110

105-
if not isinstance(data, list):
106-
raise AmatinoError('Unexpected response format: ' + str(type(data)))
107-
108-
if len(data) < 1:
109-
raise AmatinoError('Response list unexpectedly empty')
110-
111-
raw_entity = data[0]
112-
113-
if not isinstance(raw_entity, dict):
114-
raise AmatinoError('Unexpected response format')
115-
try:
116-
entity = cls(
117-
session=session,
118-
entity_id=raw_entity['entity_id'],
119-
name=raw_entity['name'],
120-
description=raw_entity['description'],
121-
region_id=raw_entity['region_id'],
122-
owner_id=raw_entity['owner'],
123-
permissions_graph=PermissionsGraph(
124-
raw_entity['permissions_graph']
125-
)
126-
)
127-
except KeyError as error:
128-
raise AmatinoError(
129-
'Unexpected response format, missing key ' + error.args[0]
130-
)
111+
assert isinstance(session, Session)
131112

132-
return entity
113+
return cls(
114+
session=session,
115+
entity_id=data['entity_id'],
116+
name=data['name'],
117+
description=data['description'],
118+
region_id=data['region_id'],
119+
owner_id=data['owner'],
120+
permissions_graph=PermissionsGraph(data['permissions_graph']),
121+
disposition=Disposition.decode(data['disposition'])
122+
)
133123

134124
@classmethod
135125
def retrieve(
136126
cls: Type[T],
137127
session: Session,
138128
entity_id: str
139-
) -> T:
129+
) -> Optional[T]:
130+
140131
if not isinstance(session, Session):
141132
raise TypeError('session must be of type `Session`')
142133

@@ -154,9 +145,70 @@ def retrieve(
154145
debug=False
155146
)
156147

157-
entity = cls._decode(request.response_data, session)
148+
return cls.optionally_decode(request.response_data, session)
149+
150+
@classmethod
151+
def retrieve_list(
152+
cls: Type[T],
153+
session: Session,
154+
state: State = State.ALL,
155+
offset: int = 0,
156+
limit: int = 10,
157+
name_fragment: Optional[str] = None
158+
) -> List[T]:
159+
160+
if not isinstance(session, Session):
161+
raise TypeError('session must be of type `amatino.Session`')
162+
163+
if not isinstance(offset, int):
164+
raise TypeError('offset must be of type `int`')
165+
166+
if not isinstance(limit, int):
167+
raise TypeError('limit must be of type `int`')
158168

159-
return entity
169+
if not isinstance(state, State):
170+
raise TypeError('state must be of type `amatino.State`')
171+
172+
if name_fragment is not None:
173+
if not isinstance(name_fragment, str):
174+
raise TypeError('name_fragment must be of type `str`')
175+
if len(name_fragment) < cls.MIN_NAME_SEARCH_LENGTH:
176+
raise ValueError(
177+
'name_fragment minimum length is {c} char'.format(
178+
c=str(cls.MIN_NAME_SEARCH_LENGTH)
179+
)
180+
)
181+
if len(name_fragment) > cls.MAX_NAME_SEARCH_LENGTH:
182+
raise ValueError(
183+
'name_fragment maximum length is {c} char'.format(
184+
c=str(cls.MAX_NAME_SEARCH_LENGTH)
185+
)
186+
)
187+
188+
url_targets = [
189+
UrlTarget('limit', limit),
190+
UrlTarget('offset', offset),
191+
UrlTarget('state', state.value)
192+
]
193+
194+
if name_fragment is not None:
195+
url_targets.append(UrlTarget('name', name_fragment))
196+
197+
url_parameters = UrlParameters(targets=url_targets)
198+
199+
request = ApiRequest(
200+
path=Entity.LIST_PATH,
201+
method=HTTPMethod.GET,
202+
credentials=session,
203+
data=None,
204+
url_parameters=url_parameters
205+
)
206+
207+
return cls.optionally_decode_many(
208+
data=request.response_data,
209+
session=session,
210+
default_to_empty_list=True
211+
)
160212

161213
def update(
162214
self,
@@ -192,7 +244,7 @@ def update(
192244
url_parameters=None
193245
)
194246

195-
updated_entity = Entity._decode(request.response_data, self.session)
247+
updated_entity = Entity.decode(request.response_data, self.session)
196248

197249
return updated_entity
198250

amatino/internal/decodable.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
"""
2+
Amatino Python
3+
Decodable Module
4+
author: hugh@blinkybeach.com
5+
"""
6+
from json import loads
7+
from typing import Any, Optional, TypeVar, Type, List
8+
9+
T = TypeVar('T', bound='Decodable')
10+
11+
12+
class Decodable:
13+
"""Abstract protocol defining an interface for decodable classes"""
14+
15+
@classmethod
16+
def decode(self, data: Any) -> T:
17+
"""Return a JSON-serialisable form of the object"""
18+
raise NotImplementedError
19+
20+
@classmethod
21+
def optionally_decode(cls: Type[T], data: Optional[Any]) -> Optional[T]:
22+
"""Optionally return a decoded object from serialised data"""
23+
if data is None:
24+
return None
25+
return cls.decode(data)
26+
27+
@classmethod
28+
def deserialise(cls: Type[T], serial: str) -> T:
29+
"""Return a JSON string representation of the object"""
30+
return cls.decode(loads(serial))
31+
32+
@classmethod
33+
def optionally_deserialise(
34+
cls: Type[T],
35+
serial: Optional[str]
36+
) -> Optional[T]:
37+
if serial is None:
38+
return None
39+
return cls.deserialise(serial)
40+
41+
@classmethod
42+
def decode_many(cls: Type[T], data: Any) -> List[T]:
43+
"""Return list of decoded instances of an object"""
44+
return [cls.decode(d) for d in data]
45+
46+
@classmethod
47+
def optionally_decode_many(
48+
cls: Type[T],
49+
data: Optional[Any],
50+
default_to_empty_list: bool = False
51+
) -> Optional[List[T]]:
52+
"""Optionally return a list of decoded objects"""
53+
if data is None and default_to_empty_list is True:
54+
return list()
55+
if data is None:
56+
return None
57+
return cls.decode_many(data)

amatino/internal/disposition.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
"""
2+
Amatino Python
3+
Disposition Module
4+
author: hugh@blinkybeach.com
5+
"""
6+
from amatino.internal.decodable import Decodable
7+
from nozomi.ancillary.immutable import Immutable
8+
from typing import TypeVar, Type, Any, Dict
9+
10+
T = TypeVar('T', bound='Disposition')
11+
12+
13+
class Disposition(Decodable):
14+
15+
def __init__(
16+
self,
17+
sequence: int,
18+
count: int,
19+
limit: int,
20+
offset: int
21+
) -> None:
22+
23+
self._sequence = sequence
24+
self._count = count
25+
self._limit = limit
26+
self._offset = offset
27+
28+
return
29+
30+
sequence = Immutable(lambda s: s._sequence)
31+
count = Immutable(lambda s: s._count)
32+
limit = Immutable(lambda s: s._limit)
33+
offset = Immutable(lambda s: s._offset)
34+
35+
@classmethod
36+
def decode(cls: Type[T], data: Any) -> T:
37+
return cls(
38+
sequence=data['sequence'],
39+
count=data['count'],
40+
limit=data['limit'],
41+
offset=data['offset']
42+
)
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
"""
2+
Amatino Python
3+
Session Decodable Module
4+
author: hugh@blinkybeach.com
5+
"""
6+
from typing import Any, Optional, TypeVar, Type, List
7+
from amatino.session import Session
8+
9+
T = TypeVar('T', bound='SessionDecodable')
10+
11+
12+
class SessionDecodable:
13+
"""Abstract protocol defining an interface for decodable classes"""
14+
15+
@classmethod
16+
def decode(self, data: Any, session: Session) -> T:
17+
"""Return a JSON-serialisable form of the object"""
18+
raise NotImplementedError
19+
20+
@classmethod
21+
def optionally_decode(
22+
cls: Type[T],
23+
data: Optional[Any],
24+
session: Session
25+
) -> Optional[T]:
26+
"""Optionally return a decoded object from serialised data"""
27+
if data is None:
28+
return None
29+
return cls.decode(data, session)
30+
31+
@classmethod
32+
def decode_many(cls: Type[T], data: Any, session: Session) -> List[T]:
33+
"""Return list of decoded instances of an object"""
34+
return [cls.decode(d, session) for d in data]
35+
36+
@classmethod
37+
def optionally_decode_many(
38+
cls: Type[T],
39+
data: Optional[Any],
40+
session: Session,
41+
default_to_empty_list: bool = False
42+
) -> Optional[List[T]]:
43+
"""Optionally return a list of decoded objects"""
44+
if data is None and default_to_empty_list is True:
45+
return list()
46+
if data is None:
47+
return None
48+
return cls.decode_many(data, session)

amatino/internal/url_target.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,7 @@ def __init__(self, key: str, value: str) -> None:
1818
if not isinstance(key, str):
1919
raise TypeError('Key must be of type `str`')
2020

21-
if not isinstance(value, str):
22-
raise TypeError('value must be of type `str`')
21+
value = str(value)
2322

2423
self.key = key
2524
self.value = value

amatino/tests/primary/entity.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,5 +72,12 @@ def execute(self) -> None:
7272
self.record_failure('Entity name not updated: ' + str(entity.name))
7373
return
7474

75+
listed_entities = Entity.retrieve_list(
76+
session=self.session
77+
)
78+
79+
assert isinstance(listed_entities, list)
80+
assert len(listed_entities) > 0
81+
7582
self.record_success()
7683
return

0 commit comments

Comments
 (0)