sqlalchemy-searchable 2.0.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.
@@ -0,0 +1,468 @@
1
+ import os
2
+ from functools import reduce
3
+
4
+ import sqlalchemy as sa
5
+ from sqlalchemy import event
6
+ from sqlalchemy.ext.compiler import compiles
7
+ from sqlalchemy.schema import DDL, DDLElement
8
+ from sqlalchemy.sql.expression import Executable
9
+ from sqlalchemy_utils import TSVectorType
10
+
11
+ from .vectorizers import Vectorizer
12
+
13
+ __version__ = "2.0.0"
14
+
15
+
16
+ vectorizer = Vectorizer()
17
+ """
18
+ An instance of :class:`Vectorizer` that keeps a track of the registered vectorizers. Use
19
+ this as a decorator to register a function as a vectorizer.
20
+ """
21
+
22
+
23
+ class SearchQueryMixin:
24
+ def search(self, search_query, vector=None, regconfig=None, sort=False):
25
+ """
26
+ Search given query with full text search.
27
+
28
+ :param search_query: the search query
29
+ :param vector: search vector to use
30
+ :param regconfig: postgresql regconfig to be used
31
+ :param sort: order results by relevance (quality of hit)
32
+ """
33
+ return search(self, search_query, vector=vector, regconfig=regconfig, sort=sort)
34
+
35
+
36
+ def inspect_search_vectors(entity):
37
+ return [
38
+ getattr(entity, key).property.columns[0]
39
+ for key, column in sa.inspect(entity).columns.items()
40
+ if isinstance(column.type, TSVectorType)
41
+ ]
42
+
43
+
44
+ def search(query, search_query, vector=None, regconfig=None, sort=False):
45
+ """
46
+ Search given query with full text search.
47
+
48
+ :param search_query: the search query
49
+ :param vector: search vector to use
50
+ :param regconfig: postgresql regconfig to be used
51
+ :param sort: Order the results by relevance. This uses `cover density`_ ranking
52
+ algorithm (``ts_rank_cd``) for sorting.
53
+
54
+ .. _cover density: https://www.postgresql.org/docs/devel/textsearch-controls.html#TEXTSEARCH-RANKING
55
+ """
56
+ if not search_query.strip():
57
+ return query
58
+
59
+ if vector is None:
60
+ entity = query.column_descriptions[0]["entity"]
61
+ search_vectors = inspect_search_vectors(entity)
62
+ vector = search_vectors[0]
63
+
64
+ if regconfig is None:
65
+ regconfig = search_manager.options["regconfig"]
66
+
67
+ query = query.filter(
68
+ vector.op("@@")(sa.func.parse_websearch(regconfig, search_query))
69
+ )
70
+ if sort:
71
+ query = query.order_by(
72
+ sa.desc(sa.func.ts_rank_cd(vector, sa.func.parse_websearch(search_query)))
73
+ )
74
+
75
+ return query.params(term=search_query)
76
+
77
+
78
+ class SQLConstruct:
79
+ def __init__(self, tsvector_column, indexed_columns=None, options=None):
80
+ self.table = tsvector_column.table
81
+ self.tsvector_column = tsvector_column
82
+ self.options = self.init_options(options)
83
+ if indexed_columns:
84
+ self.indexed_columns = list(indexed_columns)
85
+ elif hasattr(self.tsvector_column.type, "columns"):
86
+ self.indexed_columns = list(self.tsvector_column.type.columns)
87
+ else:
88
+ self.indexed_columns = None
89
+
90
+ def init_options(self, options=None):
91
+ if not options:
92
+ options = {}
93
+ for key, value in SearchManager.default_options.items():
94
+ try:
95
+ option = self.tsvector_column.type.options[key]
96
+ except (KeyError, AttributeError):
97
+ option = value
98
+ options.setdefault(key, option)
99
+ return options
100
+
101
+ @property
102
+ def table_name(self):
103
+ if self.table.schema:
104
+ return f'{self.table.schema}."{self.table.name}"'
105
+ else:
106
+ return '"' + self.table.name + '"'
107
+
108
+ @property
109
+ def search_function_name(self):
110
+ return self.options["search_trigger_function_name"].format(
111
+ table=self.table.name, column=self.tsvector_column.name
112
+ )
113
+
114
+ @property
115
+ def search_trigger_name(self):
116
+ return self.options["search_trigger_name"].format(
117
+ table=self.table.name, column=self.tsvector_column.name
118
+ )
119
+
120
+ def column_vector(self, column):
121
+ value = sa.text(f"NEW.{sa.column(column.name)}")
122
+ try:
123
+ vectorizer_func = vectorizer[column]
124
+ except KeyError:
125
+ pass
126
+ else:
127
+ value = vectorizer_func(value)
128
+ value = sa.func.coalesce(value, sa.text("''"))
129
+ value = sa.func.to_tsvector(sa.literal(self.options["regconfig"]), value)
130
+ if column.name in self.options["weights"]:
131
+ weight = self.options["weights"][column.name]
132
+ value = sa.func.setweight(value, weight)
133
+ return value
134
+
135
+ def search_vector(self, compiler):
136
+ vectors = (
137
+ self.column_vector(getattr(self.table.c, column_name))
138
+ for column_name in self.indexed_columns
139
+ )
140
+ concatenated = reduce(lambda x, y: x.op("||")(y), vectors)
141
+ return compiler.sql_compiler.process(concatenated, literal_binds=True)
142
+
143
+
144
+ class CreateSearchFunctionSQL(SQLConstruct, DDLElement, Executable):
145
+ pass
146
+
147
+
148
+ @compiles(CreateSearchFunctionSQL)
149
+ def compile_create_search_function_sql(element, compiler):
150
+ return f"""CREATE FUNCTION
151
+ {element.search_function_name}() RETURNS TRIGGER AS $$
152
+ BEGIN
153
+ NEW.{element.tsvector_column.name} = {element.search_vector(compiler)};
154
+ RETURN NEW;
155
+ END
156
+ $$ LANGUAGE 'plpgsql';
157
+ """
158
+
159
+
160
+ class CreateSearchTriggerSQL(SQLConstruct, DDLElement, Executable):
161
+ @property
162
+ def search_trigger_function_with_trigger_args(self):
163
+ if self.options["weights"] or any(
164
+ getattr(self.table.c, column) in vectorizer
165
+ for column in self.indexed_columns
166
+ ):
167
+ return self.search_function_name + "()"
168
+ return "tsvector_update_trigger({arguments})".format(
169
+ arguments=", ".join(
170
+ [self.tsvector_column.name, "'%s'" % self.options["regconfig"]]
171
+ + self.indexed_columns
172
+ )
173
+ )
174
+
175
+
176
+ @compiles(CreateSearchTriggerSQL)
177
+ def compile_create_search_trigger_sql(element, compiler):
178
+ return (
179
+ f"CREATE TRIGGER {element.search_trigger_name}"
180
+ f" BEFORE UPDATE OR INSERT ON {element.table_name}"
181
+ " FOR EACH ROW EXECUTE PROCEDURE"
182
+ f" {element.search_trigger_function_with_trigger_args}"
183
+ )
184
+
185
+
186
+ class DropSearchFunctionSQL(SQLConstruct, DDLElement, Executable):
187
+ pass
188
+
189
+
190
+ @compiles(DropSearchFunctionSQL)
191
+ def compile_drop_search_function_sql(element, compiler):
192
+ return "DROP FUNCTION IF EXISTS %s()" % element.search_function_name
193
+
194
+
195
+ class DropSearchTriggerSQL(SQLConstruct, DDLElement, Executable):
196
+ pass
197
+
198
+
199
+ @compiles(DropSearchTriggerSQL)
200
+ def compile_drop_search_trigger_sql(element, compiler):
201
+ return (
202
+ f"DROP TRIGGER IF EXISTS {element.search_trigger_name} ON {element.table_name}"
203
+ )
204
+
205
+
206
+ class SearchManager:
207
+ default_options = {
208
+ "search_trigger_name": "{table}_{column}_trigger",
209
+ "search_trigger_function_name": "{table}_{column}_update",
210
+ "regconfig": "pg_catalog.english",
211
+ "weights": (),
212
+ "auto_index": True,
213
+ }
214
+
215
+ def __init__(self, options={}):
216
+ self.options = self.default_options
217
+ self.options.update(options)
218
+ self.processed_columns = []
219
+ self.classes = set()
220
+ self.listeners = []
221
+
222
+ def option(self, column, name):
223
+ try:
224
+ return column.type.options[name]
225
+ except (AttributeError, KeyError):
226
+ return self.options[name]
227
+
228
+ def inspect_columns(self, table):
229
+ """
230
+ Inspects all searchable columns for given class.
231
+
232
+ :param table: SQLAlchemy Table
233
+ """
234
+ return [column for column in table.c if isinstance(column.type, TSVectorType)]
235
+
236
+ def append_index(self, cls, column):
237
+ sa.Index(
238
+ "_".join(("ix", column.table.name, column.name)),
239
+ column,
240
+ postgresql_using="gin",
241
+ )
242
+
243
+ def process_mapper(self, mapper, cls):
244
+ columns = self.inspect_columns(mapper.persist_selectable)
245
+ for column in columns:
246
+ if column in self.processed_columns:
247
+ continue
248
+
249
+ if self.option(column, "auto_index"):
250
+ self.append_index(cls, column)
251
+
252
+ self.processed_columns.append(column)
253
+
254
+ def add_listener(self, args):
255
+ self.listeners.append(args)
256
+ event.listen(*args)
257
+
258
+ def remove_listeners(self):
259
+ for listener in self.listeners:
260
+ event.remove(*listener)
261
+ self.listeners = []
262
+
263
+ def attach_ddl_listeners(self):
264
+ # Remove all previously added listeners, so that same listener don't
265
+ # get added twice in situations where class configuration happens in
266
+ # multiple phases (issue #31).
267
+ self.remove_listeners()
268
+
269
+ for column in self.processed_columns:
270
+ # This sets up the trigger that keeps the tsvector column up to
271
+ # date.
272
+ if column.type.columns:
273
+ table = column.table
274
+ if self.option(column, "weights") or vectorizer.contains_tsvector(
275
+ column
276
+ ):
277
+ self.add_listener(
278
+ (table, "after_create", CreateSearchFunctionSQL(column))
279
+ )
280
+ self.add_listener(
281
+ (table, "after_drop", DropSearchFunctionSQL(column))
282
+ )
283
+ self.add_listener(
284
+ (table, "after_create", CreateSearchTriggerSQL(column))
285
+ )
286
+
287
+
288
+ search_manager = SearchManager()
289
+
290
+
291
+ def sync_trigger(
292
+ conn, table_name, tsvector_column, indexed_columns, metadata=None, options=None
293
+ ):
294
+ """Synchronize the search trigger and trigger function for the given table and
295
+ search vector column. Internally, this function executes the following SQL
296
+ queries:
297
+
298
+ - Drop the search trigger for the given table and column if it exists.
299
+ - Drop the search function for the given table and column if it exists.
300
+ - Create the search function for the given table and column.
301
+ - Create the search trigger for the given table and column.
302
+ - Update all rows for the given search vector by executing a column=column update
303
+ query for the given table.
304
+
305
+ Example::
306
+
307
+ from sqlalchemy_searchable import sync_trigger
308
+
309
+
310
+ sync_trigger(
311
+ conn,
312
+ 'article',
313
+ 'search_vector',
314
+ ['name', 'content']
315
+ )
316
+
317
+ This function is especially useful when working with Alembic migrations. In the
318
+ following example, we add a ``content`` column to the ``article`` table and then
319
+ synchronize the trigger to contain this new column::
320
+
321
+ from alembic import op
322
+ from sqlalchemy_searchable import sync_trigger
323
+
324
+
325
+ def upgrade():
326
+ conn = op.get_bind()
327
+ op.add_column('article', sa.Column('content', sa.Text))
328
+
329
+ sync_trigger(conn, 'article', 'search_vector', ['name', 'content'])
330
+
331
+ # ... same for downgrade
332
+
333
+ If you are using vectorizers, you need to initialize them in your migration
334
+ file and pass them to this function::
335
+
336
+ import sqlalchemy as sa
337
+ from alembic import op
338
+ from sqlalchemy.dialects.postgresql import HSTORE
339
+ from sqlalchemy_searchable import sync_trigger, vectorizer
340
+
341
+
342
+ def upgrade():
343
+ vectorizer.clear()
344
+
345
+ conn = op.get_bind()
346
+ op.add_column('article', sa.Column('name_translations', HSTORE))
347
+
348
+ metadata = sa.MetaData(bind=conn)
349
+ articles = sa.Table('article', metadata, autoload=True)
350
+
351
+ @vectorizer(articles.c.name_translations)
352
+ def hstore_vectorizer(column):
353
+ return sa.cast(sa.func.avals(column), sa.Text)
354
+
355
+ op.add_column('article', sa.Column('content', sa.Text))
356
+ sync_trigger(
357
+ conn,
358
+ 'article',
359
+ 'search_vector',
360
+ ['name_translations', 'content'],
361
+ metadata=metadata
362
+ )
363
+
364
+ # ... same for downgrade
365
+
366
+ :param conn: SQLAlchemy Connection object
367
+ :param table_name: name of the table to apply search trigger syncing
368
+ :param tsvector_column:
369
+ TSVector typed column which is used as the search index column
370
+ :param indexed_columns:
371
+ Full text indexed column names as a list
372
+ :param metadata:
373
+ Optional SQLAlchemy metadata object that is being used for autoloaded
374
+ Table. If None is given, then a new MetaData object is initialized within
375
+ this function.
376
+ :param options: Dictionary of configuration options
377
+ """
378
+ if metadata is None:
379
+ metadata = sa.MetaData()
380
+ table = sa.Table(table_name, metadata, autoload_with=conn)
381
+ params = dict(
382
+ tsvector_column=getattr(table.c, tsvector_column),
383
+ indexed_columns=indexed_columns,
384
+ options=options,
385
+ )
386
+ classes = [
387
+ DropSearchTriggerSQL,
388
+ DropSearchFunctionSQL,
389
+ CreateSearchFunctionSQL,
390
+ CreateSearchTriggerSQL,
391
+ ]
392
+ for class_ in classes:
393
+ conn.execute(class_(**params))
394
+ update_sql = table.update().values(
395
+ {indexed_columns[0]: sa.text(indexed_columns[0])}
396
+ )
397
+ conn.execute(update_sql)
398
+
399
+
400
+ def drop_trigger(conn, table_name, tsvector_column, metadata=None, options=None):
401
+ """
402
+ Drop the search trigger and trigger function for the given table and
403
+ search vector column. Internally, this function executes the following SQL
404
+ queries:
405
+
406
+ - Drop the search trigger for the given table if it exists.
407
+ - Drop the search function for the given table if it exists.
408
+
409
+ Example::
410
+
411
+ from alembic import op
412
+ from sqlalchemy_searchable import drop_trigger
413
+
414
+
415
+ def downgrade():
416
+ conn = op.get_bind()
417
+
418
+ drop_trigger(conn, 'article', 'search_vector')
419
+ op.drop_index('ix_article_search_vector', table_name='article')
420
+ op.drop_column('article', 'search_vector')
421
+
422
+ :param conn: SQLAlchemy Connection object
423
+ :param table_name: name of the table to apply search trigger dropping
424
+ :param tsvector_column:
425
+ TSVector typed column which is used as the search index column
426
+ :param metadata:
427
+ Optional SQLAlchemy metadata object that is being used for autoloaded
428
+ Table. If None is given, then a new MetaData object is initialized within
429
+ this function.
430
+ :param options: Dictionary of configuration options
431
+ """
432
+ if metadata is None:
433
+ metadata = sa.MetaData()
434
+ table = sa.Table(table_name, metadata, autoload_with=conn)
435
+ params = dict(tsvector_column=getattr(table.c, tsvector_column), options=options)
436
+ classes = [
437
+ DropSearchTriggerSQL,
438
+ DropSearchFunctionSQL,
439
+ ]
440
+ for class_ in classes:
441
+ conn.execute(class_(**params))
442
+
443
+
444
+ path = os.path.dirname(os.path.abspath(__file__))
445
+
446
+
447
+ with open(os.path.join(path, "expressions.sql")) as file:
448
+ sql_expressions = DDL(file.read())
449
+
450
+
451
+ def make_searchable(metadata, mapper=sa.orm.Mapper, manager=search_manager, options={}):
452
+ """
453
+ Configure SQLAlchemy-Searchable for given SQLAlchemy metadata object.
454
+
455
+ :param metadata: SQLAlchemy metadata object
456
+ :param options: Dictionary of configuration options
457
+ """
458
+ manager.options.update(options)
459
+ event.listen(mapper, "instrument_class", manager.process_mapper)
460
+ event.listen(mapper, "after_configured", manager.attach_ddl_listeners)
461
+ event.listen(metadata, "before_create", sql_expressions)
462
+
463
+
464
+ def remove_listeners(metadata, manager=search_manager, mapper=sa.orm.Mapper):
465
+ event.remove(mapper, "instrument_class", manager.process_mapper)
466
+ event.remove(mapper, "after_configured", manager.attach_ddl_listeners)
467
+ manager.remove_listeners()
468
+ event.remove(metadata, "before_create", sql_expressions)
@@ -0,0 +1,27 @@
1
+ CREATE OR REPLACE FUNCTION parse_websearch(config regconfig, search_query text)
2
+ RETURNS tsquery AS $$
3
+ SELECT
4
+ string_agg(
5
+ (
6
+ CASE
7
+ WHEN position('''' IN words.word) > 0 THEN CONCAT(words.word, ':*')
8
+ ELSE words.word
9
+ END
10
+ ),
11
+ ' '
12
+ )::tsquery
13
+ FROM (
14
+ SELECT trim(
15
+ regexp_split_to_table(
16
+ websearch_to_tsquery(config, lower(search_query))::text,
17
+ ' '
18
+ )
19
+ ) AS word
20
+ ) AS words
21
+ $$ LANGUAGE SQL IMMUTABLE;
22
+
23
+
24
+ CREATE OR REPLACE FUNCTION parse_websearch(search_query text)
25
+ RETURNS tsquery AS $$
26
+ SELECT parse_websearch('pg_catalog.simple', search_query);
27
+ $$ LANGUAGE SQL IMMUTABLE;
@@ -0,0 +1,80 @@
1
+ from functools import wraps
2
+ from inspect import isclass
3
+
4
+ import sqlalchemy as sa
5
+ from sqlalchemy.orm.attributes import InstrumentedAttribute
6
+ from sqlalchemy.sql.type_api import TypeEngine
7
+
8
+
9
+ class Vectorizer:
10
+ def __init__(self, type_vectorizers=None, column_vectorizers=None):
11
+ self.type_vectorizers = {} if type_vectorizers is None else type_vectorizers
12
+ self.column_vectorizers = (
13
+ {} if column_vectorizers is None else column_vectorizers
14
+ )
15
+
16
+ def clear(self):
17
+ """Clear all registered vectorizers."""
18
+ self.type_vectorizers = {}
19
+ self.column_vectorizers = {}
20
+
21
+ def contains_tsvector(self, tsvector_column):
22
+ if not hasattr(tsvector_column.type, "columns"):
23
+ return False
24
+ return any(
25
+ getattr(tsvector_column.table.c, column) in self
26
+ for column in tsvector_column.type.columns
27
+ )
28
+
29
+ def __contains__(self, column):
30
+ try:
31
+ self[column]
32
+ return True
33
+ except KeyError:
34
+ return False
35
+
36
+ def __getitem__(self, column):
37
+ if column in self.column_vectorizers:
38
+ return self.column_vectorizers[column]
39
+ type_class = column.type.__class__
40
+
41
+ if type_class in self.type_vectorizers:
42
+ return self.type_vectorizers[type_class]
43
+ raise KeyError(column)
44
+
45
+ def __call__(self, type_or_column):
46
+ """Decorator to register a function as a vectorizer.
47
+
48
+ :param type_or_column: the SQLAlchemy database data type or the column to
49
+ register a vectorizer for
50
+ """
51
+
52
+ def outer(func):
53
+ @wraps(func)
54
+ def wrapper(*args, **kwargs):
55
+ return func(*args, **kwargs)
56
+
57
+ if isclass(type_or_column) and issubclass(type_or_column, TypeEngine):
58
+ self.type_vectorizers[type_or_column] = wrapper
59
+ elif isinstance(type_or_column, sa.Column):
60
+ self.column_vectorizers[type_or_column] = wrapper
61
+ elif isinstance(type_or_column, InstrumentedAttribute):
62
+ prop = type_or_column.property
63
+ if not isinstance(prop, sa.orm.ColumnProperty):
64
+ raise TypeError(
65
+ "Given InstrumentedAttribute does not wrap "
66
+ "ColumnProperty. Only instances of ColumnProperty are "
67
+ "supported for vectorizer."
68
+ )
69
+ column = type_or_column.property.columns[0]
70
+
71
+ self.column_vectorizers[column] = wrapper
72
+ else:
73
+ raise TypeError(
74
+ "First argument should be either valid SQLAlchemy type, "
75
+ "Column, ColumnProperty or InstrumentedAttribute object."
76
+ )
77
+
78
+ return wrapper
79
+
80
+ return outer
@@ -0,0 +1,47 @@
1
+ Metadata-Version: 2.1
2
+ Name: sqlalchemy-searchable
3
+ Version: 2.0.0
4
+ Summary: Provides fulltext search capabilities for declarative SQLAlchemy models.
5
+ Project-URL: Code, https://github.com/kvesteri/sqlalchemy-searchable
6
+ Project-URL: Documentation, https://sqlalchemy-searchable.readthedocs.io/
7
+ Project-URL: Issue Tracker, http://github.com/kvesteri/sqlalchemy-searchable/issues
8
+ Author-email: Konsta Vesterinen <konsta@fastmonkeys.com>
9
+ License-Expression: BSD-3-Clause
10
+ License-File: LICENSE
11
+ Classifier: Environment :: Web Environment
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: BSD License
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.8
18
+ Classifier: Programming Language :: Python :: 3.9
19
+ Classifier: Programming Language :: Python :: 3.10
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Topic :: Internet :: WWW/HTTP :: Dynamic Content
22
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
23
+ Requires-Python: >=3.8
24
+ Requires-Dist: sqlalchemy-utils>=0.40.0
25
+ Requires-Dist: sqlalchemy>=1.4
26
+ Description-Content-Type: text/x-rst
27
+
28
+ SQLAlchemy-Searchable
29
+ =====================
30
+
31
+ |Version Status| |Downloads|
32
+
33
+ Fulltext searchable models for SQLAlchemy. Only supports PostgreSQL
34
+
35
+
36
+ Resources
37
+ ---------
38
+
39
+ - `Documentation <https://sqlalchemy-searchable.readthedocs.io/>`_
40
+ - `Issue Tracker <http://github.com/kvesteri/sqlalchemy-searchable/issues>`_
41
+ - `Code <http://github.com/kvesteri/sqlalchemy-searchable/>`_
42
+
43
+
44
+ .. |Version Status| image:: https://img.shields.io/pypi/v/SQLAlchemy-Searchable.svg
45
+ :target: https://pypi.python.org/pypi/SQLAlchemy-Searchable/
46
+ .. |Downloads| image:: https://img.shields.io/pypi/dm/SQLAlchemy-Searchable.svg
47
+ :target: https://pypi.python.org/pypi/SQLAlchemy-Searchable/
@@ -0,0 +1,7 @@
1
+ sqlalchemy_searchable/__init__.py,sha256=t0ahfsv2me-nJRHpOb1Zz22fL05s0V_ZIr5tY3t_2Yk,15621
2
+ sqlalchemy_searchable/expressions.sql,sha256=FJPoygBzVbfg3fdyIqWW3Lz118l3CvLYYi8GAEWY2sI,696
3
+ sqlalchemy_searchable/vectorizers.py,sha256=G5f6Qyqm0tcXogL9OPCGXi8d-s92c5489EKicOUDuqc,2835
4
+ sqlalchemy_searchable-2.0.0.dist-info/METADATA,sha256=d4izduiTxs6tu2iEOfIb8mMTYnwPGB_zcdrLEFe3BXQ,1896
5
+ sqlalchemy_searchable-2.0.0.dist-info/WHEEL,sha256=9QBuHhg6FNW7lppboF2vKVbCGTVzsFykgRQjjlajrhA,87
6
+ sqlalchemy_searchable-2.0.0.dist-info/licenses/LICENSE,sha256=aKpRvWCrOmo-gm2RyB2KhgP4FtG6tTWi_xi_fWmqmwo,1437
7
+ sqlalchemy_searchable-2.0.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.18.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,27 @@
1
+ Copyright (c) 2012, Konsta Vesterinen
2
+
3
+ All rights reserved.
4
+
5
+ Redistribution and use in source and binary forms, with or without
6
+ modification, are permitted provided that the following conditions are met:
7
+
8
+ * Redistributions of source code must retain the above copyright notice, this
9
+ list of conditions and the following disclaimer.
10
+
11
+ * Redistributions in binary form must reproduce the above copyright notice,
12
+ this list of conditions and the following disclaimer in the documentation
13
+ and/or other materials provided with the distribution.
14
+
15
+ * The names of the contributors may not be used to endorse or promote products
16
+ derived from this software without specific prior written permission.
17
+
18
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
19
+ ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
20
+ WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
21
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE FOR ANY DIRECT,
22
+ INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
23
+ BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
24
+ DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
25
+ LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE
26
+ OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF
27
+ ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.