Skip to content

Commit 5c0e37c

Browse files
authored
Implement backup & restore (#479)
## Problem Implement backup & restore ## Solution Added new methods to `Pinecone` and `PineconeAsyncio`: - `create_index_from_backup` - `create_backup` - `list_backups` - `describe_backup` - `delete_backup` - `list_restore_jobs` - `describe_restore_job` These can also be accessed with the new-style syntax, e.g. `pc.db.index.create_from_backup`, `pc.db.backup.create`, `pc.db.restore_job.list`. More Details: - Had to re-run codegen to pull in recent spec changes - Organize implementation around resource-types - Expose legacy-style names (`create_backup`, `create_index_from_backups`) as well as new-style names `pc.db.index.create_from_backup`. In the upcoming release, both styles will be present. We still need to do some work to reorg methods for some less-used parts of the client (bulk imports, etc) before transitioning fully to the new style in examples and documentation. - For new methods being added, begin enforcing keyword argument usage with a new `@kwargs_required` decorator. I will probably follow up and add this to all new methods added in the recent refactoring PR. Keyword arguments are strongly preferred over positional arguments because the keyword labels act as documentation and having the keyword labels makes them order-independent. This gives a lot of flexibility to expand the signature or change things from required to optional later without creating breaking changes for callers. - Wire up the code paths for new methods: - `Pinecone > DbControl > BackupResource` - `Pinecone > DbControl > IndexResource` - `Pinecone > DbControl > RestoreJobResource` - `PineconeAsyncio > DbControlAsyncio > AsyncioBackupResource` - `PineconeAsyncio > DbControlAsyncio > AsyncioIndexResource` - `PineconeAsyncio > DbControlAsyncio > AsyncioRestoreJobResource` - Update interface classes so that docs will show information about the new methods. ## Usage ### Initial setup ```python from pinecone import Pinecone, ServerlessSpec pc = Pinecone(api_key='key') # First you need an index pc.create_index( name='foo', dimension=2, metric='cosine', spec=ServerlessSpec(cloud='aws', region='us-east-1') ) # Upsert some fake data just for demonstration purposes import random idx = pc.Index(name='foo') idx.upsert( vectors=[ (str(i), [random.random(), random.random()] for i in range(1000) ] ) ``` ### Backups ```python pc.create_backup( index_name='foo', backup_name='bar', description='an example backup' ) # Describe a backup pc.describe_backup(backup_id='7c8e6fcf-577b-4df5-9869-3c67f0f3d6e1') # { # "backup_id": "7c8e6fcf-577b-4df5-9869-3c67f0f3d6e1", # "source_index_name": "foo", # "source_index_id": "4c292a8a-77cc-4a37-917d-51c6051a80bf", # "status": "Ready", # "cloud": "aws", # "region": "us-east-1", # "tags": {}, # "name": "bar", # "description": "", # "dimension": 2, # "record_count": 1000, # "namespace_count": 1, # "size_bytes": 289392, # "created_at": "2025-05-13T14:15:16.908702Z" # } # List backups pc.list_backups() # [ # { # "backup_id": "7c8e6fcf-577b-4df5-9869-3c67f0f3d6e1", # "source_index_name": "foo", # "source_index_id": "4c292a8a-77cc-4a37-917d-51c6051a80bf", # "status": "Ready", # "cloud": "aws", # "region": "us-east-1", # "tags": {}, # "name": "bar", # "description": "", # "dimension": 2, # "record_count": 1000, # "namespace_count": 1, # "size_bytes": 289392, # "created_at": "2025-05-13T14:15:16.908702Z" # } # ] # Delete backup pc.delete_backup(backup_id='7c8e6fcf-577b-4df5-9869-3c67f0f3d6e1') ``` ### Creating an index from backup ```python # Create index from backup pc.create_index_from_backup( backup_id='7c8e6fcf-577b-4df5-9869-3c67f0f3d6e1', name='foo2', deletion_protection='enabled', tags={'env': 'testing'} ) # { # "name": "foo2", # "metric": "cosine", # "host": "foo2-dojoi3u.svc.aped-4627-b74a.pinecone.io", # "spec": { # "serverless": { # "cloud": "aws", # "region": "us-east-1" # } # }, # "status": { # "ready": true, # "state": "Ready" # }, # "vector_type": "dense", # "dimension": 2, # "deletion_protection": "enabled", # "tags": { # "env": "testing" # } # } ``` ### Restore job ```python # List jobs pc.list_restore_jobs() # {'data': [{'backup_id': 'e5957dc2-a76e-4b72-9645-569fb7ec143f', # 'completed_at': datetime.datetime(2025, 5, 13, 14, 56, 13, 939921, tzinfo=tzutc()), # 'created_at': datetime.datetime(2025, 5, 13, 14, 56, 4, 534826, tzinfo=tzutc()), # 'percent_complete': 100.0, # 'restore_job_id': '744ea5bd-7ddc-44ce-81f5-cfb876572e59', # 'status': 'Completed', # 'target_index_id': '572130f9-cfdd-42bf-a280-4218cd112bf8', # 'target_index_name': 'foo2'}, # {'backup_id': '7c8e6fcf-577b-4df5-9869-3c67f0f3d6e1', # 'completed_at': datetime.datetime(2025, 5, 13, 16, 27, 10, 290234, tzinfo=tzutc()), # 'created_at': datetime.datetime(2025, 5, 13, 16, 27, 6, 130522, tzinfo=tzutc()), # 'percent_complete': 100.0, # 'restore_job_id': '06aa5739-2785-4121-b71b-99b73c3e3247', # 'status': 'Completed', # 'target_index_id': 'd3f31cd1-b077-4bcf-8e7d-d091d408c82b', # 'target_index_name': 'foo2'}], # 'pagination': None} # Describe jobs pc.describe_restore_job(job_id='504dd1a9-e3cd-420f-8756-65d5411fcb10') # {'backup_id': '7c8e6fcf-577b-4df5-9869-3c67f0f3d6e1', # 'completed_at': datetime.datetime(2025, 5, 13, 15, 55, 10, 108584, tzinfo=tzutc()), # 'created_at': datetime.datetime(2025, 5, 13, 15, 54, 49, 925105, tzinfo=tzutc()), # 'percent_complete': 100.0, # 'restore_job_id': '504dd1a9-e3cd-420f-8756-65d5411fcb10', # 'status': 'Completed', # 'target_index_id': 'b5607ee7-be78-4401-aaf5-ea20413f409d', # 'target_index_name': 'foo4'} ``` ## Type of Change - [x] New feature (non-breaking change which adds functionality) ## Test Plan Describe specific steps for validating this change.
1 parent 1bb8551 commit 5c0e37c

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

68 files changed

+3807
-157
lines changed

.github/workflows/testing-integration.yaml

+11-5
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,17 @@ jobs:
1111
PINECONE_API_KEY: '${{ secrets.PINECONE_API_KEY }}'
1212
PINECONE_ADDITIONAL_HEADERS: '{"sdk-test-suite": "pinecone-python-client"}'
1313
strategy:
14+
fail-fast: false
1415
matrix:
1516
python_version: [3.9, 3.12]
17+
test_suite:
18+
- tests/integration/control/index
19+
- tests/integration/control/collections
20+
- tests/integration/control/backup
21+
- tests/integration/control/restore_job
22+
- tests/integration/control_asyncio/index
23+
- tests/integration/control_asyncio/backup
24+
- tests/integration/control_asyncio/restore_job
1625
steps:
1726
- uses: actions/checkout@v4
1827
- name: 'Set up Python ${{ matrix.python_version }}'
@@ -23,11 +32,8 @@ jobs:
2332
uses: ./.github/actions/setup-poetry
2433
with:
2534
include_asyncio: true
26-
- name: 'Run index tests'
27-
run: poetry run pytest tests/integration/control/index --retries 5 --retry-delay 35 -s -vv --log-cli-level=DEBUG
28-
- name: 'Run collection tests'
29-
run: poetry run pytest tests/integration/control/collections --retries 5 --retry-delay 35 -s -vv --log-cli-level=DEBUG
30-
35+
- name: 'Run tests'
36+
run: poetry run pytest ${{ matrix.test_suite }} --retries 2 --retry-delay 35 -s -vv --log-cli-level=DEBUG
3137

3238
inference:
3339
name: Inference tests

codegen/apis

Submodule apis updated from ba143ab to 09015d9

pinecone/__init__.py

+4
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,10 @@
7676
"PodSpec": ("pinecone.db_control.models", "PodSpec"),
7777
"PodSpecDefinition": ("pinecone.db_control.models", "PodSpecDefinition"),
7878
"PodType": ("pinecone.db_control.enums", "PodType"),
79+
"RestoreJobModel": ("pinecone.db_control.models", "RestoreJobModel"),
80+
"RestoreJobList": ("pinecone.db_control.models", "RestoreJobList"),
81+
"BackupModel": ("pinecone.db_control.models", "BackupModel"),
82+
"BackupList": ("pinecone.db_control.models", "BackupList"),
7983
}
8084

8185
_config_lazy_imports = {

pinecone/core/openapi/db_control/api/manage_indexes_api.py

+46-27
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,9 @@
3636
from pinecone.core.openapi.db_control.model.create_index_from_backup_request import (
3737
CreateIndexFromBackupRequest,
3838
)
39+
from pinecone.core.openapi.db_control.model.create_index_from_backup_response import (
40+
CreateIndexFromBackupResponse,
41+
)
3942
from pinecone.core.openapi.db_control.model.create_index_request import CreateIndexRequest
4043
from pinecone.core.openapi.db_control.model.error_response import ErrorResponse
4144
from pinecone.core.openapi.db_control.model.index_list import IndexList
@@ -281,7 +284,7 @@ def __create_collection(
281284
def __create_index(self, create_index_request, **kwargs: ExtraOpenApiKwargsTypedDict):
282285
"""Create an index # noqa: E501
283286
284-
Create a Pinecone index. This is where you specify the measure of similarity, the dimension of vectors to be stored in the index, which cloud provider you would like to deploy with, and more. For guidance and examples, see [Create an index](https://docs.pinecone.io/guides/indexes/create-an-index#create-a-serverless-index). # noqa: E501
287+
Create a Pinecone index. This is where you specify the measure of similarity, the dimension of vectors to be stored in the index, which cloud provider you would like to deploy with, and more. For guidance and examples, see [Create an index](https://docs.pinecone.io/guides/index-data/create-an-index). # noqa: E501
285288
This method makes a synchronous HTTP request by default. To make an
286289
asynchronous HTTP request, please pass async_req=True
287290
@@ -352,7 +355,7 @@ def __create_index_for_model(
352355
):
353356
"""Create an index with integrated embedding # noqa: E501
354357
355-
Create an index with integrated embedding. With this type of index, you provide source text, and Pinecone uses a [hosted embedding model](https://docs.pinecone.io/guides/inference/understanding-inference#embedding-models) to convert the text automatically during [upsert](https://docs.pinecone.io/reference/api/2025-01/data-plane/upsert_records) and [search](https://docs.pinecone.io/reference/api/2025-01/data-plane/search_records). For guidance and examples, see [Create an index](https://docs.pinecone.io/guides/indexes/create-an-index#integrated-embedding). # noqa: E501
358+
Create an index with integrated embedding. With this type of index, you provide source text, and Pinecone uses a [hosted embedding model](https://docs.pinecone.io/guides/index-data/create-an-index#embedding-models) to convert the text automatically during [upsert](https://docs.pinecone.io/reference/api/2025-01/data-plane/upsert_records) and [search](https://docs.pinecone.io/reference/api/2025-01/data-plane/search_records). For guidance and examples, see [Create an index](https://docs.pinecone.io/guides/index-data/create-an-index#integrated-embedding). # noqa: E501
356359
This method makes a synchronous HTTP request by default. To make an
357360
asynchronous HTTP request, please pass async_req=True
358361
@@ -418,7 +421,7 @@ def __create_index_for_model(
418421
callable=__create_index_for_model,
419422
)
420423

421-
def __create_index_from_backup(
424+
def __create_index_from_backup_operation(
422425
self, backup_id, create_index_from_backup_request, **kwargs: ExtraOpenApiKwargsTypedDict
423426
):
424427
"""Create an index from a backup # noqa: E501
@@ -427,7 +430,7 @@ def __create_index_from_backup(
427430
This method makes a synchronous HTTP request by default. To make an
428431
asynchronous HTTP request, please pass async_req=True
429432
430-
>>> thread = api.create_index_from_backup(backup_id, create_index_from_backup_request, async_req=True)
433+
>>> thread = api.create_index_from_backup_operation(backup_id, create_index_from_backup_request, async_req=True)
431434
>>> result = thread.get()
432435
433436
Args:
@@ -453,7 +456,7 @@ def __create_index_from_backup(
453456
async_req (bool): execute request asynchronously
454457
455458
Returns:
456-
None
459+
CreateIndexFromBackupResponse
457460
If the method is called asynchronously, returns the request
458461
thread.
459462
"""
@@ -462,12 +465,12 @@ def __create_index_from_backup(
462465
kwargs["create_index_from_backup_request"] = create_index_from_backup_request
463466
return self.call_with_http_info(**kwargs)
464467

465-
self.create_index_from_backup = _Endpoint(
468+
self.create_index_from_backup_operation = _Endpoint(
466469
settings={
467-
"response_type": None,
470+
"response_type": (CreateIndexFromBackupResponse,),
468471
"auth": ["ApiKeyAuth"],
469472
"endpoint_path": "/backups/{backup_id}/create-index",
470-
"operation_id": "create_index_from_backup",
473+
"operation_id": "create_index_from_backup_operation",
471474
"http_method": "POST",
472475
"servers": None,
473476
},
@@ -491,7 +494,7 @@ def __create_index_from_backup(
491494
},
492495
headers_map={"accept": ["application/json"], "content_type": ["application/json"]},
493496
api_client=api_client,
494-
callable=__create_index_from_backup,
497+
callable=__create_index_from_backup_operation,
495498
)
496499

497500
def __delete_backup(self, backup_id, **kwargs: ExtraOpenApiKwargsTypedDict):
@@ -1192,6 +1195,8 @@ def __list_project_backups(self, **kwargs: ExtraOpenApiKwargsTypedDict):
11921195
11931196
11941197
Keyword Args:
1198+
limit (int): The number of results to return per page. [optional] if omitted the server will use the default value of 10.
1199+
pagination_token (str): The token to use to retrieve the next page of results. [optional]
11951200
_return_http_data_only (bool): response data without head status
11961201
code and headers. Default is True.
11971202
_preload_content (bool): if False, the urllib3.HTTPResponse object
@@ -1226,13 +1231,19 @@ def __list_project_backups(self, **kwargs: ExtraOpenApiKwargsTypedDict):
12261231
"http_method": "GET",
12271232
"servers": None,
12281233
},
1229-
params_map={"all": [], "required": [], "nullable": [], "enum": [], "validation": []},
1234+
params_map={
1235+
"all": ["limit", "pagination_token"],
1236+
"required": [],
1237+
"nullable": [],
1238+
"enum": [],
1239+
"validation": ["limit"],
1240+
},
12301241
root_map={
1231-
"validations": {},
1242+
"validations": {("limit",): {"inclusive_maximum": 100, "inclusive_minimum": 1}},
12321243
"allowed_values": {},
1233-
"openapi_types": {},
1234-
"attribute_map": {},
1235-
"location_map": {},
1244+
"openapi_types": {"limit": (int,), "pagination_token": (str,)},
1245+
"attribute_map": {"limit": "limit", "pagination_token": "paginationToken"},
1246+
"location_map": {"limit": "query", "pagination_token": "query"},
12361247
"collection_format_map": {},
12371248
},
12381249
headers_map={"accept": ["application/json"], "content_type": []},
@@ -1519,7 +1530,7 @@ async def __create_collection(self, create_collection_request, **kwargs):
15191530
async def __create_index(self, create_index_request, **kwargs):
15201531
"""Create an index # noqa: E501
15211532
1522-
Create a Pinecone index. This is where you specify the measure of similarity, the dimension of vectors to be stored in the index, which cloud provider you would like to deploy with, and more. For guidance and examples, see [Create an index](https://docs.pinecone.io/guides/indexes/create-an-index#create-a-serverless-index). # noqa: E501
1533+
Create a Pinecone index. This is where you specify the measure of similarity, the dimension of vectors to be stored in the index, which cloud provider you would like to deploy with, and more. For guidance and examples, see [Create an index](https://docs.pinecone.io/guides/index-data/create-an-index). # noqa: E501
15231534
15241535
15251536
Args:
@@ -1581,7 +1592,7 @@ async def __create_index(self, create_index_request, **kwargs):
15811592
async def __create_index_for_model(self, create_index_for_model_request, **kwargs):
15821593
"""Create an index with integrated embedding # noqa: E501
15831594
1584-
Create an index with integrated embedding. With this type of index, you provide source text, and Pinecone uses a [hosted embedding model](https://docs.pinecone.io/guides/inference/understanding-inference#embedding-models) to convert the text automatically during [upsert](https://docs.pinecone.io/reference/api/2025-01/data-plane/upsert_records) and [search](https://docs.pinecone.io/reference/api/2025-01/data-plane/search_records). For guidance and examples, see [Create an index](https://docs.pinecone.io/guides/indexes/create-an-index#integrated-embedding). # noqa: E501
1595+
Create an index with integrated embedding. With this type of index, you provide source text, and Pinecone uses a [hosted embedding model](https://docs.pinecone.io/guides/index-data/create-an-index#embedding-models) to convert the text automatically during [upsert](https://docs.pinecone.io/reference/api/2025-01/data-plane/upsert_records) and [search](https://docs.pinecone.io/reference/api/2025-01/data-plane/search_records). For guidance and examples, see [Create an index](https://docs.pinecone.io/guides/index-data/create-an-index#integrated-embedding). # noqa: E501
15851596
15861597
15871598
Args:
@@ -1640,7 +1651,7 @@ async def __create_index_for_model(self, create_index_for_model_request, **kwarg
16401651
callable=__create_index_for_model,
16411652
)
16421653

1643-
async def __create_index_from_backup(
1654+
async def __create_index_from_backup_operation(
16441655
self, backup_id, create_index_from_backup_request, **kwargs
16451656
):
16461657
"""Create an index from a backup # noqa: E501
@@ -1670,19 +1681,19 @@ async def __create_index_from_backup(
16701681
Default is True.
16711682
16721683
Returns:
1673-
None
1684+
CreateIndexFromBackupResponse
16741685
"""
16751686
self._process_openapi_kwargs(kwargs)
16761687
kwargs["backup_id"] = backup_id
16771688
kwargs["create_index_from_backup_request"] = create_index_from_backup_request
16781689
return await self.call_with_http_info(**kwargs)
16791690

1680-
self.create_index_from_backup = _AsyncioEndpoint(
1691+
self.create_index_from_backup_operation = _AsyncioEndpoint(
16811692
settings={
1682-
"response_type": None,
1693+
"response_type": (CreateIndexFromBackupResponse,),
16831694
"auth": ["ApiKeyAuth"],
16841695
"endpoint_path": "/backups/{backup_id}/create-index",
1685-
"operation_id": "create_index_from_backup",
1696+
"operation_id": "create_index_from_backup_operation",
16861697
"http_method": "POST",
16871698
"servers": None,
16881699
},
@@ -1706,7 +1717,7 @@ async def __create_index_from_backup(
17061717
},
17071718
headers_map={"accept": ["application/json"], "content_type": ["application/json"]},
17081719
api_client=api_client,
1709-
callable=__create_index_from_backup,
1720+
callable=__create_index_from_backup_operation,
17101721
)
17111722

17121723
async def __delete_backup(self, backup_id, **kwargs):
@@ -2333,6 +2344,8 @@ async def __list_project_backups(self, **kwargs):
23332344
23342345
23352346
Keyword Args:
2347+
limit (int): The number of results to return per page. [optional] if omitted the server will use the default value of 10.
2348+
pagination_token (str): The token to use to retrieve the next page of results. [optional]
23362349
_return_http_data_only (bool): response data without head status
23372350
code and headers. Default is True.
23382351
_preload_content (bool): if False, the urllib3.HTTPResponse object
@@ -2364,13 +2377,19 @@ async def __list_project_backups(self, **kwargs):
23642377
"http_method": "GET",
23652378
"servers": None,
23662379
},
2367-
params_map={"all": [], "required": [], "nullable": [], "enum": [], "validation": []},
2380+
params_map={
2381+
"all": ["limit", "pagination_token"],
2382+
"required": [],
2383+
"nullable": [],
2384+
"enum": [],
2385+
"validation": ["limit"],
2386+
},
23682387
root_map={
2369-
"validations": {},
2388+
"validations": {("limit",): {"inclusive_maximum": 100, "inclusive_minimum": 1}},
23702389
"allowed_values": {},
2371-
"openapi_types": {},
2372-
"attribute_map": {},
2373-
"location_map": {},
2390+
"openapi_types": {"limit": (int,), "pagination_token": (str,)},
2391+
"attribute_map": {"limit": "limit", "pagination_token": "paginationToken"},
2392+
"location_map": {"limit": "query", "pagination_token": "query"},
23742393
"collection_format_map": {},
23752394
},
23762395
headers_map={"accept": ["application/json"], "content_type": []},

pinecone/core/openapi/db_control/model/dedicated_spec.py renamed to pinecone/core/openapi/db_control/model/byoc_spec.py

+4-4
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,10 @@
3030
from typing import Dict, Literal, Tuple, Set, Any, Type, TypeVar
3131
from pinecone.openapi_support import PropertyValidationTypedDict, cached_class_property
3232

33-
T = TypeVar("T", bound="DedicatedSpec")
33+
T = TypeVar("T", bound="ByocSpec")
3434

3535

36-
class DedicatedSpec(ModelNormal):
36+
class ByocSpec(ModelNormal):
3737
"""NOTE: This class is @generated using OpenAPI.
3838
3939
Do not edit the class manually.
@@ -102,7 +102,7 @@ def discriminator(cls):
102102
@classmethod
103103
@convert_js_args_to_python_args
104104
def _from_openapi_data(cls: Type[T], environment, *args, **kwargs) -> T: # noqa: E501
105-
"""DedicatedSpec - a model defined in OpenAPI
105+
"""ByocSpec - a model defined in OpenAPI
106106
107107
Args:
108108
environment (str): The environment where the index is hosted.
@@ -189,7 +189,7 @@ def _from_openapi_data(cls: Type[T], environment, *args, **kwargs) -> T: # noqa
189189

190190
@convert_js_args_to_python_args
191191
def __init__(self, environment, *args, **kwargs) -> None: # noqa: E501
192-
"""DedicatedSpec - a model defined in OpenAPI
192+
"""ByocSpec - a model defined in OpenAPI
193193
194194
Args:
195195
environment (str): The environment where the index is hosted.

pinecone/core/openapi/db_control/model/create_backup_request.py

+3-5
Original file line numberDiff line numberDiff line change
@@ -61,9 +61,7 @@ class CreateBackupRequest(ModelNormal):
6161

6262
allowed_values: Dict[Tuple[str, ...], Dict[str, Any]] = {}
6363

64-
validations: Dict[Tuple[str, ...], PropertyValidationTypedDict] = {
65-
("name",): {"max_length": 45, "min_length": 1}
66-
}
64+
validations: Dict[Tuple[str, ...], PropertyValidationTypedDict] = {}
6765

6866
@cached_class_property
6967
def additional_properties_type(cls):
@@ -139,7 +137,7 @@ def _from_openapi_data(cls: Type[T], *args, **kwargs) -> T: # noqa: E501
139137
Animal class but this time we won't travel
140138
through its discriminator because we passed in
141139
_visited_composed_classes = (Animal,)
142-
name (str): The name of the index. Resource name must be 1-45 characters long, start and end with an alphanumeric character, and consist only of lower case alphanumeric characters or '-'. [optional] # noqa: E501
140+
name (str): The name of the backup. [optional] # noqa: E501
143141
description (str): A description of the backup. [optional] # noqa: E501
144142
"""
145143

@@ -224,7 +222,7 @@ def __init__(self, *args, **kwargs) -> None: # noqa: E501
224222
Animal class but this time we won't travel
225223
through its discriminator because we passed in
226224
_visited_composed_classes = (Animal,)
227-
name (str): The name of the index. Resource name must be 1-45 characters long, start and end with an alphanumeric character, and consist only of lower case alphanumeric characters or '-'. [optional] # noqa: E501
225+
name (str): The name of the backup. [optional] # noqa: E501
228226
description (str): A description of the backup. [optional] # noqa: E501
229227
"""
230228

0 commit comments

Comments
 (0)