pydoptic-elastic 0.0.1.post1.dev2__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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 John Hungerford
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,63 @@
1
+ Metadata-Version: 2.4
2
+ Name: pydoptic-elastic
3
+ Version: 0.0.1.post1.dev2
4
+ Summary: An Elasticsearch integration built on pydoptic
5
+ Author: John Hungerford
6
+ License-Expression: MIT
7
+ Project-URL: Source, https://github.com/johnhungerford/pydoptic
8
+ Requires-Python: <3.14,>=3.8
9
+ Description-Content-Type: text/markdown
10
+ License-File: LICENSE
11
+ Requires-Dist: pydoptic
12
+ Requires-Dist: elasticsearch==8.14.0
13
+ Provides-Extra: test
14
+ Requires-Dist: pytest~=8.4.2; extra == "test"
15
+ Provides-Extra: types
16
+ Requires-Dist: mypy~=1.18.2; extra == "types"
17
+ Dynamic: license-file
18
+
19
+ # pydoptic-elastic
20
+
21
+ An Elasticsearch integration built on [pydoptic](https://pypi.org/project/pydoptic/).
22
+
23
+ `ElasticModel` captures index and field-mapping metadata directly on the model's `Prop`s, and `Query`
24
+ lets you build Elasticsearch queries by referencing those `Prop`s directly -- no field or index names
25
+ passed as bare strings.
26
+
27
+ ## Installation
28
+
29
+ ```bash
30
+ pip install pydoptic-elastic
31
+ ```
32
+
33
+ Includes the `elasticsearch` client as a regular dependency, so no extras are needed.
34
+
35
+ ## Quickstart
36
+
37
+ ```python3
38
+ from pydoptic import Prop
39
+ from pydoptic_elastic import ElasticModel, ElasticService, Query, elastic_prop, ESMapping
40
+ from elasticsearch import Elasticsearch
41
+
42
+ class Person(ElasticModel):
43
+ name: Prop['Person', str] = elastic_prop(mapping=ESMapping.keyword)
44
+ age: Prop['Person', int]
45
+
46
+ service = ElasticService(Elasticsearch('http://localhost:9200'))
47
+ service.create_index(Person)
48
+
49
+ service.index(Person(name='John', age=42))
50
+
51
+ query = Query.match(Person.name, 'John')
52
+ for person in service.search(query):
53
+ print(Person.name.get_val(person))
54
+ # John
55
+
56
+ # Retrieve only specific fields, as a PartialModel:
57
+ for person in service.search_partial(query, source=[Person.name]):
58
+ print(Person.name.get_val_unsafe(person))
59
+ # John
60
+ ```
61
+
62
+ See the [pydoptic README](https://github.com/johnhungerford/pydoptic/tree/main/packages/pydoptic) for
63
+ the underlying `Prop`/`Select` model this is built on.
@@ -0,0 +1,45 @@
1
+ # pydoptic-elastic
2
+
3
+ An Elasticsearch integration built on [pydoptic](https://pypi.org/project/pydoptic/).
4
+
5
+ `ElasticModel` captures index and field-mapping metadata directly on the model's `Prop`s, and `Query`
6
+ lets you build Elasticsearch queries by referencing those `Prop`s directly -- no field or index names
7
+ passed as bare strings.
8
+
9
+ ## Installation
10
+
11
+ ```bash
12
+ pip install pydoptic-elastic
13
+ ```
14
+
15
+ Includes the `elasticsearch` client as a regular dependency, so no extras are needed.
16
+
17
+ ## Quickstart
18
+
19
+ ```python3
20
+ from pydoptic import Prop
21
+ from pydoptic_elastic import ElasticModel, ElasticService, Query, elastic_prop, ESMapping
22
+ from elasticsearch import Elasticsearch
23
+
24
+ class Person(ElasticModel):
25
+ name: Prop['Person', str] = elastic_prop(mapping=ESMapping.keyword)
26
+ age: Prop['Person', int]
27
+
28
+ service = ElasticService(Elasticsearch('http://localhost:9200'))
29
+ service.create_index(Person)
30
+
31
+ service.index(Person(name='John', age=42))
32
+
33
+ query = Query.match(Person.name, 'John')
34
+ for person in service.search(query):
35
+ print(Person.name.get_val(person))
36
+ # John
37
+
38
+ # Retrieve only specific fields, as a PartialModel:
39
+ for person in service.search_partial(query, source=[Person.name]):
40
+ print(Person.name.get_val_unsafe(person))
41
+ # John
42
+ ```
43
+
44
+ See the [pydoptic README](https://github.com/johnhungerford/pydoptic/tree/main/packages/pydoptic) for
45
+ the underlying `Prop`/`Select` model this is built on.
@@ -0,0 +1,38 @@
1
+ [build-system]
2
+ requires = ["setuptools>=64", "setuptools-scm>=8"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "pydoptic-elastic"
7
+ dynamic = ["version"]
8
+ authors = [{ name = "John Hungerford" }]
9
+ urls = { Source = "https://github.com/johnhungerford/pydoptic" }
10
+ description = "An Elasticsearch integration built on pydoptic"
11
+ readme = "README.md"
12
+ license = "MIT"
13
+ license-files = ["LICENSE"]
14
+ requires-python = ">=3.8,<3.14"
15
+ dependencies = [
16
+ "pydoptic",
17
+ "elasticsearch==8.14.0",
18
+ ]
19
+
20
+ [project.optional-dependencies]
21
+ test = [
22
+ "pytest~=8.4.2",
23
+ ]
24
+ types = [
25
+ "mypy~=1.18.2",
26
+ ]
27
+
28
+ [tool.setuptools_scm]
29
+ # See packages/pydoptic/pyproject.toml -- same repo-wide lockstep version, same reasoning.
30
+ root = "../.."
31
+ version_scheme = "no-guess-dev"
32
+ local_scheme = "no-local-version"
33
+
34
+ [tool.pytest.ini_options]
35
+ markers = [
36
+ "integration: requires a live Elasticsearch instance started via docker-compose.yml; excluded by default, opt in with `-m integration`",
37
+ ]
38
+ addopts = "-m 'not integration'"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,9 @@
1
+ from pydoptic_elastic.elastic_model import ESFieldData, ESMapping, ElasticModel, elastic_prop
2
+ from pydoptic_elastic.elastic_service import ElasticService
3
+ from pydoptic_elastic.elastic_query import Query
4
+
5
+ __all__ = [
6
+ 'ESFieldData', 'ESMapping', 'ElasticModel', 'elastic_prop',
7
+ 'ElasticService',
8
+ 'Query',
9
+ ]
@@ -0,0 +1,88 @@
1
+ from datetime import date, datetime
2
+ from enum import Enum
3
+ from typing import Any, ClassVar, Dict, Optional, TypeVar, TypedDict, Unpack, cast
4
+
5
+ from pydoptic import BaseModel, select
6
+ from pydoptic.selector import Discrim, PropSelect, LinkedSelect, Select
7
+
8
+ class ESFieldData(TypedDict):
9
+ mapping: Optional['ESMapping']
10
+
11
+ TERM_SUFFIX = 'term'
12
+
13
+ class ESMapping(Enum):
14
+ text_keyword = 'text_keyword'
15
+ text = 'text'
16
+ keyword = 'keyword'
17
+ long = 'long'
18
+ double = 'double'
19
+ boolean = 'boolean'
20
+ date = 'date'
21
+
22
+ @classmethod
23
+ def from_select(self, prop: Select[Any, Any]) -> 'ESMapping':
24
+ match prop:
25
+ case PropSelect():
26
+ data = cast(ESFieldData, prop.data)
27
+ mapping = data.get('mapping')
28
+ if mapping is not None:
29
+ return mapping
30
+ return _mappings_lookup.get(prop.target) or ESMapping.text_keyword
31
+ case LinkedSelect(select_2=prop):
32
+ return self.from_select(prop)
33
+ case _:
34
+ raise ValueError()
35
+
36
+ def to_dict(self) -> Dict[str, Any]:
37
+ match self:
38
+ case ESMapping.text_keyword:
39
+ return {'type': 'text', 'fields': {TERM_SUFFIX: {'type': 'keyword'}}}
40
+ case ESMapping.text:
41
+ return {'type': 'text'}
42
+ case ESMapping.keyword:
43
+ return {'type': 'keyword'}
44
+ case ESMapping.long:
45
+ return {'type': 'long'}
46
+ case ESMapping.double:
47
+ return {'type': 'double'}
48
+ case ESMapping.boolean:
49
+ return {'type': 'boolean'}
50
+ case ESMapping.date:
51
+ return {'type': 'date'}
52
+
53
+ _mappings_lookup = {
54
+ str: ESMapping.text_keyword,
55
+ int: ESMapping.long,
56
+ float: ESMapping.double,
57
+ bool: ESMapping.boolean,
58
+ date: ESMapping.date,
59
+ datetime: ESMapping.date,
60
+ }
61
+
62
+ class ElasticModel(BaseModel):
63
+ index_name: ClassVar[str]
64
+
65
+ @classmethod
66
+ def _get_index_name(cls) -> str:
67
+ try:
68
+ return cls.index_name
69
+ except AttributeError:
70
+ return cls.__name__.lower()
71
+
72
+ @classmethod
73
+ def _get_mappings(cls) -> Dict[str, Any]:
74
+ mappings: Dict[str, Any] = {}
75
+ for prop in cls.properties().values():
76
+ if isinstance(prop, PropSelect):
77
+ if issubclass(prop.target, ElasticModel):
78
+ mappings[prop.label] = {'type': 'object', 'properties': prop.target._get_mappings()}
79
+ else:
80
+ mappings[prop.label] = ESMapping.from_select(prop).to_dict()
81
+ elif isinstance(prop, Discrim):
82
+ mappings[prop.property.label] = ESMapping.keyword
83
+ return mappings
84
+
85
+ M = TypeVar('M', bound=ElasticModel)
86
+
87
+ def elastic_prop(name: str | None = None, **field_data: Unpack[ESFieldData]) -> Any:
88
+ return select(name, **field_data)
@@ -0,0 +1,154 @@
1
+ from typing import TypeVar, Generic, Dict, Any, Type, List
2
+
3
+ from pydoptic import Select
4
+ from pydoptic_elastic.elastic_model import M, TERM_SUFFIX, ESMapping
5
+
6
+ A = TypeVar('A')
7
+
8
+ class Query(Generic[M]):
9
+ def to_dict(self) -> Dict[str, Any]:
10
+ raise NotImplementedError()
11
+
12
+ @property
13
+ def model(self) -> Type[M]:
14
+ raise NotImplementedError()
15
+
16
+ @classmethod
17
+ def match(cls, select: Select[M, A], value: A, **kwargs) -> 'MatchQuery[M]':
18
+ return MatchQuery(select, value)
19
+
20
+ @classmethod
21
+ def bool(
22
+ cls,
23
+ should: List['Query[M]'] | None = None,
24
+ must: List['Query[M]'] | None = None,
25
+ filter: List['Query[M]'] | None = None,
26
+ must_not: List['Query[M]'] | None = None,
27
+ minimum_should_match: int | None = None,
28
+ ) -> 'BoolQuery[M]':
29
+ return BoolQuery(should=should or [], must=must or [], filter=filter or [], must_not=must_not or [], minimum_should_match=minimum_should_match)
30
+
31
+ @classmethod
32
+ def term(cls, select: Select[M, str], value: str, **kwargs) -> 'TermQuery[M]':
33
+ return TermQuery(select, value, **kwargs)
34
+
35
+ @classmethod
36
+ def exists(cls, select: Select[M, A]) -> 'ExistsQuery[M]':
37
+ return ExistsQuery(select)
38
+
39
+ @classmethod
40
+ def manual(cls, model: Type[M], query: Dict[str, Any]) -> 'ManualQuery[M]':
41
+ return ManualQuery(model, query)
42
+
43
+ class ManualQuery(Generic[M]):
44
+ def __init__(self, mdl: Type[M], query: Dict[str, Any]):
45
+ self.mdl = mdl
46
+ self.query = query
47
+
48
+ def to_dict(self) -> Dict[str, Any]:
49
+ return self.query
50
+
51
+ def model(self) -> Type[M]:
52
+ return self.mdl
53
+
54
+ class ExistsQuery(Query[M]):
55
+ def __init__(self, select: Select[M, A]) -> None:
56
+ self.select = select
57
+
58
+ def to_dict(self) -> Dict[str, Any]:
59
+ return {'exists': {'field': self.select.path}}
60
+
61
+ @property
62
+ def model(self) -> Type[M]:
63
+ return self.select.origin
64
+
65
+ class TermQuery(Query[M]):
66
+ def __init__(self, select: Select[M, str], value: str, **options) -> None:
67
+ self.select = select
68
+ self.value = value
69
+ self.options = options
70
+
71
+ def to_dict(self) -> Dict[str, Any]:
72
+ match ESMapping.from_select(self.select):
73
+ case ESMapping.keyword:
74
+ path = self.select.path
75
+ case ESMapping.text_keyword:
76
+ path = self.select.path + '.' + TERM_SUFFIX
77
+ case other:
78
+ raise ValueError(f'Term query unsupported on {self.select.path} (mapping type: {other.value})')
79
+ return {'term': {path: {'value': self.value, **self.options}}}
80
+
81
+ @property
82
+ def model(self) -> Type[M]:
83
+ return self.select.origin
84
+
85
+ class BoolQuery(Query[M]):
86
+ def __init__(self, should: List[Query[M]], must: List[Query[M]], filter: List[Query[M]], must_not: List[Query[M]], minimum_should_match: int | None = None) -> None:
87
+ self.should = should
88
+ self.must = must
89
+ self.filter = filter
90
+ self.must_not = must_not
91
+ self.mdl: Type[M] | None = None
92
+ self.minimum_should_match = minimum_should_match
93
+
94
+ def to_dict(self) -> Dict[str, Any]:
95
+ bool_obj: Dict[str, Any] = {}
96
+ if len(self.should) > 0:
97
+ bool_obj['should'] = self.should
98
+ if len(self.must) > 0:
99
+ bool_obj['must'] = self.must
100
+ if len(self.filter) > 0:
101
+ bool_obj['filter'] = self.filter
102
+ if len(self.must_not) > 0:
103
+ bool_obj['must_not'] = self.must_not
104
+ if self.minimum_should_match is not None:
105
+ bool_obj['minimum_should_match'] = self.minimum_should_match
106
+ return {'bool': bool_obj}
107
+
108
+ @property
109
+ def model(self) -> Type[M]:
110
+ if self.mdl is not None:
111
+ return self.mdl
112
+ else:
113
+ for batch in [self.should, self.must, self.must_not, self.filter]:
114
+ for query in batch:
115
+ self.mdl = query.model
116
+ return self.mdl
117
+ raise ValueError(f'Bool query contains no queries and no valid models: {self}')
118
+
119
+ def add_should(self, query: Query[M], *queries: Query[M]):
120
+ if self.mdl is None:
121
+ self.mdl = query.model
122
+ self.should.append(query)
123
+ self.should.extend(queries)
124
+
125
+ def add_must(self, query: Query[M], *queries: Query[M]):
126
+ if self.mdl is None:
127
+ self.mdl = query.model
128
+ self.must.append(query)
129
+ self.must.extend(queries)
130
+
131
+ def add_filter(self, query: Query[M], *queries: Query[M]):
132
+ if self.mdl is None:
133
+ self.mdl = query.model
134
+ self.filter.append(query)
135
+ self.filter.extend(queries)
136
+
137
+ def add_must_not(self, query: Query[M], *queries: Query[M]):
138
+ if self.mdl is None:
139
+ self.mdl = query.model
140
+ self.must_not.append(query)
141
+ self.must_not.extend(queries)
142
+
143
+ class MatchQuery(Query[M]):
144
+ def __init__(self, select: Select[M, A], value: A, **options):
145
+ self.select = select
146
+ self.value = value
147
+ self.options = options
148
+
149
+ def to_dict(self) -> Dict[str, Any]:
150
+ return {'match': {self.select.path: {'query': self.value}, **self.options}}
151
+
152
+ @property
153
+ def model(self) -> Type[M]:
154
+ return self.select.origin
@@ -0,0 +1,73 @@
1
+ from typing import List, Iterable, Any, Dict, Type
2
+
3
+ from elasticsearch import Elasticsearch, NotFoundError
4
+
5
+ from pydoptic import BaseModel, Select, PartialModel
6
+ from pydoptic.selector import Prop
7
+ from pydoptic_elastic.elastic_model import M
8
+ from pydoptic_elastic.elastic_query import Query
9
+ from pydoptic.base_model import select
10
+
11
+ class _IndexResponse(BaseModel):
12
+ id: Prop['_IndexResponse', str] = select('_id')
13
+
14
+ class _GetResponse(BaseModel):
15
+ id: Prop['_GetResponse', str] = select('_id')
16
+ doc: Prop['_GetResponse', Dict[str, Any]] = select('_source')
17
+
18
+ class ElasticService:
19
+ def __init__(self, client: Elasticsearch):
20
+ self.__client = client
21
+
22
+ def create_index(self, model: Type[M]):
23
+ self.__client.indices.create(index=model._get_index_name(), mappings={'properties': model._get_mappings()})
24
+
25
+ def delete_index(self, model: Type[M]):
26
+ self.__client.indices.delete(index=model._get_index_name())
27
+
28
+ def refresh_index(self, model: Type[M]):
29
+ self.__client.indices.refresh(index=model._get_index_name())
30
+
31
+ def index(self, document: M, **kwargs) -> str:
32
+ response = self.__client.index(index=document.__class__._get_index_name(), document=document.as_dict_full(), **kwargs)
33
+ return _IndexResponse.id.get_val_unsafe(response.body)
34
+
35
+ def get(self, cls: Type[M], id: str, **kwargs) -> M | None:
36
+ try:
37
+ response = self.__client.get(index = cls._get_index_name(), id=id, **kwargs)
38
+ return _GetResponse.doc.get_unsafe(response.body).map(lambda d: cls(**d)).as_opt
39
+ except NotFoundError:
40
+ return None
41
+
42
+ def get_partial(self, cls: Type[M], id: str, **kwargs) -> PartialModel[M] | None:
43
+ try:
44
+ response = self.__client.get(index = cls._get_index_name(), id=id, **kwargs)
45
+ return _GetResponse.doc.get_unsafe(response.body).map(lambda d: cls.partial(**d)).as_opt
46
+ except NotFoundError:
47
+ return None
48
+
49
+ def get_raw(self, cls: Type[M], id: str, **kwargs) -> Dict[str, Any] | None:
50
+ try:
51
+ response = self.__client.get(index = cls._get_index_name(), id=id, **kwargs)
52
+ return _GetResponse.doc.get_unsafe(response.body).as_opt
53
+ except NotFoundError:
54
+ return None
55
+
56
+ def search(self, query: Query[M], **kwargs) -> Iterable[M]:
57
+ search_results = self.__client.search(index = query.model._get_index_name(), query=query.to_dict(), **kwargs)
58
+ for hit in search_results['hits']['hits']:
59
+ _source = hit['_source']
60
+ yield query.model(**_source)
61
+
62
+ def search_partial(self, query: Query[M], source: Iterable[Select[M, Any]] | None = None, **kwargs) -> Iterable[PartialModel[M]]:
63
+ _kwargs = kwargs if source is None else {'source': [sel.path for sel in source], **kwargs}
64
+ search_results = self.__client.search(index=query.model._get_index_name(), query=query.to_dict(), **_kwargs)
65
+ for hit in search_results['hits']['hits']:
66
+ _source = hit['_source']
67
+ yield query.model.partial(**_source)
68
+
69
+ def search_raw(self, query: Query[M], source: Iterable[Select[M, Any]] | None = None, **kwargs) -> Iterable[Dict[str, Any]]:
70
+ _kwargs = kwargs if source is None else {'source': [sel.path for sel in source], **kwargs}
71
+ search_results = self.__client.search(index=query.model._get_index_name(), query=query.to_dict(), **_kwargs)
72
+ for hit in search_results['hits']['hits']:
73
+ yield hit['_source']
@@ -0,0 +1,63 @@
1
+ Metadata-Version: 2.4
2
+ Name: pydoptic-elastic
3
+ Version: 0.0.1.post1.dev2
4
+ Summary: An Elasticsearch integration built on pydoptic
5
+ Author: John Hungerford
6
+ License-Expression: MIT
7
+ Project-URL: Source, https://github.com/johnhungerford/pydoptic
8
+ Requires-Python: <3.14,>=3.8
9
+ Description-Content-Type: text/markdown
10
+ License-File: LICENSE
11
+ Requires-Dist: pydoptic
12
+ Requires-Dist: elasticsearch==8.14.0
13
+ Provides-Extra: test
14
+ Requires-Dist: pytest~=8.4.2; extra == "test"
15
+ Provides-Extra: types
16
+ Requires-Dist: mypy~=1.18.2; extra == "types"
17
+ Dynamic: license-file
18
+
19
+ # pydoptic-elastic
20
+
21
+ An Elasticsearch integration built on [pydoptic](https://pypi.org/project/pydoptic/).
22
+
23
+ `ElasticModel` captures index and field-mapping metadata directly on the model's `Prop`s, and `Query`
24
+ lets you build Elasticsearch queries by referencing those `Prop`s directly -- no field or index names
25
+ passed as bare strings.
26
+
27
+ ## Installation
28
+
29
+ ```bash
30
+ pip install pydoptic-elastic
31
+ ```
32
+
33
+ Includes the `elasticsearch` client as a regular dependency, so no extras are needed.
34
+
35
+ ## Quickstart
36
+
37
+ ```python3
38
+ from pydoptic import Prop
39
+ from pydoptic_elastic import ElasticModel, ElasticService, Query, elastic_prop, ESMapping
40
+ from elasticsearch import Elasticsearch
41
+
42
+ class Person(ElasticModel):
43
+ name: Prop['Person', str] = elastic_prop(mapping=ESMapping.keyword)
44
+ age: Prop['Person', int]
45
+
46
+ service = ElasticService(Elasticsearch('http://localhost:9200'))
47
+ service.create_index(Person)
48
+
49
+ service.index(Person(name='John', age=42))
50
+
51
+ query = Query.match(Person.name, 'John')
52
+ for person in service.search(query):
53
+ print(Person.name.get_val(person))
54
+ # John
55
+
56
+ # Retrieve only specific fields, as a PartialModel:
57
+ for person in service.search_partial(query, source=[Person.name]):
58
+ print(Person.name.get_val_unsafe(person))
59
+ # John
60
+ ```
61
+
62
+ See the [pydoptic README](https://github.com/johnhungerford/pydoptic/tree/main/packages/pydoptic) for
63
+ the underlying `Prop`/`Select` model this is built on.
@@ -0,0 +1,17 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/pydoptic_elastic/__init__.py
5
+ src/pydoptic_elastic/elastic_model.py
6
+ src/pydoptic_elastic/elastic_query.py
7
+ src/pydoptic_elastic/elastic_service.py
8
+ src/pydoptic_elastic/py.typed
9
+ src/pydoptic_elastic.egg-info/PKG-INFO
10
+ src/pydoptic_elastic.egg-info/SOURCES.txt
11
+ src/pydoptic_elastic.egg-info/dependency_links.txt
12
+ src/pydoptic_elastic.egg-info/requires.txt
13
+ src/pydoptic_elastic.egg-info/scm_file_list.json
14
+ src/pydoptic_elastic.egg-info/scm_version.json
15
+ src/pydoptic_elastic.egg-info/top_level.txt
16
+ test/test_elastic_model.py
17
+ test/test_elastic_service.py
@@ -0,0 +1,8 @@
1
+ pydoptic
2
+ elasticsearch==8.14.0
3
+
4
+ [test]
5
+ pytest~=8.4.2
6
+
7
+ [types]
8
+ mypy~=1.18.2
@@ -0,0 +1,14 @@
1
+ {
2
+ "files": [
3
+ "LICENSE",
4
+ "README.md",
5
+ "pyproject.toml",
6
+ "src/pydoptic_elastic/__init__.py",
7
+ "src/pydoptic_elastic/elastic_model.py",
8
+ "src/pydoptic_elastic/elastic_query.py",
9
+ "src/pydoptic_elastic/elastic_service.py",
10
+ "src/pydoptic_elastic/py.typed",
11
+ "test/test_elastic_model.py",
12
+ "test/test_elastic_service.py"
13
+ ]
14
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "tag": "0.0.1",
3
+ "distance": 2,
4
+ "node": "gf2a88e816d5622e7cf652ca9af0f07673483e3a0",
5
+ "dirty": false,
6
+ "branch": "main",
7
+ "node_date": "2026-08-18"
8
+ }
@@ -0,0 +1,33 @@
1
+ from datetime import date, datetime
2
+ from elasticsearch import Elasticsearch
3
+
4
+ from pydoptic.selector import Prop, PropOptArr
5
+ from pydoptic_elastic import ElasticModel, elastic_prop, ESMapping
6
+
7
+ class Nested(ElasticModel):
8
+ prop_a: Prop['Nested', str] = elastic_prop(mapping=ESMapping.keyword)
9
+
10
+ class SimpleModel(ElasticModel):
11
+ index_name = 'name_override'
12
+
13
+ prop_1: Prop['SimpleModel', str] = elastic_prop(mapping=ESMapping.text)
14
+ prop_2: Prop['SimpleModel', str]
15
+ prop_3: Prop['SimpleModel', int]
16
+ prop_4: Prop['SimpleModel', float]
17
+ prop_5: Prop['SimpleModel', bool]
18
+ prop_6: Prop['SimpleModel', date]
19
+ prop_7: Prop['SimpleModel', datetime]
20
+ prop_8: PropOptArr['SimpleModel', Nested]
21
+
22
+ def test_elastic_model_mapping_should_reflect_custom_and_default_mappings():
23
+ mappings = SimpleModel._get_mappings()
24
+ assert mappings == {
25
+ 'prop_1': {'type': 'text'},
26
+ 'prop_2': {'type': 'text', 'fields': {'term': {'type': 'keyword'}}},
27
+ 'prop_3': {'type': 'long'},
28
+ 'prop_4': {'type': 'double'},
29
+ 'prop_5': {'type': 'boolean'},
30
+ 'prop_6': {'type': 'date'},
31
+ 'prop_7': {'type': 'date'},
32
+ 'prop_8': {'type': 'object', 'properties': {'prop_a': {'type': 'keyword'}}},
33
+ }
@@ -0,0 +1,68 @@
1
+ import pytest
2
+ from elasticsearch import Elasticsearch
3
+
4
+ from pydoptic.selector import Prop
5
+ from pydoptic_elastic.elastic_model import ElasticModel
6
+ from pydoptic_elastic.elastic_service import ElasticService
7
+ from pydoptic_elastic.elastic_query import Query
8
+
9
+ pytestmark = pytest.mark.integration
10
+
11
+ class SimpleModel(ElasticModel):
12
+ prop_1: Prop['SimpleModel', int]
13
+ prop_2: Prop['SimpleModel', str]
14
+ prop_3: Prop['SimpleModel', bool]
15
+
16
+ def test_index_and_retrieve_records():
17
+ client = Elasticsearch('http://localhost:9200')
18
+
19
+ service = ElasticService(client)
20
+
21
+ try:
22
+ service.create_index(SimpleModel)
23
+ except:
24
+ service.delete_index(SimpleModel)
25
+ service.create_index(SimpleModel)
26
+
27
+ try:
28
+ value = SimpleModel(prop_1=23, prop_2="hello", prop_3=True)
29
+
30
+ value_id = service.index(value)
31
+ service.refresh_index(SimpleModel)
32
+
33
+ value_retrieved = service.get(SimpleModel, value_id)
34
+
35
+ assert value_retrieved == value
36
+
37
+ finally:
38
+ service.delete_index(SimpleModel)
39
+
40
+ def test_search_records():
41
+ client = Elasticsearch('http://localhost:9200')
42
+
43
+ service = ElasticService(client)
44
+
45
+ try:
46
+ service.create_index(SimpleModel)
47
+ except:
48
+ service.delete_index(SimpleModel)
49
+ service.create_index(SimpleModel)
50
+
51
+ try:
52
+ value_1 = SimpleModel(prop_1=23, prop_2="hello", prop_3=True)
53
+ value_2 = SimpleModel(prop_1=24, prop_2="world", prop_3=False)
54
+
55
+ service.index(value_1)
56
+ service.index(value_2)
57
+ service.refresh_index(SimpleModel)
58
+
59
+ query_1 = Query[SimpleModel].match(SimpleModel.prop_1, SimpleModel.prop_1.get_val(value_1))
60
+ res_1 = list(service.search(query_1))
61
+ assert res_1 == [value_1]
62
+
63
+ query_2 = Query[SimpleModel].match(SimpleModel.prop_1, SimpleModel.prop_1.get_val(value_2))
64
+ res_2 = list(service.search(query_2))
65
+ assert res_2 == [value_2]
66
+
67
+ finally:
68
+ service.delete_index(SimpleModel)