stac-fastapi-pgstac 6.1.0__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.
- stac_fastapi/pgstac/__init__.py +1 -0
- stac_fastapi/pgstac/app.py +217 -0
- stac_fastapi/pgstac/config.py +225 -0
- stac_fastapi/pgstac/core.py +677 -0
- stac_fastapi/pgstac/db.py +153 -0
- stac_fastapi/pgstac/extensions/__init__.py +7 -0
- stac_fastapi/pgstac/extensions/filter.py +40 -0
- stac_fastapi/pgstac/extensions/free_text.py +31 -0
- stac_fastapi/pgstac/extensions/query.py +47 -0
- stac_fastapi/pgstac/models/__init__.py +1 -0
- stac_fastapi/pgstac/models/links.py +339 -0
- stac_fastapi/pgstac/transactions.py +370 -0
- stac_fastapi/pgstac/types/base_item_cache.py +56 -0
- stac_fastapi/pgstac/types/search.py +26 -0
- stac_fastapi/pgstac/utils.py +114 -0
- stac_fastapi/pgstac/version.py +3 -0
- stac_fastapi_pgstac-6.1.0.dist-info/METADATA +140 -0
- stac_fastapi_pgstac-6.1.0.dist-info/RECORD +20 -0
- stac_fastapi_pgstac-6.1.0.dist-info/WHEEL +4 -0
- stac_fastapi_pgstac-6.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""stac_fastapi.pgstac module."""
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""FastAPI application using PGStac.
|
|
2
|
+
|
|
3
|
+
Enables the extensions specified as a comma-delimited list in
|
|
4
|
+
the ENABLED_EXTENSIONS environment variable (e.g. `transactions,sort,query`).
|
|
5
|
+
If the variable is not set, enables all extensions.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
from contextlib import asynccontextmanager
|
|
10
|
+
|
|
11
|
+
from brotli_asgi import BrotliMiddleware
|
|
12
|
+
from fastapi import APIRouter, FastAPI
|
|
13
|
+
from stac_fastapi.api.app import StacApi
|
|
14
|
+
from stac_fastapi.api.middleware import CORSMiddleware, ProxyHeaderMiddleware
|
|
15
|
+
from stac_fastapi.api.models import (
|
|
16
|
+
EmptyRequest,
|
|
17
|
+
ItemCollectionUri,
|
|
18
|
+
JSONResponse,
|
|
19
|
+
create_get_request_model,
|
|
20
|
+
create_post_request_model,
|
|
21
|
+
create_request_model,
|
|
22
|
+
)
|
|
23
|
+
from stac_fastapi.extensions.core import (
|
|
24
|
+
CollectionSearchExtension,
|
|
25
|
+
CollectionSearchFilterExtension,
|
|
26
|
+
FieldsExtension,
|
|
27
|
+
ItemCollectionFilterExtension,
|
|
28
|
+
OffsetPaginationExtension,
|
|
29
|
+
SearchFilterExtension,
|
|
30
|
+
SortExtension,
|
|
31
|
+
TokenPaginationExtension,
|
|
32
|
+
TransactionExtension,
|
|
33
|
+
)
|
|
34
|
+
from stac_fastapi.extensions.core.fields import FieldsConformanceClasses
|
|
35
|
+
from stac_fastapi.extensions.core.free_text import FreeTextConformanceClasses
|
|
36
|
+
from stac_fastapi.extensions.core.query import QueryConformanceClasses
|
|
37
|
+
from stac_fastapi.extensions.core.sort import SortConformanceClasses
|
|
38
|
+
from stac_fastapi.extensions.third_party import BulkTransactionExtension
|
|
39
|
+
from starlette.middleware import Middleware
|
|
40
|
+
|
|
41
|
+
from stac_fastapi.pgstac.config import Settings
|
|
42
|
+
from stac_fastapi.pgstac.core import CoreCrudClient, health_check
|
|
43
|
+
from stac_fastapi.pgstac.db import close_db_connection, connect_to_db
|
|
44
|
+
from stac_fastapi.pgstac.extensions import FreeTextExtension, QueryExtension
|
|
45
|
+
from stac_fastapi.pgstac.extensions.filter import FiltersClient
|
|
46
|
+
from stac_fastapi.pgstac.transactions import BulkTransactionsClient, TransactionsClient
|
|
47
|
+
from stac_fastapi.pgstac.types.search import PgstacSearch
|
|
48
|
+
|
|
49
|
+
settings = Settings()
|
|
50
|
+
|
|
51
|
+
# search extensions
|
|
52
|
+
search_extensions_map = {
|
|
53
|
+
"query": QueryExtension(),
|
|
54
|
+
"sort": SortExtension(),
|
|
55
|
+
"fields": FieldsExtension(),
|
|
56
|
+
"filter": SearchFilterExtension(client=FiltersClient()),
|
|
57
|
+
"pagination": TokenPaginationExtension(),
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
# collection_search extensions
|
|
61
|
+
cs_extensions_map = {
|
|
62
|
+
"query": QueryExtension(conformance_classes=[QueryConformanceClasses.COLLECTIONS]),
|
|
63
|
+
"sort": SortExtension(conformance_classes=[SortConformanceClasses.COLLECTIONS]),
|
|
64
|
+
"fields": FieldsExtension(conformance_classes=[FieldsConformanceClasses.COLLECTIONS]),
|
|
65
|
+
"filter": CollectionSearchFilterExtension(client=FiltersClient()),
|
|
66
|
+
"free_text": FreeTextExtension(
|
|
67
|
+
conformance_classes=[FreeTextConformanceClasses.COLLECTIONS],
|
|
68
|
+
),
|
|
69
|
+
"pagination": OffsetPaginationExtension(),
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
# item_collection extensions
|
|
73
|
+
itm_col_extensions_map = {
|
|
74
|
+
"query": QueryExtension(
|
|
75
|
+
conformance_classes=[QueryConformanceClasses.ITEMS],
|
|
76
|
+
),
|
|
77
|
+
"sort": SortExtension(
|
|
78
|
+
conformance_classes=[SortConformanceClasses.ITEMS],
|
|
79
|
+
),
|
|
80
|
+
"fields": FieldsExtension(conformance_classes=[FieldsConformanceClasses.ITEMS]),
|
|
81
|
+
"filter": ItemCollectionFilterExtension(client=FiltersClient()),
|
|
82
|
+
"pagination": TokenPaginationExtension(),
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
enabled_extensions = {
|
|
86
|
+
*search_extensions_map.keys(),
|
|
87
|
+
*cs_extensions_map.keys(),
|
|
88
|
+
*itm_col_extensions_map.keys(),
|
|
89
|
+
"collection_search",
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if ext := os.environ.get("ENABLED_EXTENSIONS"):
|
|
93
|
+
enabled_extensions = set(ext.split(","))
|
|
94
|
+
|
|
95
|
+
application_extensions = []
|
|
96
|
+
|
|
97
|
+
with_transactions = os.environ.get("ENABLE_TRANSACTIONS_EXTENSIONS", "").lower() in [
|
|
98
|
+
"yes",
|
|
99
|
+
"true",
|
|
100
|
+
"1",
|
|
101
|
+
]
|
|
102
|
+
if with_transactions:
|
|
103
|
+
application_extensions.append(
|
|
104
|
+
TransactionExtension(
|
|
105
|
+
client=TransactionsClient(),
|
|
106
|
+
settings=settings,
|
|
107
|
+
response_class=JSONResponse,
|
|
108
|
+
),
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
application_extensions.append(
|
|
112
|
+
BulkTransactionExtension(client=BulkTransactionsClient()),
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
# /search models
|
|
116
|
+
search_extensions = [
|
|
117
|
+
extension
|
|
118
|
+
for key, extension in search_extensions_map.items()
|
|
119
|
+
if key in enabled_extensions
|
|
120
|
+
]
|
|
121
|
+
post_request_model = create_post_request_model(search_extensions, base_model=PgstacSearch)
|
|
122
|
+
get_request_model = create_get_request_model(search_extensions)
|
|
123
|
+
application_extensions.extend(search_extensions)
|
|
124
|
+
|
|
125
|
+
# /collections/{collectionId}/items model
|
|
126
|
+
items_get_request_model = ItemCollectionUri
|
|
127
|
+
itm_col_extensions = [
|
|
128
|
+
extension
|
|
129
|
+
for key, extension in itm_col_extensions_map.items()
|
|
130
|
+
if key in enabled_extensions
|
|
131
|
+
]
|
|
132
|
+
if itm_col_extensions:
|
|
133
|
+
items_get_request_model = create_request_model(
|
|
134
|
+
model_name="ItemCollectionUri",
|
|
135
|
+
base_model=ItemCollectionUri,
|
|
136
|
+
extensions=itm_col_extensions,
|
|
137
|
+
request_type="GET",
|
|
138
|
+
)
|
|
139
|
+
application_extensions.extend(itm_col_extensions)
|
|
140
|
+
|
|
141
|
+
# /collections model
|
|
142
|
+
collections_get_request_model = EmptyRequest
|
|
143
|
+
if "collection_search" in enabled_extensions:
|
|
144
|
+
cs_extensions = [
|
|
145
|
+
extension
|
|
146
|
+
for key, extension in cs_extensions_map.items()
|
|
147
|
+
if key in enabled_extensions
|
|
148
|
+
]
|
|
149
|
+
collection_search_extension = CollectionSearchExtension.from_extensions(cs_extensions)
|
|
150
|
+
collections_get_request_model = collection_search_extension.GET
|
|
151
|
+
application_extensions.append(collection_search_extension)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@asynccontextmanager
|
|
155
|
+
async def lifespan(app: FastAPI):
|
|
156
|
+
"""FastAPI Lifespan."""
|
|
157
|
+
await connect_to_db(app, add_write_connection_pool=with_transactions)
|
|
158
|
+
yield
|
|
159
|
+
await close_db_connection(app)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
api = StacApi(
|
|
163
|
+
app=FastAPI(
|
|
164
|
+
openapi_url=settings.openapi_url,
|
|
165
|
+
docs_url=settings.docs_url,
|
|
166
|
+
redoc_url=None,
|
|
167
|
+
root_path=settings.root_path,
|
|
168
|
+
title=settings.stac_fastapi_title,
|
|
169
|
+
version=settings.stac_fastapi_version,
|
|
170
|
+
description=settings.stac_fastapi_description,
|
|
171
|
+
lifespan=lifespan,
|
|
172
|
+
),
|
|
173
|
+
router=APIRouter(prefix=settings.prefix_path),
|
|
174
|
+
settings=settings,
|
|
175
|
+
extensions=application_extensions,
|
|
176
|
+
client=CoreCrudClient(pgstac_search_model=post_request_model),
|
|
177
|
+
response_class=JSONResponse,
|
|
178
|
+
items_get_request_model=items_get_request_model,
|
|
179
|
+
search_get_request_model=get_request_model,
|
|
180
|
+
search_post_request_model=post_request_model,
|
|
181
|
+
collections_get_request_model=collections_get_request_model,
|
|
182
|
+
middlewares=[
|
|
183
|
+
Middleware(BrotliMiddleware),
|
|
184
|
+
Middleware(ProxyHeaderMiddleware),
|
|
185
|
+
Middleware(
|
|
186
|
+
CORSMiddleware,
|
|
187
|
+
allow_origins=settings.cors_origins,
|
|
188
|
+
allow_origin_regex=settings.cors_origin_regex,
|
|
189
|
+
allow_methods=settings.cors_methods,
|
|
190
|
+
allow_credentials=settings.cors_credentials,
|
|
191
|
+
allow_headers=settings.cors_headers,
|
|
192
|
+
),
|
|
193
|
+
],
|
|
194
|
+
health_check=health_check,
|
|
195
|
+
)
|
|
196
|
+
app = api.app
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def run():
|
|
200
|
+
"""Run app from command line using uvicorn if available."""
|
|
201
|
+
try:
|
|
202
|
+
import uvicorn
|
|
203
|
+
|
|
204
|
+
uvicorn.run(
|
|
205
|
+
"stac_fastapi.pgstac.app:app",
|
|
206
|
+
host=settings.app_host,
|
|
207
|
+
port=settings.app_port,
|
|
208
|
+
log_level="info",
|
|
209
|
+
reload=settings.reload,
|
|
210
|
+
root_path=os.getenv("UVICORN_ROOT_PATH", ""),
|
|
211
|
+
)
|
|
212
|
+
except ImportError as e:
|
|
213
|
+
raise RuntimeError("Uvicorn must be installed in order to use command") from e
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
if __name__ == "__main__":
|
|
217
|
+
run()
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"""Postgres API configuration."""
|
|
2
|
+
|
|
3
|
+
import warnings
|
|
4
|
+
from typing import Annotated, Any, List, Optional, Sequence, Type
|
|
5
|
+
from urllib.parse import quote_plus as quote
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, BeforeValidator, Field, model_validator
|
|
8
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
9
|
+
from stac_fastapi.types.config import ApiSettings
|
|
10
|
+
from typing_extensions import Self
|
|
11
|
+
|
|
12
|
+
from stac_fastapi.pgstac.types.base_item_cache import (
|
|
13
|
+
BaseItemCache,
|
|
14
|
+
DefaultBaseItemCache,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
DEFAULT_INVALID_ID_CHARS = [
|
|
18
|
+
":",
|
|
19
|
+
"/",
|
|
20
|
+
"?",
|
|
21
|
+
"#",
|
|
22
|
+
"[",
|
|
23
|
+
"]",
|
|
24
|
+
"@",
|
|
25
|
+
"!",
|
|
26
|
+
"$",
|
|
27
|
+
"&",
|
|
28
|
+
"'",
|
|
29
|
+
"(",
|
|
30
|
+
")",
|
|
31
|
+
"*",
|
|
32
|
+
"+",
|
|
33
|
+
",",
|
|
34
|
+
";",
|
|
35
|
+
"=",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class ServerSettings(BaseModel):
|
|
40
|
+
"""Server runtime parameters.
|
|
41
|
+
|
|
42
|
+
Attributes:
|
|
43
|
+
search_path: Postgres search path. Defaults to "pgstac,public".
|
|
44
|
+
application_name: PgSTAC Application name. Defaults to 'pgstac'.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
search_path: str = "pgstac,public"
|
|
48
|
+
application_name: str = "pgstac"
|
|
49
|
+
|
|
50
|
+
model_config = SettingsConfigDict(extra="allow")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class PostgresSettings(BaseSettings):
|
|
54
|
+
"""Postgres-specific API settings.
|
|
55
|
+
|
|
56
|
+
Attributes:
|
|
57
|
+
pguser: postgres username.
|
|
58
|
+
pgpassword: postgres password.
|
|
59
|
+
pghost: hostname for the connection.
|
|
60
|
+
pgport: database port.
|
|
61
|
+
pgdatabase: database name.
|
|
62
|
+
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
postgres_user: Annotated[
|
|
66
|
+
Optional[str],
|
|
67
|
+
Field(
|
|
68
|
+
deprecated="`postgres_user` is deprecated, please use `pguser`", default=None
|
|
69
|
+
),
|
|
70
|
+
]
|
|
71
|
+
postgres_pass: Annotated[
|
|
72
|
+
Optional[str],
|
|
73
|
+
Field(
|
|
74
|
+
deprecated="`postgres_pass` is deprecated, please use `pgpassword`",
|
|
75
|
+
default=None,
|
|
76
|
+
),
|
|
77
|
+
]
|
|
78
|
+
postgres_host_reader: Annotated[
|
|
79
|
+
Optional[str],
|
|
80
|
+
Field(
|
|
81
|
+
deprecated="`postgres_host_reader` is deprecated, please use `pghost`",
|
|
82
|
+
default=None,
|
|
83
|
+
),
|
|
84
|
+
]
|
|
85
|
+
postgres_host_writer: Annotated[
|
|
86
|
+
Optional[str],
|
|
87
|
+
Field(
|
|
88
|
+
deprecated="`postgres_host_writer` is deprecated, please use `pghost`",
|
|
89
|
+
default=None,
|
|
90
|
+
),
|
|
91
|
+
]
|
|
92
|
+
postgres_port: Annotated[
|
|
93
|
+
Optional[int],
|
|
94
|
+
Field(
|
|
95
|
+
deprecated="`postgres_port` is deprecated, please use `pgport`", default=None
|
|
96
|
+
),
|
|
97
|
+
]
|
|
98
|
+
postgres_dbname: Annotated[
|
|
99
|
+
Optional[str],
|
|
100
|
+
Field(
|
|
101
|
+
deprecated="`postgres_dbname` is deprecated, please use `pgdatabase`",
|
|
102
|
+
default=None,
|
|
103
|
+
),
|
|
104
|
+
]
|
|
105
|
+
|
|
106
|
+
pguser: str
|
|
107
|
+
pgpassword: str
|
|
108
|
+
pghost: str
|
|
109
|
+
pgport: int
|
|
110
|
+
pgdatabase: str
|
|
111
|
+
|
|
112
|
+
db_min_conn_size: int = 1
|
|
113
|
+
db_max_conn_size: int = 10
|
|
114
|
+
db_max_queries: int = 50000
|
|
115
|
+
db_max_inactive_conn_lifetime: float = 300
|
|
116
|
+
|
|
117
|
+
server_settings: ServerSettings = ServerSettings()
|
|
118
|
+
|
|
119
|
+
model_config = {"env_file": ".env", "extra": "ignore"}
|
|
120
|
+
|
|
121
|
+
@model_validator(mode="before")
|
|
122
|
+
@classmethod
|
|
123
|
+
def _pg_settings_compat(cls, data: Any) -> Any:
|
|
124
|
+
if isinstance(data, dict):
|
|
125
|
+
compat = {
|
|
126
|
+
"postgres_user": "pguser",
|
|
127
|
+
"postgres_pass": "pgpassword",
|
|
128
|
+
"postgres_host_reader": "pghost",
|
|
129
|
+
"postgres_host_writer": "pghost",
|
|
130
|
+
"postgres_port": "pgport",
|
|
131
|
+
"postgres_dbname": "pgdatabase",
|
|
132
|
+
}
|
|
133
|
+
for old_key, new_key in compat.items():
|
|
134
|
+
if val := data.get(old_key, None):
|
|
135
|
+
warnings.warn(
|
|
136
|
+
f"`{old_key}` is deprecated, please use `{new_key}`",
|
|
137
|
+
DeprecationWarning,
|
|
138
|
+
stacklevel=1,
|
|
139
|
+
)
|
|
140
|
+
data[new_key] = val
|
|
141
|
+
|
|
142
|
+
if (pgh_reader := data.get("postgres_host_reader")) and (
|
|
143
|
+
pgh_writer := data.get("postgres_host_writer")
|
|
144
|
+
):
|
|
145
|
+
if pgh_reader != pgh_writer:
|
|
146
|
+
raise ValueError(
|
|
147
|
+
"In order to use different host values for reading and writing "
|
|
148
|
+
"you must explicitly provide write_postgres_settings to the connect_to_db function"
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
return data
|
|
152
|
+
|
|
153
|
+
@property
|
|
154
|
+
def connection_string(self):
|
|
155
|
+
"""Create reader psql connection string."""
|
|
156
|
+
return f"postgresql://{self.pguser}:{quote(self.pgpassword)}@{self.pghost}:{self.pgport}/{self.pgdatabase}"
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def str_to_list(value: Any) -> Any:
|
|
160
|
+
if isinstance(value, str):
|
|
161
|
+
return [v.strip() for v in value.split(",")]
|
|
162
|
+
return value
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class Settings(ApiSettings):
|
|
166
|
+
"""API settings.
|
|
167
|
+
|
|
168
|
+
Attributes:
|
|
169
|
+
prefix_path: An optional path prefix for the underyling FastAPI router.
|
|
170
|
+
use_api_hydrate: perform hydration of stac items within stac-fastapi.
|
|
171
|
+
invalid_id_chars: list of characters that are not allowed in item or collection ids.
|
|
172
|
+
|
|
173
|
+
"""
|
|
174
|
+
|
|
175
|
+
prefix_path: str = ""
|
|
176
|
+
use_api_hydrate: bool = False
|
|
177
|
+
"""
|
|
178
|
+
When USE_API_HYDRATE=TRUE, PgSTAC database will receive `NO_HYDRATE=TRUE`
|
|
179
|
+
|
|
180
|
+
| use_api_hydrate | nohydrate | Hydration |
|
|
181
|
+
| --- | --- | --- |
|
|
182
|
+
| False | False | PgSTAC |
|
|
183
|
+
| True | True | API |
|
|
184
|
+
|
|
185
|
+
ref: https://stac-utils.github.io/pgstac/pgstac/#runtime-configurations
|
|
186
|
+
"""
|
|
187
|
+
exclude_hydrate_markers: bool = True
|
|
188
|
+
"""
|
|
189
|
+
In some case, PgSTAC can return `DO_NOT_MERGE_MARKER` markers (`𒍟※`).
|
|
190
|
+
If `EXCLUDE_HYDRATE_MARKERS=TRUE` and `USE_API_HYDRATE=TRUE`, stac-fastapi-pgstac
|
|
191
|
+
will exclude those values from the responses.
|
|
192
|
+
"""
|
|
193
|
+
|
|
194
|
+
invalid_id_chars: List[str] = DEFAULT_INVALID_ID_CHARS
|
|
195
|
+
base_item_cache: Type[BaseItemCache] = DefaultBaseItemCache
|
|
196
|
+
|
|
197
|
+
validate_extensions: bool = False
|
|
198
|
+
"""
|
|
199
|
+
Validate `stac_extensions` schemas against submitted data when creating or updated STAC objects.
|
|
200
|
+
|
|
201
|
+
Implies that the `Transactions` extension is enabled.
|
|
202
|
+
"""
|
|
203
|
+
|
|
204
|
+
cors_origins: Annotated[Sequence[str], BeforeValidator(str_to_list)] = ("*",)
|
|
205
|
+
cors_origin_regex: Optional[str] = None
|
|
206
|
+
cors_methods: Annotated[Sequence[str], BeforeValidator(str_to_list)] = (
|
|
207
|
+
"GET",
|
|
208
|
+
"POST",
|
|
209
|
+
"OPTIONS",
|
|
210
|
+
)
|
|
211
|
+
cors_credentials: bool = False
|
|
212
|
+
cors_headers: Annotated[Sequence[str], BeforeValidator(str_to_list)] = (
|
|
213
|
+
"Content-Type",
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
testing: bool = False
|
|
217
|
+
|
|
218
|
+
@model_validator(mode="after")
|
|
219
|
+
def check_origins(self) -> Self:
|
|
220
|
+
if self.cors_origin_regex and "*" in self.cors_origins:
|
|
221
|
+
raise ValueError(
|
|
222
|
+
"Conflicting options found in API settings: `cors_origin_regex` and `*` in `cors_origins`"
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
return self
|