aspyx-service 0.10.4__tar.gz → 0.10.5__tar.gz

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 aspyx-service might be problematic. Click here for more details.

Files changed (34) hide show
  1. {aspyx_service-0.10.4 → aspyx_service-0.10.5}/PKG-INFO +28 -11
  2. {aspyx_service-0.10.4 → aspyx_service-0.10.5}/README.md +26 -8
  3. {aspyx_service-0.10.4 → aspyx_service-0.10.5}/performance-test/client.py +4 -3
  4. aspyx_service-0.10.5/performance-test/main.py +34 -0
  5. {aspyx_service-0.10.4 → aspyx_service-0.10.5}/performance-test/performance-test.py +14 -8
  6. {aspyx_service-0.10.4 → aspyx_service-0.10.5}/performance-test/server.py +19 -5
  7. {aspyx_service-0.10.4 → aspyx_service-0.10.5}/pyproject.toml +2 -3
  8. {aspyx_service-0.10.4 → aspyx_service-0.10.5}/src/aspyx_service/__init__.py +8 -7
  9. {aspyx_service-0.10.4 → aspyx_service-0.10.5}/src/aspyx_service/authorization.py +4 -13
  10. {aspyx_service-0.10.4 → aspyx_service-0.10.5}/src/aspyx_service/channels.py +9 -7
  11. {aspyx_service-0.10.4 → aspyx_service-0.10.5}/src/aspyx_service/restchannel.py +2 -6
  12. {aspyx_service-0.10.4 → aspyx_service-0.10.5}/src/aspyx_service/server.py +94 -32
  13. aspyx_service-0.10.5/src/aspyx_service/session.py +136 -0
  14. {aspyx_service-0.10.4 → aspyx_service-0.10.5}/tests/common.py +59 -25
  15. {aspyx_service-0.10.4 → aspyx_service-0.10.5}/tests/test_async_service.py +8 -3
  16. {aspyx_service-0.10.4 → aspyx_service-0.10.5}/tests/test_jwt.py +38 -19
  17. {aspyx_service-0.10.4 → aspyx_service-0.10.5}/tests/test_serialization.py +1 -1
  18. aspyx_service-0.10.4/performance-test/main.py +0 -23
  19. aspyx_service-0.10.4/src/aspyx_service/serialization.py +0 -137
  20. aspyx_service-0.10.4/src/aspyx_service/session.py +0 -97
  21. {aspyx_service-0.10.4 → aspyx_service-0.10.5}/.gitignore +0 -0
  22. {aspyx_service-0.10.4 → aspyx_service-0.10.5}/LICENSE +0 -0
  23. {aspyx_service-0.10.4 → aspyx_service-0.10.5}/performance-test/__init__.py +0 -0
  24. {aspyx_service-0.10.4 → aspyx_service-0.10.5}/performance-test/config.yaml +0 -0
  25. {aspyx_service-0.10.4 → aspyx_service-0.10.5}/performance-test/readme.txt +0 -0
  26. {aspyx_service-0.10.4 → aspyx_service-0.10.5}/performance-test/start_server_8000.sh +0 -0
  27. {aspyx_service-0.10.4 → aspyx_service-0.10.5}/performance-test/start_server_8001.sh +0 -0
  28. {aspyx_service-0.10.4 → aspyx_service-0.10.5}/src/aspyx_service/healthcheck.py +0 -0
  29. {aspyx_service-0.10.4 → aspyx_service-0.10.5}/src/aspyx_service/registries.py +0 -0
  30. {aspyx_service-0.10.4 → aspyx_service-0.10.5}/src/aspyx_service/service.py +0 -0
  31. {aspyx_service-0.10.4 → aspyx_service-0.10.5}/tests/__init__.py +0 -0
  32. {aspyx_service-0.10.4 → aspyx_service-0.10.5}/tests/config.yaml +0 -0
  33. {aspyx_service-0.10.4 → aspyx_service-0.10.5}/tests/test_healthcheck.py +0 -0
  34. {aspyx_service-0.10.4 → aspyx_service-0.10.5}/tests/test_service.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aspyx_service
3
- Version: 0.10.4
3
+ Version: 0.10.5
4
4
  Summary: Aspyx Service framework
5
5
  Author-email: Andreas Ernst <andreas.ernst7@gmail.com>
6
6
  License: MIT License
@@ -26,8 +26,7 @@ License: MIT License
26
26
  SOFTWARE.
27
27
  License-File: LICENSE
28
28
  Requires-Python: >=3.9
29
- Requires-Dist: aspyx>=1.5.3
30
- Requires-Dist: cachetools~=5.5.2
29
+ Requires-Dist: aspyx>=1.6.0
31
30
  Requires-Dist: fastapi~=0.115.13
32
31
  Requires-Dist: httpx~=0.28.1
33
32
  Requires-Dist: msgpack~=1.1.1
@@ -110,18 +109,20 @@ class TestComponent(Component):
110
109
  After booting the DI infrastructure with a main module we could already call a service:
111
110
 
112
111
  **Example**:
112
+
113
113
  ```python
114
114
  @module(imports=[ServiceModule])
115
115
  class Module:
116
116
  def __init__(self):
117
117
  pass
118
-
118
+
119
119
  @create()
120
120
  def create_registry(self) -> ConsulComponentRegistry:
121
- return ConsulComponentRegistry(Server.port, Consul(host="localhost", port=8500)) # a consul based registry!
121
+ return ConsulComponentRegistry(Server.port, Consul(host="localhost", port=8500)) # a consul based registry!
122
+
122
123
 
123
124
  environment = Environment(Module)
124
- service_manager = environment.get(ServiceManager)
125
+ service_manager = environment.read(ServiceManager)
125
126
 
126
127
  service = service_manager.get_service(TestService)
127
128
 
@@ -202,6 +203,14 @@ The library offers:
202
203
  As well as the DI and AOP core, all mechanisms are heavily optimized.
203
204
  A simple benchmark resulted in message roundtrips in significanlty under a ms per call.
204
205
 
206
+ ## Installation
207
+
208
+ Just install from PyPI with
209
+
210
+ `pip install aspyx-service`
211
+
212
+ The library is tested with all Python version >= 3.9
213
+
205
214
  Let's see some details
206
215
 
207
216
  ## Service and Component declaration
@@ -484,18 +493,26 @@ class ChannelAdvice:
484
493
 
485
494
  ## FastAPI server
486
495
 
487
- In order to expose components via HTTP, the corresponding infrastructure in form of a FastAPI server needs to be setup.
496
+ The required - `FastAPI` - infrastructure to expose those services requires:
488
497
 
498
+ - a `FastAPI` instance
499
+ - an injectable `FastAPIServer`
500
+ - and a final `boot` call with the root module, which will return an `Environment`
489
501
 
490
502
  ```python
491
- @module()
492
- class Module():
503
+ fast_api = FastAPI() # so you can run it with uvivorn from command-line
504
+
505
+ @module(imports=[ServiceModule])
506
+ class Module:
493
507
  def __init__(self):
494
508
  pass
509
+
510
+ @create()
511
+ def create_server(self, service_manager: ServiceManager, component_registry: ComponentRegistry) -> FastAPIServer:
512
+ return FastAPIServer(fastapi, service_manager, component_registry)
495
513
 
496
- server = FastAPIServer(host="0.0.0.0", port=8000)
497
514
 
498
- environment = server.boot(Module) # will start the http server
515
+ environment = FastAPIServer.boot(Moudle, host="0.0.0.0", port=8000)
499
516
  ```
500
517
 
501
518
  This setup will also expose all service interfaces decorated with the corresponding http decorators!
@@ -73,18 +73,20 @@ class TestComponent(Component):
73
73
  After booting the DI infrastructure with a main module we could already call a service:
74
74
 
75
75
  **Example**:
76
+
76
77
  ```python
77
78
  @module(imports=[ServiceModule])
78
79
  class Module:
79
80
  def __init__(self):
80
81
  pass
81
-
82
+
82
83
  @create()
83
84
  def create_registry(self) -> ConsulComponentRegistry:
84
- return ConsulComponentRegistry(Server.port, Consul(host="localhost", port=8500)) # a consul based registry!
85
+ return ConsulComponentRegistry(Server.port, Consul(host="localhost", port=8500)) # a consul based registry!
86
+
85
87
 
86
88
  environment = Environment(Module)
87
- service_manager = environment.get(ServiceManager)
89
+ service_manager = environment.read(ServiceManager)
88
90
 
89
91
  service = service_manager.get_service(TestService)
90
92
 
@@ -165,6 +167,14 @@ The library offers:
165
167
  As well as the DI and AOP core, all mechanisms are heavily optimized.
166
168
  A simple benchmark resulted in message roundtrips in significanlty under a ms per call.
167
169
 
170
+ ## Installation
171
+
172
+ Just install from PyPI with
173
+
174
+ `pip install aspyx-service`
175
+
176
+ The library is tested with all Python version >= 3.9
177
+
168
178
  Let's see some details
169
179
 
170
180
  ## Service and Component declaration
@@ -447,18 +457,26 @@ class ChannelAdvice:
447
457
 
448
458
  ## FastAPI server
449
459
 
450
- In order to expose components via HTTP, the corresponding infrastructure in form of a FastAPI server needs to be setup.
460
+ The required - `FastAPI` - infrastructure to expose those services requires:
451
461
 
462
+ - a `FastAPI` instance
463
+ - an injectable `FastAPIServer`
464
+ - and a final `boot` call with the root module, which will return an `Environment`
452
465
 
453
466
  ```python
454
- @module()
455
- class Module():
467
+ fast_api = FastAPI() # so you can run it with uvivorn from command-line
468
+
469
+ @module(imports=[ServiceModule])
470
+ class Module:
456
471
  def __init__(self):
457
472
  pass
473
+
474
+ @create()
475
+ def create_server(self, service_manager: ServiceManager, component_registry: ComponentRegistry) -> FastAPIServer:
476
+ return FastAPIServer(fastapi, service_manager, component_registry)
458
477
 
459
- server = FastAPIServer(host="0.0.0.0", port=8000)
460
478
 
461
- environment = server.boot(Module) # will start the http server
479
+ environment = FastAPIServer.boot(Moudle, host="0.0.0.0", port=8000)
462
480
  ```
463
481
 
464
482
  This setup will also expose all service interfaces decorated with the corresponding http decorators!
@@ -7,11 +7,12 @@ from dataclasses import dataclass
7
7
 
8
8
  from pydantic import BaseModel
9
9
 
10
- from aspyx.di import module
11
-
12
10
  from aspyx_service import ServiceModule, delete, post, put, get, rest, Body
13
11
  from aspyx_service.service import component, Component, Service, service
14
12
 
13
+ from aspyx.di import module
14
+
15
+
15
16
  class Pydantic(BaseModel):
16
17
  i: int
17
18
  f: float
@@ -151,4 +152,4 @@ class TestComponent(Component): # pylint: disable=abstract-method
151
152
  @module(imports=[ServiceModule])
152
153
  class ClientModule:
153
154
  def __init__(self):
154
- pass
155
+ pass
@@ -0,0 +1,34 @@
1
+ """
2
+ the server hosting the test services
3
+ """
4
+ import logging
5
+ import os
6
+
7
+ from fastapi import FastAPI
8
+
9
+ from aspyx_service import FastAPIServer, RequestContext
10
+ from server import ServerModule
11
+ from aspyx.util import Logger
12
+
13
+
14
+ Logger.configure(default_level=logging.DEBUG, levels={
15
+ "httpx": logging.ERROR,
16
+ "aspyx.di": logging.ERROR,
17
+ "aspyx.di.aop": logging.ERROR,
18
+ "aspyx.service": logging.ERROR
19
+ })
20
+
21
+ PORT = int(os.getenv("FAST_API_PORT", "8000"))
22
+
23
+ app = FastAPI()
24
+
25
+ app.add_middleware(RequestContext)
26
+ #app.add_middleware(TokenContextMiddleware)
27
+
28
+ ServerModule.fastapi = app
29
+
30
+ FastAPIServer.boot(ServerModule, host="0.0.0.0", port=PORT, start_thread= False)
31
+
32
+ if __name__ == "__main__":
33
+ import uvicorn
34
+ uvicorn.run("main:app", host="0.0.0.0", port=PORT, reload=True, log_level="warning", access_log=False)
@@ -10,18 +10,20 @@ from typing import Callable, TypeVar, Type, Awaitable, Any, Dict, cast
10
10
 
11
11
  from consul import Consul
12
12
 
13
+ from aspyx_service import ConsulComponentRegistry, SessionManager
14
+
13
15
  from aspyx.di import module, Environment, create
14
16
  from aspyx.di.aop import advice, around, methods, Invocation
15
17
  from aspyx.util import Logger
16
18
 
17
- from aspyx_service import ConsulComponentRegistry
19
+
18
20
  from aspyx_service.service import ServiceManager, ComponentRegistry, Channel
19
21
 
20
- Logger.configure(default_level=logging.DEBUG, levels={
21
- "httpx": logging.ERROR,
22
- "aspyx.di": logging.ERROR,
23
- "aspyx.di.aop": logging.ERROR,
24
- "aspyx.service": logging.ERROR
22
+ Logger.configure(default_level=logging.INFO, levels={
23
+ "httpx": logging.CRITICAL,
24
+ "aspyx.di": logging.INFO,
25
+ "aspyx.di.aop": logging.INFO,
26
+ "aspyx.service": logging.INFO
25
27
  })
26
28
 
27
29
  from client import TestService, TestRestService, Pydantic, Data, TestAsyncRestService, TestAsyncService, ClientModule
@@ -46,6 +48,10 @@ class TestModule:
46
48
  def __init__(self):
47
49
  pass
48
50
 
51
+ @create()
52
+ def create_session_storage(self) -> SessionManager.Storage:
53
+ return SessionManager.InMemoryStorage(max_size=1000, ttl=3600)
54
+
49
55
  @create()
50
56
  def create_registry(self) -> ComponentRegistry:
51
57
  return ConsulComponentRegistry(port=8000, consul=Consul(host="localhost", port=8500))
@@ -207,7 +213,7 @@ async def main():
207
213
  # async
208
214
 
209
215
  await run_async_loops("async rest", loops, TestAsyncRestService, manager.get_service(TestAsyncRestService, preferred_channel="rest"),
210
- lambda service: service.get("world"))
216
+ lambda service: service.get("world"))
211
217
  await run_async_loops("async json", loops, TestAsyncService, manager.get_service(TestAsyncService, preferred_channel="dispatch-json"),
212
218
  lambda service: service.hello("world"))
213
219
  await run_async_loops("async msgpack", loops, TestAsyncService, manager.get_service(TestAsyncService, preferred_channel="dispatch-msgpack"),
@@ -276,4 +282,4 @@ async def main():
276
282
  lambda service: service.hello("world"))
277
283
 
278
284
  if __name__ == "__main__":
279
- asyncio.run(main())
285
+ asyncio.run(main())
@@ -5,15 +5,19 @@ import os
5
5
  from typing import Optional
6
6
 
7
7
  from consul import Consul
8
+ from fastapi import FastAPI
8
9
 
10
+ from aspyx_service import HealthCheckManager, ServiceModule, ConsulComponentRegistry, SessionManager, FastAPIServer
11
+
12
+ from client import ClientModule, TestService, TestAsyncService, Data, Pydantic, TestRestService, TestAsyncRestService, TestComponent
13
+
14
+ from aspyx_service.service import ChannelAddress, Server, \
15
+ component_services, AbstractComponent, implementation, health, ComponentRegistry, ServiceManager
9
16
  from aspyx.di import on_running, module, create
10
17
  from aspyx.di.aop import Invocation, advice, error
11
18
  from aspyx.exception import handle, ExceptionManager
12
- from aspyx_service import HealthCheckManager, ServiceModule, ConsulComponentRegistry
13
- from aspyx_service.service import ChannelAddress, Server, \
14
- component_services, AbstractComponent, implementation, health, ComponentRegistry
15
19
 
16
- from client import ClientModule, TestService, TestAsyncService, Data, Pydantic, TestRestService, TestAsyncRestService, TestComponent
20
+
17
21
 
18
22
 
19
23
  # implementation classes
@@ -142,6 +146,8 @@ class TestComponentImpl(AbstractComponent, TestComponent):
142
146
 
143
147
  @module(imports=[ClientModule, ServiceModule])
144
148
  class ServerModule:
149
+ fastapi: Optional[FastAPI] = None
150
+
145
151
  def __init__(self):
146
152
  pass
147
153
 
@@ -149,6 +155,14 @@ class ServerModule:
149
155
  #def create_yaml_source(self) -> YamlConfigurationSource:
150
156
  # return YamlConfigurationSource(f"{Path(__file__).parent}/config.yaml")
151
157
 
158
+ @create()
159
+ def create_server(self, service_manager: ServiceManager, component_registry: ComponentRegistry) -> FastAPIServer:
160
+ return FastAPIServer(self.fastapi, service_manager, component_registry)
161
+
162
+ @create()
163
+ def create_session_storage(self) -> SessionManager.Storage:
164
+ return SessionManager.InMemoryStorage(max_size=1000, ttl=3600)
165
+
152
166
  @create()
153
167
  def create_registry(self) -> ComponentRegistry:
154
- return ConsulComponentRegistry(port=int(os.getenv("FAST_API_PORT", 8000)), consul=Consul(host="localhost", port=8500))
168
+ return ConsulComponentRegistry(port=int(os.getenv("FAST_API_PORT", "8000")), consul=Consul(host="localhost", port=8500))
@@ -2,19 +2,18 @@
2
2
 
3
3
  [project]
4
4
  name = "aspyx_service"
5
- version = "0.10.4"
5
+ version = "0.10.5"
6
6
  description = "Aspyx Service framework"
7
7
  authors = [{ name = "Andreas Ernst", email = "andreas.ernst7@gmail.com" }]
8
8
  readme = "README.md"
9
9
  license = { file = "LICENSE" }
10
10
  requires-python = ">=3.9"
11
11
  dependencies = [
12
- "aspyx>=1.5.3",
12
+ "aspyx>=1.6.0",
13
13
  "python-consul2~=0.1.5",
14
14
  "fastapi~=0.115.13",
15
15
  "httpx~=0.28.1",
16
16
  "msgpack~=1.1.1",
17
- "cachetools~= 5.5.2",
18
17
  "uvicorn[standard]"
19
18
  ]
20
19
 
@@ -7,12 +7,11 @@ from aspyx.di import module
7
7
  from .service import AuthorizationException, MissingTokenException, RemoteServiceException, ServiceCommunicationException, TokenException, TokenExpiredException, InvalidTokenException, component_services, ServiceException, Server, Channel, ComponentDescriptor, inject_service, ChannelAddress, ChannelInstances, ServiceManager, Component, Service, AbstractComponent, ComponentStatus, ComponentRegistry, implementation, health, component, service
8
8
  from .channels import HTTPXChannel, DispatchJSONChannel, TokenContext
9
9
  from .registries import ConsulComponentRegistry
10
- from .server import FastAPIServer, RequestContext
10
+ from .server import FastAPIServer, RequestContext, ResponseContext, TokenContextMiddleware
11
11
  from .healthcheck import health_checks, health_check, HealthCheckManager, HealthStatus
12
12
  from .restchannel import RestChannel, post, get, put, delete, QueryParam, Body, rest
13
- from .session import Session, SessionManager
14
- from .authorization import AuthorizationManager, AbstractAuthorizationFactory, AuthorizationException
15
-
13
+ from .session import Session, SessionManager, SessionContext
14
+ from .authorization import AuthorizationManager, AbstractAuthorizationFactory
16
15
 
17
16
  @module()
18
17
  class ServiceModule:
@@ -53,12 +52,12 @@ __all__ = [
53
52
 
54
53
  "AuthorizationManager",
55
54
  "AbstractAuthorizationFactory",
56
- "AuthorizationException",
57
55
 
58
56
  # session
59
57
 
60
58
  "Session",
61
59
  "SessionManager",
60
+ "SessionContext",
62
61
 
63
62
  # healthcheck
64
63
 
@@ -91,10 +90,12 @@ __all__ = [
91
90
  # registries
92
91
 
93
92
  "ConsulComponentRegistry",
94
- "RequestContext",
95
93
 
96
94
  # server
97
95
 
98
96
  "FastAPIServer",
99
- "RequestContext"
97
+ "RequestContext",
98
+ "ResponseContext",
99
+ "TokenContext",
100
+ "TokenContextMiddleware",
100
101
  ]
@@ -9,13 +9,6 @@ from aspyx.di import injectable, inject, order
9
9
  from aspyx.di.aop import Invocation
10
10
  from aspyx.reflection import TypeDescriptor, Decorators
11
11
 
12
-
13
- class AuthorizationException(Exception):
14
- """
15
- Any authorization exception
16
- """
17
- pass
18
-
19
12
  def get_method_class(method):
20
13
  if inspect.ismethod(method) or inspect.isfunction(method):
21
14
  qualname = method.__qualname__
@@ -38,11 +31,10 @@ class AuthorizationManager:
38
31
  """
39
32
  Base class for authorization checks
40
33
  """
41
- def check(self, invocation: Invocation):
34
+ def authorize(self, invocation: Invocation):
42
35
  """
43
- execute the authorization check. Throws an exception in case of violations,
36
+ execute the authorization check. Throws an exception in case of violations
44
37
  """
45
- pass
46
38
 
47
39
  class AuthorizationFactory(ABC):
48
40
  """
@@ -62,7 +54,6 @@ class AuthorizationManager:
62
54
  Returns:
63
55
  an authorization check or None
64
56
  """
65
- pass
66
57
 
67
58
  # constructor
68
59
 
@@ -111,9 +102,9 @@ class AuthorizationManager:
111
102
 
112
103
  return checks
113
104
 
114
- def check(self, invocation: Invocation) -> Optional[Authorization]:
105
+ def authorize(self, invocation: Invocation):
115
106
  for check in self.get_checks(invocation.func):
116
- check.check(invocation)
107
+ check.authorize(invocation)
117
108
 
118
109
  class AbstractAuthorizationFactory(AuthorizationManager.AuthorizationFactory):
119
110
  """
@@ -16,11 +16,11 @@ from pydantic import BaseModel
16
16
  from aspyx.di.configuration import inject_value
17
17
  from aspyx.reflection import DynamicProxy, TypeDescriptor
18
18
  from aspyx.threading import ThreadLocal, ContextLocal
19
+ from aspyx.util import get_deserializer, TypeDeserializer, TypeSerializer, get_serializer
19
20
  from .service import ServiceManager, ServiceCommunicationException, TokenExpiredException, InvalidTokenException, \
20
21
  AuthorizationException, MissingTokenException
21
22
 
22
23
  from .service import ComponentDescriptor, ChannelInstances, ServiceException, channel, Channel, RemoteServiceException
23
- from .serialization import get_deserializer, TypeDeserializer, TypeSerializer, get_serializer
24
24
 
25
25
  class TokenContext:
26
26
  """
@@ -208,10 +208,11 @@ class HTTPXChannel(Channel):
208
208
  if "invalid_token" in www_auth:
209
209
  if 'expired' in www_auth:
210
210
  raise TokenExpiredException() from e
211
- elif 'missing' in www_auth:
211
+
212
+ if 'missing' in www_auth:
212
213
  raise MissingTokenException() from e
213
- else:
214
- raise InvalidTokenException() from e
214
+
215
+ raise InvalidTokenException() from e
215
216
 
216
217
  raise AuthorizationException(str(e)) from e
217
218
  except httpx.HTTPError as e:
@@ -451,10 +452,11 @@ class DispatchMSPackChannel(HTTPXChannel):
451
452
  if "invalid_token" in www_auth:
452
453
  if 'expired' in www_auth:
453
454
  raise TokenExpiredException() from e
454
- elif 'missing' in www_auth:
455
+
456
+ if 'missing' in www_auth:
455
457
  raise MissingTokenException() from e
456
- else:
457
- raise InvalidTokenException() from e
458
+
459
+ raise InvalidTokenException() from e
458
460
 
459
461
  raise RemoteServiceException(str(e)) from e
460
462
 
@@ -6,19 +6,15 @@ import re
6
6
  from dataclasses import is_dataclass
7
7
 
8
8
  from typing import get_type_hints, TypeVar, Annotated, Callable, get_origin, get_args, Type
9
-
10
-
11
9
  from pydantic import BaseModel
12
10
 
13
- from .channels import HTTPXChannel
14
-
15
11
  from aspyx.reflection import DynamicProxy, Decorators
12
+
13
+ from .channels import HTTPXChannel
16
14
  from .service import channel, ServiceCommunicationException
17
15
 
18
16
  T = TypeVar("T")
19
17
 
20
-
21
-
22
18
  class BodyMarker:
23
19
  pass
24
20