From 7741eb6cfa1717c7397fedb707a5139e1aabe1ad Mon Sep 17 00:00:00 2001 From: Georgy Moiseev Date: Tue, 27 Sep 2022 11:43:46 +0300 Subject: [PATCH 01/14] code-health: explicit Connection named args Using ``**kwargs`` with ``kwargs.get`` seems like an old-style way to force argument to be a named one. It is seems like a bad practice and makes it harder to make a readable documentation. Part of #67 --- tarantool/connection.py | 64 ++++++++++++++++-------------------- tarantool/connection_pool.py | 22 +++++++------ 2 files changed, 40 insertions(+), 46 deletions(-) diff --git a/tarantool/connection.py b/tarantool/connection.py index eb11ab5a..597e566e 100644 --- a/tarantool/connection.py +++ b/tarantool/connection.py @@ -129,11 +129,11 @@ def connect(self): raise NotImplementedError @abc.abstractmethod - def call(self, func_name, *args, **kwargs): + def call(self, func_name, *args): raise NotImplementedError @abc.abstractmethod - def eval(self, expr, *args, **kwargs): + def eval(self, expr, *args): raise NotImplementedError @abc.abstractmethod @@ -145,15 +145,15 @@ def insert(self, space_name, values): raise NotImplementedError @abc.abstractmethod - def delete(self, space_name, key, **kwargs): + def delete(self, space_name, key, *, index=None): raise NotImplementedError @abc.abstractmethod - def upsert(self, space_name, tuple_value, op_list, **kwargs): + def upsert(self, space_name, tuple_value, op_list, *, index=None): raise NotImplementedError @abc.abstractmethod - def update(self, space_name, key, op_list, **kwargs): + def update(self, space_name, key, op_list, *, index=None): raise NotImplementedError @abc.abstractmethod @@ -161,11 +161,12 @@ def ping(self, notime): raise NotImplementedError @abc.abstractmethod - def select(self, space_name, key, **kwargs): + def select(self, space_name, key, *, offset=None, limit=None, + index=None, iterator=None): raise NotImplementedError @abc.abstractmethod - def execute(self, query, params, **kwargs): + def execute(self, query, params): raise NotImplementedError @@ -733,7 +734,7 @@ def insert(self, space_name, values): request = RequestInsert(self, space_name, values) return self._send_request(request) - def delete(self, space_name, key, **kwargs): + def delete(self, space_name, key, *, index=0): ''' Execute DELETE request. Delete a single record identified by `key`. If you're using a secondary @@ -746,17 +747,16 @@ def delete(self, space_name, key, **kwargs): :rtype: `Response` instance ''' - index_name = kwargs.get("index", 0) key = check_key(key) if isinstance(space_name, str): space_name = self.schema.get_space(space_name).sid - if isinstance(index_name, str): - index_name = self.schema.get_index(space_name, index_name).iid - request = RequestDelete(self, space_name, index_name, key) + if isinstance(index, str): + index = self.schema.get_index(space_name, index).iid + request = RequestDelete(self, space_name, index, key) return self._send_request(request) - def upsert(self, space_name, tuple_value, op_list, **kwargs): + def upsert(self, space_name, tuple_value, op_list, *, index=0): ''' Execute UPSERT request. @@ -819,18 +819,17 @@ def upsert(self, space_name, tuple_value, op_list, **kwargs): # Delete two fields, starting with the second field [('#', 2, 2)] ''' - index_name = kwargs.get("index", 0) if isinstance(space_name, str): space_name = self.schema.get_space(space_name).sid - if isinstance(index_name, str): - index_name = self.schema.get_index(space_name, index_name).iid + if isinstance(index, str): + index = self.schema.get_index(space_name, index).iid op_list = self._ops_process(space_name, op_list) - request = RequestUpsert(self, space_name, index_name, tuple_value, + request = RequestUpsert(self, space_name, index, tuple_value, op_list) return self._send_request(request) - def update(self, space_name, key, op_list, **kwargs): + def update(self, space_name, key, op_list, *, index=0): ''' Execute an UPDATE request. @@ -894,15 +893,14 @@ def update(self, space_name, key, op_list, **kwargs): # Delete two fields, starting with the second field [('#', 2, 2)] ''' - index_name = kwargs.get("index", 0) key = check_key(key) if isinstance(space_name, str): space_name = self.schema.get_space(space_name).sid - if isinstance(index_name, str): - index_name = self.schema.get_index(space_name, index_name).iid + if isinstance(index, str): + index = self.schema.get_index(space_name, index).iid op_list = self._ops_process(space_name, op_list) - request = RequestUpdate(self, space_name, index_name, key, op_list) + request = RequestUpdate(self, space_name, index, key, op_list) return self._send_request(request) def ping(self, notime=False): @@ -923,7 +921,7 @@ def ping(self, notime=False): return "Success" return t1 - t0 - def select(self, space_name, key=None, **kwargs): + def select(self, space_name, key=None, *, offset=0, limit=0xffffffff, index=0, iterator=None): ''' Execute a SELECT request. Select and retrieve data from the database. @@ -964,17 +962,11 @@ def select(self, space_name, key=None, **kwargs): >>> select(0, []) ''' - # Initialize arguments and its defaults from **kwargs - offset = kwargs.get("offset", 0) - limit = kwargs.get("limit", 0xffffffff) - index_name = kwargs.get("index", 0) - iterator_type = kwargs.get("iterator") - - if iterator_type is None: - iterator_type = ITERATOR_EQ + if iterator is None: + iterator = ITERATOR_EQ if key is None or (isinstance(key, (list, tuple)) and len(key) == 0): - iterator_type = ITERATOR_ALL + iterator = ITERATOR_ALL # Perform smart type checking (scalar / list of scalars / list of # tuples) @@ -982,10 +974,10 @@ def select(self, space_name, key=None, **kwargs): if isinstance(space_name, str): space_name = self.schema.get_space(space_name).sid - if isinstance(index_name, str): - index_name = self.schema.get_index(space_name, index_name).iid - request = RequestSelect(self, space_name, index_name, key, offset, - limit, iterator_type) + if isinstance(index, str): + index = self.schema.get_index(space_name, index).iid + request = RequestSelect(self, space_name, index, key, offset, + limit, iterator) response = self._send_request(request) return response diff --git a/tarantool/connection_pool.py b/tarantool/connection_pool.py index 165d655e..8c6dea5b 100644 --- a/tarantool/connection_pool.py +++ b/tarantool/connection_pool.py @@ -446,30 +446,30 @@ def insert(self, space_name, values, *, mode=Mode.RW): return self._send(mode, 'insert', space_name, values) - def delete(self, space_name, key, *, mode=Mode.RW, **kwargs): + def delete(self, space_name, key, *, index=0, mode=Mode.RW): ''' :param tarantool.Mode mode: Request mode (default is RW). ''' - return self._send(mode, 'delete', space_name, key, **kwargs) + return self._send(mode, 'delete', space_name, key, index=index) - def upsert(self, space_name, tuple_value, op_list, *, mode=Mode.RW, **kwargs): + def upsert(self, space_name, tuple_value, op_list, *, index=0, mode=Mode.RW): ''' :param tarantool.Mode mode: Request mode (default is RW). ''' return self._send(mode, 'upsert', space_name, tuple_value, - op_list, **kwargs) + op_list, index=index) - def update(self, space_name, key, op_list, *, mode=Mode.RW, **kwargs): + def update(self, space_name, key, op_list, *, index=0, mode=Mode.RW): ''' :param tarantool.Mode mode: Request mode (default is RW). ''' return self._send(mode, 'update', space_name, key, - op_list, **kwargs) + op_list, index=index) - def ping(self, *, mode=None, **kwargs): + def ping(self, notime=False, *, mode=None): ''' :param tarantool.Mode mode: Request mode. ''' @@ -477,15 +477,17 @@ def ping(self, *, mode=None, **kwargs): if mode is None: raise ValueError("Please, specify 'mode' keyword argument") - return self._send(mode, 'ping', **kwargs) + return self._send(mode, 'ping', notime) - def select(self, space_name, key, *, mode=Mode.ANY, **kwargs): + def select(self, space_name, key, *, offset=0, limit=0xffffffff, + index=0, iterator=None, mode=Mode.ANY): ''' :param tarantool.Mode mode: Request mode (default is ANY). ''' - return self._send(mode, 'select', space_name, key, **kwargs) + return self._send(mode, 'select', space_name, key, offset=offset, limit=limit, + index=index, iterator=iterator) def execute(self, query, params=None, *, mode=None): ''' From 9674521cca1a2ede11e44e45d2d0266f9f7953e8 Mon Sep 17 00:00:00 2001 From: Georgy Moiseev Date: Thu, 22 Sep 2022 18:33:38 +0300 Subject: [PATCH 02/14] doc: add build dependencies Part of #67 --- requirements-doc.txt | 1 + 1 file changed, 1 insertion(+) create mode 100644 requirements-doc.txt diff --git a/requirements-doc.txt b/requirements-doc.txt new file mode 100644 index 00000000..c6fe59fc --- /dev/null +++ b/requirements-doc.txt @@ -0,0 +1 @@ +sphinx==5.2.1 From 09462bec589bf83e5eeb6c531140e86874e9f36a Mon Sep 17 00:00:00 2001 From: Georgy Moiseev Date: Fri, 7 Oct 2022 13:12:44 +0300 Subject: [PATCH 03/14] readme: add documentation build guide Part of #67 --- README.rst | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/README.rst b/README.rst index 500e3a1d..ae22a6d9 100644 --- a/README.rst +++ b/README.rst @@ -113,6 +113,29 @@ On Windows: * ``REMOTE_TARANTOOL_CONSOLE_PORT=3302``. * Run ``python setup.py test``. +Build docs +^^^^^^^^^^ + +To build documentation, first you must install its build requirements: + +.. code-block:: bash + + $ pip install -r requirements-doc.txt + +Then run + +.. code-block:: bash + + $ make docs + +You may host local documentation server with + +.. code-block:: bash + + $ python -m http.server --directory build/sphinx/html + +Open ``localhost:8000`` in your browser to read the docs. + .. _`Tarantool`: .. _`Tarantool Database`: .. _`Tarantool homepage`: https://tarantool.io From dbbfd752f092b0b9c473f2b592a154cd64c994d0 Mon Sep 17 00:00:00 2001 From: Georgy Moiseev Date: Fri, 23 Sep 2022 15:03:58 +0300 Subject: [PATCH 04/14] doc: update AUTHORS Part of #67 --- AUTHORS | 2 ++ doc/conf.py | 8 ++++---- setup.py | 4 ++-- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/AUTHORS b/AUTHORS index 1bc49242..f77bd18d 100644 --- a/AUTHORS +++ b/AUTHORS @@ -33,3 +33,5 @@ Vladislav Shpilevoy Artem Morozov Sergey Bronnikov Yaroslav Lobankov +Georgy Moiseev +Oleg Jukovec diff --git a/doc/conf.py b/doc/conf.py index 3e96e59e..8cf835f1 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -45,7 +45,7 @@ # General information about the project. project = u'Tarantool python client library' -copyright = u'2011, Konstantin Cherkasoff' +copyright = u'2011-2022, tarantool-python AUTHORS' # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the @@ -190,7 +190,7 @@ # (source start file, target name, title, author, documentclass [howto/manual]). latex_documents = [ ('index', 'Tarantoolpythonclientlibrary.tex', u'Tarantool python client library Documentation', - u'Konstantin Cherkasoff', 'manual'), + u'tarantool-python AUTHORS', 'manual'), ] # The name of an image file (relative to this directory) to place at the top of @@ -220,7 +220,7 @@ # (source start file, name, description, authors, manual section). man_pages = [ ('index', 'tarantoolpythonclientlibrary', u'Tarantool python client library Documentation', - [u'Konstantin Cherkasoff'], 1) + [u'tarantool-python AUTHORS'], 1) ] # If true, show URL addresses after external links. @@ -234,7 +234,7 @@ # dir menu entry, description, category) texinfo_documents = [ ('index', 'Tarantoolpythonclientlibrary', u'Tarantool python client library Documentation', - u'Konstantin Cherkasoff', 'Tarantoolpythonclientlibrary', 'One line description of project.', + u'tarantool-python AUTHORS', 'Tarantoolpythonclientlibrary', 'One line description of project.', 'Miscellaneous'), ] diff --git a/setup.py b/setup.py index 3ee55f05..4aa0e7f1 100755 --- a/setup.py +++ b/setup.py @@ -66,8 +66,8 @@ def find_version(*file_paths): package_dir={"tarantool": os.path.join("tarantool")}, version=find_version('tarantool', '__init__.py'), platforms=["all"], - author="Konstantin Cherkasoff", - author_email="k.cherkasoff@gmail.com", + author="tarantool-python AUTHORS", + author_email="admin@tarantool.org", url="https://github.com/tarantool/tarantool-python", license="BSD", description="Python client library for Tarantool 1.6 Database", From d56bc3d04d252c1dfebfdca81a52d309a300a758 Mon Sep 17 00:00:00 2001 From: Georgy Moiseev Date: Fri, 7 Oct 2022 13:21:09 +0300 Subject: [PATCH 05/14] doc: use sphinxdoc HTML theme Default 'classic' theme [1] was used by Python 2 documentation. It's not well-suited for modern resolutions and it's sidebar width isn't configurable, which is crucial for modules API ToC correct display. 1. https://www.sphinx-doc.org/en/master/usage/theming.html Part of #67 --- CHANGELOG.md | 1 + doc/_static/tarantool.css | 19 ------------------- doc/conf.py | 8 ++++---- 3 files changed, 5 insertions(+), 23 deletions(-) delete mode 100644 doc/_static/tarantool.css diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d415421..09fb119b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -138,6 +138,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Bump msgpack requirement to 1.0.4 (PR #223). The only reason of this bump is various vulnerability fixes, msgpack>=0.4.0 and msgpack-python==0.4.0 are still supported. +- Change documentation HTML theme (#67). ### Fixed diff --git a/doc/_static/tarantool.css b/doc/_static/tarantool.css deleted file mode 100644 index fb94f7ef..00000000 --- a/doc/_static/tarantool.css +++ /dev/null @@ -1,19 +0,0 @@ -@import url("default.css"); - -cite, code, tt { - font-family: 'Consolas', 'Deja Vu Sans Mono', - 'Bitstream Vera Sans Mono', monospace; -} - -dl.attribute, dl.class, dl.method { - margin-top: 2em; - margin-bottom: 2em; -} - -tt { - font-size: 100%; -} - -th { - background-color: #fff; -} diff --git a/doc/conf.py b/doc/conf.py index 8cf835f1..9fba7f64 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -93,16 +93,16 @@ # -- Options for HTML output --------------------------------------------------- -html_style = 'tarantool.css' +#html_style = 'style.css' # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. -html_theme = 'default' +html_theme = 'sphinxdoc' # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the # documentation. -#html_theme_options = {} +html_theme_options = {'sidebarwidth': '30%'} # Add any paths that contain custom themes here, relative to this directory. #html_theme_path = [] @@ -126,7 +126,7 @@ # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". -html_static_path = ['_static'] +#html_static_path = ['_static'] # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, # using the given strftime format. From 768846a5e8f60b946d5844bc43028b0a93571eaa Mon Sep 17 00:00:00 2001 From: Georgy Moiseev Date: Fri, 7 Oct 2022 13:25:08 +0300 Subject: [PATCH 06/14] doc: remove unused Russian translation files Even though index, guide and quick start documentation pages have Russian translation. it isn't published on readthedocs [1]. Since we don't plan to have a Russian connector documentation for now, this patch removes the files. 1. https://tarantool-python.readthedocs.io/en/latest/ Part of #67 --- doc/{guide.en.rst => guide.rst} | 0 doc/guide.ru.rst | 284 -------------------- doc/index.rst | 4 +- doc/index.ru.rst | 65 ----- doc/{quick-start.en.rst => quick-start.rst} | 0 doc/quick-start.ru.rst | 97 ------- 6 files changed, 2 insertions(+), 448 deletions(-) rename doc/{guide.en.rst => guide.rst} (100%) delete mode 100644 doc/guide.ru.rst delete mode 100644 doc/index.ru.rst rename doc/{quick-start.en.rst => quick-start.rst} (100%) delete mode 100644 doc/quick-start.ru.rst diff --git a/doc/guide.en.rst b/doc/guide.rst similarity index 100% rename from doc/guide.en.rst rename to doc/guide.rst diff --git a/doc/guide.ru.rst b/doc/guide.ru.rst deleted file mode 100644 index 4a7e7bcf..00000000 --- a/doc/guide.ru.rst +++ /dev/null @@ -1,284 +0,0 @@ -.. encoding: utf-8 - -Руководство разработчика -======================== - -Базовые понятия ---------------- - -Спейсы -^^^^^^ - -Спейсы в Tarantool — это коллекции кортежей. -Как правило, кортежи в спейсе представляют собой объекты одного типа, -хотя это и не обязательно. - -.. note:: Аналог спейса — таблица в традиционных (SQL) базах данных. - -Спейсы имеют целочисленные идентификаторы, которые задаются в конфигурации сервера. -Чтобы обращаться к спейсу как к именованному объекту, можно использовать метод -:meth:`Connection.space() ` -и экземпляр класса :class:`~tarantool.space.Space`. - -Пример:: - - >>> customer = connection.space(0) - >>> customer.insert(('FFFF', 'Foxtrot')) - - -Типы полей -^^^^^^^^^^ - -Tarantool поддерживает три типа полей: ``STR``, ``NUM`` и ``NUM64``. -Эти типы используются только при конфигурации индексов, -но не сохраняются с данными кортежа и не передаются между сервером и клиентом. -Таким образом, с точки зрения клиента, поля кортежей — это просто байтовые массивы -без явно заданных типов. - -Для разработчика на Python намного удобнее использовать родные типы: -``int``, ``long``, ``unicode`` (для Python 3.x - ``int`` и ``str``). -Для бинарных данных следует использовать тип ``bytes`` -(в этом случае приведение типов не производится). - -Типы данных Tarantool соответствуют следующим типам Python: - • ``RAW`` - ``bytes`` - • ``STR`` - ``unicode`` (``str`` for Python 3.x) - • ``NUM`` - ``int`` - • ``NUM64`` - ``int`` or ``long`` (``int`` for Python 3.x) - -Для автоматического приведения типов необходимо объявить схему: - >>> import tarantool - >>> schema = { - 0: { # Space description - 'name': 'users', # Space name - 'default_type': tarantool.STR, # Type that is used to decode fields not listed below - 'fields': { - 0: ('user_id', tarantool.NUM), # (field name, field type) - 1: ('num64field', tarantool.NUM64), - 2: ('strfield', tarantool.STR), - #2: { 'name': 'strfield', 'type': tarantool.STR }, # Alternative syntax - #2: tarantool.STR # Alternative syntax - }, - 'indexes': { - 0: ('pk', [0]), # (name, [field_no]) - #0: { 'name': 'pk', 'fields': [0]}, # Alternative syntax - #0: [0], # Alternative syntax - } - } - } - >>> connection = tarantool.connect(host = 'localhost', port=33013, schema = schema) - >>> demo = connection.space('users') - >>> demo.insert((0, 12, u'this is a unicode string')) - >>> demo.select(0) - [(0, 12, u'this is a unicode string')] - -Как видно из примера, все значения были преобразованы в Python-типы в соответствии со схемой. - -Кортеж Tarantool может содержать произвольное количество полей. -Если какие-то поля не объявлены в схеме, то для конвертации будет использован ``default_type``. - -Поля с "сырыми" байтами следует использовать, если приложение работает с -двоичными данными (например, с изображениями или Python-объектами, сохраненными с помощью ``pickle``). - -Возможно также указать тип для CALL запросов: - - >>> ... - # Copy schema decription from 'users' space - >>> connection.call("box.select", '0', '0', 0L, space_name='users'); - [(0, 12, u'this is unicode string')] - # Provide schema description explicitly - >>> field_defs = [('numfield', tarantool.NUM), ('num64field', tarantool.NUM)] - >>> connection.call("box.select", '0', '1', 184L, field_defs = field_defs, default_type = tarantool.STR); - [(0, 12, u'this is unicode string')] - -.. note:: - - Python 2.6 добавляет синоним :class:`bytes` к типу :class:`str` (также поддерживается синтаксис ``b''``). - - -.. note:: Для преобразования между ``bytes`` и ``unicode`` всегда используется **utf-8**. - - - -Результат запроса -^^^^^^^^^^^^^^^^^ - -Запросы (:meth:`insert() `, -:meth:`delete() `, -:meth:`update() `, -:meth:`select() `) возвращают экземпляр -класса :class:`~tarantool.response.Response`. - -Класс :class:`~tarantool.response.Response` унаследован от стандартного типа `list`, -поэтому, по сути, результат всегда представляет собой список кортежей. - -Кроме того, у экземпляра :class:`~tarantool.response.Response` есть атрибут ``rowcount``. -Этот атрибут содержит число записей, которые затронул запроc. -Например, для запроса :meth:`delete() ` -``rowcount`` равен ``1``, если запись была удалена. - - - -Подключение к серверу ---------------------- - -Для подключения к серверу следует использовать метод :meth:`tarantool.connect`. -Он возвращает экземпляр класса :class:`~tarantool.connection.Connection`. - -Пример:: - - >>> import tarantool - >>> connection = tarantool.connect("localhost", 33013) - >>> type(connection) - - - - -Работа с данными ----------------- - -Tarantool поддерживает четыре базовых операции: -**insert**, **delete**, **update** и **select**. - - -Добавление и замещение записей -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -Для добавления и замещения записей следует использовать метод -:meth:`Space.insert() `:: - - >>> user.insert((user_id, email, int(time.time()))) - -Первый элемент кортежа — это всегда его уникальный первичный ключ. - -Если запись с таким ключом уже существует, она будет замещена -без какого-либо предупреждения или сообщения об ошибке. - -.. note:: Для :meth:`Space.insert() ` ``Response.rowcount`` всегда равен ``1``. - - -Удаление записей -^^^^^^^^^^^^^^^^ - -Для удаления записей следует использовать метод -:meth:`Space.delete() `:: - - >>> user.delete(primary_key) - -.. note:: ``Response.rowcount`` равен ``1``, если запись была удалена. - Если запись не найдена, то ``Response.rowcount`` равен ``0``. - - -Обновление записей -^^^^^^^^^^^^^^^^^^ - -Запрос *update* в Tarantool позволяет одновременно и атомарно обновить несколько -полей одного кортежа. - -Для обновления записей следует использовать метод -:meth:`Space.update() `. - -Пример:: - - >>> user.update(1001, [(1, '=', 'John'), (2, '=', 'Smith')]) - -В этом примере для полей ``1`` и ``2`` устанавливаются новые значения. - -Метод :meth:`Space.update() ` позволяет обновлять -сразу несколько полей кортежа. - -Tarantool поддерживает следующие операции обновления: - • ``'='`` – установить новое значение поля - • ``'+'`` – прибавить аргумент к значению поля (*оба аргумента рассматриваются как знаковые 32-битные целые числа*) - • ``'^'`` – битовый AND (*только для 32-битных полей*) - • ``'|'`` – битовый XOR (*только для 32-битных полей*) - • ``'&'`` – битовый OR (*только для 32-битных полей*) - • ``'splice'`` – аналог функции `splice в Perl `_ - - -.. note:: Нулевое (т.е. [0]) поле кортежа нельзя обновить, - поскольку оно является первичным ключом. - -.. seealso:: Подробности можно найти в документации по методу :meth:`Space.update() `. - -.. warning:: Операция ``'splice'`` пока не реализована. - - -Выборка записей -^^^^^^^^^^^^^^^ - -Для выборки записей следует использовать метод -:meth:`Space.select() `. -Запрос *SELECT* может возвращать одну или множество записей. - - -.. rubric:: Запрос по первичному ключу - -Извлечь запись по её первичному ключу ``3800``:: - - >>> world.select(3800) - [(3800, u'USA', u'Texas', u'Dallas', 1188580)] - - -.. rubric:: Запрос по вторичному индексу - -:: - - >>> world.select('USA', index=1) - [(3796, u'USA', u'Texas', u'Houston', 1953631), - (3801, u'USA', u'Texas', u'Huston', 10000), - (3802, u'USA', u'California', u'Los Angeles', 10000), - (3805, u'USA', u'California', u'San Francisco', 776733), - (3800, u'USA', u'Texas', u'Dallas', 1188580), - (3794, u'USA', u'California', u'Los Angeles', 3694820)] - - -Аргумент ``index=1`` указывает, что при запросе следует использовать индекс ``1``. -По умолчанию используется первичный ключ (``index=0``). - -.. note:: Вторичные индексы должны быть явно объявлены в конфигурации сервера. - - -.. rubric:: Запрос записей по нескольким ключам - -.. note:: Это аналог ``where key in (k1, k2, k3...)``. - -Извлечь записи со значениями первичного ключа ``3800``, ``3805`` и ``3796``:: - - >>>> world.select([3800, 3805, 3796]) - [(3800, u'USA', u'Texas', u'Dallas', 1188580), - (3805, u'USA', u'California', u'San Francisco', 776733), - (3796, u'USA', u'Texas', u'Houston', 1953631)] - - -.. rubric:: Запрос по составному индексу - -Извлечь данные о городах в Техасе:: - - >>> world.select([('USA', 'Texas')], index=1) - [(3800, u'USA', u'Texas', u'Dallas', 1188580), (3796, u'USA', u'Texas', u'Houston', 1953631)] - - -.. rubric:: Запрос с явным указанием типов полей - -Tarantool не имеет строгой схемы, так что поля кортежей являются просто байтовыми массивами. -Можно указывать типы полей непосредственно в параметре ``schema`` для ```Connection``. - -Вызов хранимых функций ----------------------- - -С помощью хранимых процедур на Lua можно делать выборки и изменять данные, -получать доcтуп к конфигурации и выполнять административные функции. - -Для вызова хранимых функций следует использовать метод -:meth:`Connection.call() `. -Кроме того, у этого метода есть псевдоним: :meth:`Space.call() `. - -Пример:: - - >>> server.call("box.select_range", (1, 3, 2, 'AAAA')) - [(3800, u'USA', u'Texas', u'Dallas', 1188580), (3794, u'USA', u'California', u'Los Angeles', 3694820)] - -.. seealso:: - - Tarantool documentation » `Insert one million tuples with a Lua stored procedure `_ diff --git a/doc/index.rst b/doc/index.rst index 6f4ff0ca..f2a962c9 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -27,8 +27,8 @@ Documentation .. toctree:: :maxdepth: 1 - quick-start.en - guide.en + quick-start + guide .. seealso:: `Tarantool documentation`_ diff --git a/doc/index.ru.rst b/doc/index.ru.rst deleted file mode 100644 index 562dd6c6..00000000 --- a/doc/index.ru.rst +++ /dev/null @@ -1,65 +0,0 @@ -.. encoding: utf-8 - -Клиентская библиотека для платформы Tarantool -============================================= - -:Версия: |version| - -.. sidebar:: Загрузить - - * `PyPI`_ - * `GitHub`_ - - **Установить** - - .. code-block:: none - - $ pip install tarantool - - -`Tarantool`_ – это очень быстрая платформа in-memory-вычислений. -Изначально разработана в `VK`_ и выпущена под лицензией `BSD`_. - - - -Документация ------------- -.. toctree:: - :maxdepth: 1 - - quick-start.ru - guide.ru - -.. seealso:: `Документация Tarantool`_ - - -Справочник по API ------------------ -.. toctree:: - :maxdepth: 2 - - api/module-tarantool.rst - api/class-connection.rst - api/class-mesh-connection.rst - api/class-space.rst - api/class-response.rst - - - -.. Indices and tables -.. ================== -.. -.. * :ref:`genindex` -.. * :ref:`modindex` -.. * :ref:`search` - - - -.. _`Tarantool`: -.. _`Tarantool homepage`: https://tarantool.io -.. _`Документация Tarantool`: https://www.tarantool.io/en/doc/latest/ -.. _`VK`: https://vk.company -.. _`BSD`: -.. _`BSD license`: http://www.gnu.org/licenses/license-list.html#ModifiedBSD -.. _`PyPI`: http://pypi.python.org/pypi/tarantool -.. _`GitHub`: https://github.com/coxx/tarantool-python diff --git a/doc/quick-start.en.rst b/doc/quick-start.rst similarity index 100% rename from doc/quick-start.en.rst rename to doc/quick-start.rst diff --git a/doc/quick-start.ru.rst b/doc/quick-start.ru.rst deleted file mode 100644 index 5c9c0170..00000000 --- a/doc/quick-start.ru.rst +++ /dev/null @@ -1,97 +0,0 @@ -Краткое руководство -=================== - -Подключение к серверу ---------------------- - -Создаем подключение к серверу:: - - >>> import tarantool - >>> server = tarantool.connect("localhost", 33013) - - -Создаем объект доступа к спейсу -------------------------------- - -Экземпляр :class:`~tarantool.space.Space` — это именованный объект для доступа -к спейсу ключей. - -Создаем объект ``demo``, который будет использоваться для доступа к спейсу ``cool_space``:: - - >>> demo = server.space(cool_space) - -Все последующие операции с ``cool_space`` выполняются при помощи методов объекта ``demo``. - - -Работа с данными ----------------- - -Select -^^^^^^ - -Извлечь одну запись с id ``'AAAA'`` из спейса ``demo`` -по первичному ключу (нулевой индекс):: - - >>> demo.select('AAAA') - -Извлечь несколько записей, используя первичный индекс:: - - >>> demo.select(['AAAA', 'BBBB', 'CCCC']) - [('AAAA', 'Alpha'), ('BBBB', 'Bravo'), ('CCCC', 'Charlie')] - - -Insert -^^^^^^ - -Вставить кортеж ``('DDDD', 'Delta')`` в спейс ``demo``:: - - >>> demo.insert(('DDDD', 'Delta')) - -Первый элемент является первичным ключом для этого кортежа. - - -Update -^^^^^^ - -Обновить запись с id ``'DDDD'``, поместив значение ``'Denver'`` -в поле ``1``:: - - >>> demo.update('DDDD', [(1, '=', 'Denver')]) - [('DDDD', 'Denver')] - -Для поиска записи :meth:`~tarantool.space.Space.update` всегда использует -первичный индекс. -Номера полей начинаются с нуля. -Таким образом, поле ``0`` — это первый элемент кортежа. - - -Delete -^^^^^^ - -Удалить одиночную запись с идентификатором ``'DDDD'``:: - - >>> demo.delete('DDDD') - [('DDDD', 'Denver')] - -Для поиска записи :meth:`~tarantool.space.Space.delete` всегда использует -первичный индекс. - - -Вызов хранимых функций ----------------------- - -Для вызова хранимых функций можно использовать метод -:meth:`Connection.call() `:: - - >>> server.call("box.select_range", (0, 0, 2, 'AAAA')) - [('AAAA', 'Alpha'), ('BBBB', 'Bravo')] - -То же самое можно получить при помощи метода -:meth:`Space.call() `:: - - >>> demo.call("box.select_range", (0, 0, 2, 'AAAA')) - [('AAAA', 'Alpha'), ('BBBB', 'Bravo')] - -Метод :meth:`Space.call() ` — это просто -псевдоним для -:meth:`Connection.call() ` From 22b1eb18021e20695fbeceb807a86f799a6add4a Mon Sep 17 00:00:00 2001 From: Georgy Moiseev Date: Fri, 7 Oct 2022 13:35:42 +0300 Subject: [PATCH 07/14] doc: describe tarantool submodules API Add or update docstings for submodule files contents. Submodules are not meant to be used directly in code, but their descriptions will be imported automatically to core module API description. It also may help developers of this module. Every submodule that could be of use is indexed in documentation now. Set sphinx autodoc module to describe members and inherited members by default. Dataclass with factories display is not parsed correctly, see [1] issue. 1. https://github.com/sphinx-doc/sphinx/issues/10893 Part of #67 --- doc/api/class-connection.rst | 10 - doc/api/class-mesh-connection.rst | 8 - doc/api/class-response.rst | 9 - doc/api/class-space.rst | 9 - doc/api/submodule-connection-pool.rst | 4 + doc/api/submodule-connection.rst | 4 + doc/api/submodule-dbapi.rst | 4 + doc/api/submodule-error.rst | 4 + doc/api/submodule-mesh-connection.rst | 4 + doc/api/submodule-msgpack-ext-types.rst | 19 + doc/api/submodule-msgpack-ext.rst | 49 + doc/api/submodule-request.rst | 4 + doc/api/submodule-response.rst | 4 + doc/api/submodule-schema.rst | 4 + doc/api/submodule-space.rst | 4 + doc/api/submodule-utils.rst | 4 + doc/conf.py | 15 +- doc/index.rst | 16 +- requirements-doc.txt | 1 + tarantool/connection.py | 1220 ++++++++++++----- tarantool/connection_pool.py | 655 +++++++-- tarantool/dbapi.py | 255 +++- tarantool/error.py | 223 +-- tarantool/mesh_connection.py | 317 ++++- tarantool/msgpack_ext/datetime.py | 35 + tarantool/msgpack_ext/decimal.py | 304 ++-- tarantool/msgpack_ext/interval.py | 35 + tarantool/msgpack_ext/packer.py | 19 + tarantool/msgpack_ext/types/datetime.py | 475 ++++++- tarantool/msgpack_ext/types/interval.py | 197 ++- .../msgpack_ext/types/timezones/__init__.py | 4 + .../types/timezones/gen-timezones.sh | 5 +- .../msgpack_ext/types/timezones/timezones.py | 5 +- .../types/timezones/validate_timezones.py | 5 + tarantool/msgpack_ext/unpacker.py | 22 + tarantool/msgpack_ext/uuid.py | 46 +- tarantool/request.py | 370 ++++- tarantool/response.py | 165 ++- tarantool/schema.py | 210 ++- tarantool/space.py | 80 +- tarantool/utils.py | 51 + 41 files changed, 3873 insertions(+), 1001 deletions(-) delete mode 100644 doc/api/class-connection.rst delete mode 100644 doc/api/class-mesh-connection.rst delete mode 100644 doc/api/class-response.rst delete mode 100644 doc/api/class-space.rst create mode 100644 doc/api/submodule-connection-pool.rst create mode 100644 doc/api/submodule-connection.rst create mode 100644 doc/api/submodule-dbapi.rst create mode 100644 doc/api/submodule-error.rst create mode 100644 doc/api/submodule-mesh-connection.rst create mode 100644 doc/api/submodule-msgpack-ext-types.rst create mode 100644 doc/api/submodule-msgpack-ext.rst create mode 100644 doc/api/submodule-request.rst create mode 100644 doc/api/submodule-response.rst create mode 100644 doc/api/submodule-schema.rst create mode 100644 doc/api/submodule-space.rst create mode 100644 doc/api/submodule-utils.rst diff --git a/doc/api/class-connection.rst b/doc/api/class-connection.rst deleted file mode 100644 index 35ea7765..00000000 --- a/doc/api/class-connection.rst +++ /dev/null @@ -1,10 +0,0 @@ - -.. currentmodule:: tarantool.connection - -class :class:`Connection` -------------------------- - -.. autoclass:: Connection - :members: close, ping, space - - .. automethod:: call(func_name, *args) diff --git a/doc/api/class-mesh-connection.rst b/doc/api/class-mesh-connection.rst deleted file mode 100644 index d1048eba..00000000 --- a/doc/api/class-mesh-connection.rst +++ /dev/null @@ -1,8 +0,0 @@ - -.. currentmodule:: tarantool.mesh_connection - -class :class:`MeshConnection` ------------------------------ - -.. autoclass:: MeshConnection - diff --git a/doc/api/class-response.rst b/doc/api/class-response.rst deleted file mode 100644 index 9739d32d..00000000 --- a/doc/api/class-response.rst +++ /dev/null @@ -1,9 +0,0 @@ - -.. currentmodule:: tarantool.response - -class :class:`Response` -------------------------- - -.. autoclass:: Response - :members: - :undoc-members: diff --git a/doc/api/class-space.rst b/doc/api/class-space.rst deleted file mode 100644 index 947c550e..00000000 --- a/doc/api/class-space.rst +++ /dev/null @@ -1,9 +0,0 @@ - -.. currentmodule:: tarantool.space - -class :class:`Space` --------------------- - -.. autoclass:: tarantool.space.Space - :members: - :undoc-members: diff --git a/doc/api/submodule-connection-pool.rst b/doc/api/submodule-connection-pool.rst new file mode 100644 index 00000000..d8d2b3fb --- /dev/null +++ b/doc/api/submodule-connection-pool.rst @@ -0,0 +1,4 @@ +module :py:mod:`tarantool.connection_pool` +========================================== + +.. automodule:: tarantool.connection_pool diff --git a/doc/api/submodule-connection.rst b/doc/api/submodule-connection.rst new file mode 100644 index 00000000..3d4bfb7c --- /dev/null +++ b/doc/api/submodule-connection.rst @@ -0,0 +1,4 @@ +module :py:mod:`tarantool.connection` +===================================== + +.. automodule:: tarantool.connection diff --git a/doc/api/submodule-dbapi.rst b/doc/api/submodule-dbapi.rst new file mode 100644 index 00000000..9f388d5a --- /dev/null +++ b/doc/api/submodule-dbapi.rst @@ -0,0 +1,4 @@ +module :py:mod:`tarantool.dbapi` +================================ + +.. automodule:: tarantool.dbapi diff --git a/doc/api/submodule-error.rst b/doc/api/submodule-error.rst new file mode 100644 index 00000000..ff12f691 --- /dev/null +++ b/doc/api/submodule-error.rst @@ -0,0 +1,4 @@ +module :py:mod:`tarantool.error` +================================ + +.. automodule:: tarantool.error diff --git a/doc/api/submodule-mesh-connection.rst b/doc/api/submodule-mesh-connection.rst new file mode 100644 index 00000000..8a20c19d --- /dev/null +++ b/doc/api/submodule-mesh-connection.rst @@ -0,0 +1,4 @@ +module :py:mod:`tarantool.mesh_connection` +========================================== + +.. automodule:: tarantool.mesh_connection diff --git a/doc/api/submodule-msgpack-ext-types.rst b/doc/api/submodule-msgpack-ext-types.rst new file mode 100644 index 00000000..7c771a9b --- /dev/null +++ b/doc/api/submodule-msgpack-ext-types.rst @@ -0,0 +1,19 @@ +module :py:mod:`tarantool.msgpack_ext.types` +============================================ + +.. currentmodule:: tarantool.msgpack_ext.types + +module :py:mod:`tarantool.msgpack_ext.types.datetime` +----------------------------------------------------- + +.. automodule:: tarantool.msgpack_ext.types.datetime + :special-members: __add__, __sub__, __eq__ + + +.. currentmodule:: tarantool.msgpack_ext.types + +module :py:mod:`tarantool.msgpack_ext.types.interval` +----------------------------------------------------- + +.. automodule:: tarantool.msgpack_ext.types.interval + :special-members: __add__, __sub__, __eq__ diff --git a/doc/api/submodule-msgpack-ext.rst b/doc/api/submodule-msgpack-ext.rst new file mode 100644 index 00000000..173cc84d --- /dev/null +++ b/doc/api/submodule-msgpack-ext.rst @@ -0,0 +1,49 @@ +module :py:mod:`tarantool.msgpack_ext` +====================================== + +.. currentmodule:: tarantool.msgpack_ext + +module :py:mod:`tarantool.msgpack_ext.datetime` +----------------------------------------------- + +.. automodule:: tarantool.msgpack_ext.datetime + + +.. currentmodule:: tarantool.msgpack_ext + +module :py:mod:`tarantool.msgpack_ext.decimal` +---------------------------------------------- + +.. automodule:: tarantool.msgpack_ext.decimal + + +.. currentmodule:: tarantool.msgpack_ext + +module :py:mod:`tarantool.msgpack_ext.interval` +----------------------------------------------- + +.. automodule:: tarantool.msgpack_ext.interval + + +.. currentmodule:: tarantool.msgpack_ext + +module :py:mod:`tarantool.msgpack_ext.packer` +--------------------------------------------- + +.. automodule:: tarantool.msgpack_ext.packer + + +.. currentmodule:: tarantool.msgpack_ext + +module :py:mod:`tarantool.msgpack_ext.unpacker` +----------------------------------------------- + +.. automodule:: tarantool.msgpack_ext.unpacker + + +.. currentmodule:: tarantool.msgpack_ext + +module :py:mod:`tarantool.msgpack_ext.uuid` +------------------------------------------- + +.. automodule:: tarantool.msgpack_ext.uuid diff --git a/doc/api/submodule-request.rst b/doc/api/submodule-request.rst new file mode 100644 index 00000000..e7b7b507 --- /dev/null +++ b/doc/api/submodule-request.rst @@ -0,0 +1,4 @@ +module :py:mod:`tarantool.request` +================================== + +.. automodule:: tarantool.request diff --git a/doc/api/submodule-response.rst b/doc/api/submodule-response.rst new file mode 100644 index 00000000..328d5a93 --- /dev/null +++ b/doc/api/submodule-response.rst @@ -0,0 +1,4 @@ +module :py:mod:`tarantool.response` +=================================== + +.. automodule:: tarantool.response diff --git a/doc/api/submodule-schema.rst b/doc/api/submodule-schema.rst new file mode 100644 index 00000000..70f579ea --- /dev/null +++ b/doc/api/submodule-schema.rst @@ -0,0 +1,4 @@ +module :py:mod:`tarantool.schema` +================================= + +.. automodule:: tarantool.schema diff --git a/doc/api/submodule-space.rst b/doc/api/submodule-space.rst new file mode 100644 index 00000000..c329bfcd --- /dev/null +++ b/doc/api/submodule-space.rst @@ -0,0 +1,4 @@ +module :py:mod:`tarantool.space` +================================ + +.. automodule:: tarantool.space diff --git a/doc/api/submodule-utils.rst b/doc/api/submodule-utils.rst new file mode 100644 index 00000000..a6351415 --- /dev/null +++ b/doc/api/submodule-utils.rst @@ -0,0 +1,4 @@ +module :py:mod:`tarantool.utils` +================================ + +.. automodule:: tarantool.utils diff --git a/doc/conf.py b/doc/conf.py index 9fba7f64..9708c38f 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -29,7 +29,13 @@ # Add any Sphinx extension module names here, as strings. They can be extensions # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. -extensions = ['sphinx.ext.autodoc', 'sphinx.ext.intersphinx', 'sphinx.ext.todo', 'sphinx.ext.coverage', 'sphinx.ext.ifconfig'] +extensions = ['sphinx.ext.autodoc', 'sphinx.ext.intersphinx', 'sphinx.ext.todo', + 'sphinx.ext.coverage', 'sphinx.ext.ifconfig', 'sphinx_paramlinks',] + +autodoc_default_options = { + 'members': True, + 'inherited-members': True, +} # Add any paths that contain templates here, relative to this directory. templates_path = ['_templates'] @@ -249,7 +255,12 @@ # Example configuration for intersphinx: refer to the Python standard library. -intersphinx_mapping = {'python':('http://docs.python.org/', None)} +intersphinx_mapping = { + 'python': ('http://docs.python.org/', None), + 'msgpack': ('https://msgpack-python.readthedocs.io/en/latest/', None), + 'pandas': ('https://pandas.pydata.org/docs/', None), + 'pytz': ('https://pytz.sourceforge.net/', None), +} autoclass_content = "both" diff --git a/doc/index.rst b/doc/index.rst index f2a962c9..93095b01 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -39,10 +39,18 @@ API Reference :maxdepth: 2 api/module-tarantool.rst - api/class-connection.rst - api/class-mesh-connection.rst - api/class-space.rst - api/class-response.rst + api/submodule-connection.rst + api/submodule-connection-pool.rst + api/submodule-dbapi.rst + api/submodule-error.rst + api/submodule-mesh-connection.rst + api/submodule-msgpack-ext.rst + api/submodule-msgpack-ext-types.rst + api/submodule-request.rst + api/submodule-response.rst + api/submodule-schema.rst + api/submodule-space.rst + api/submodule-utils.rst diff --git a/requirements-doc.txt b/requirements-doc.txt index c6fe59fc..6f0bb607 100644 --- a/requirements-doc.txt +++ b/requirements-doc.txt @@ -1 +1,2 @@ sphinx==5.2.1 +sphinx-paramlinks==0.5.4 diff --git a/tarantool/connection.py b/tarantool/connection.py index 597e566e..f971367e 100644 --- a/tarantool/connection.py +++ b/tarantool/connection.py @@ -1,7 +1,7 @@ # pylint: disable=C0301,W0105,W0401,W0614 -''' -This module provides low-level API for Tarantool -''' +""" +This module provides API for interaction with a Tarantool server. +""" import os import time @@ -86,6 +86,15 @@ # Based on https://realpython.com/python-interface/ class ConnectionInterface(metaclass=abc.ABCMeta): + """ + Represents a connection to single or multiple Tarantool servers. + + Interface requires that a connection object has methods to open and + close a connection, check its status, call procedures and evaluate + Lua code on server, make simple data manipulations and execute SQL + queries. + """ + @classmethod def __subclasshook__(cls, subclass): return (hasattr(subclass, 'close') and @@ -118,81 +127,211 @@ def __subclasshook__(cls, subclass): @abc.abstractmethod def close(self): + """ + Reference implementation: :meth:`~tarantool.Connection.close`. + """ + raise NotImplementedError @abc.abstractmethod def is_closed(self): + """ + Reference implementation + :meth:`~tarantool.Connection.is_closed`. + """ + raise NotImplementedError @abc.abstractmethod def connect(self): + """ + Reference implementation: :meth:`~tarantool.Connection.connect`. + """ + raise NotImplementedError @abc.abstractmethod def call(self, func_name, *args): + """ + Reference implementation: :meth:`~tarantool.Connection.call`. + """ + raise NotImplementedError @abc.abstractmethod def eval(self, expr, *args): + """ + Reference implementation: :meth:`~tarantool.Connection.eval`. + """ + raise NotImplementedError @abc.abstractmethod def replace(self, space_name, values): + """ + Reference implementation: :meth:`~tarantool.Connection.replace`. + """ + raise NotImplementedError @abc.abstractmethod def insert(self, space_name, values): + """ + Reference implementation: :meth:`~tarantool.Connection.insert`. + """ + raise NotImplementedError @abc.abstractmethod def delete(self, space_name, key, *, index=None): + """ + Reference implementation: :meth:`~tarantool.Connection.delete`. + """ + raise NotImplementedError @abc.abstractmethod def upsert(self, space_name, tuple_value, op_list, *, index=None): + """ + Reference implementation: :meth:`~tarantool.Connection.upsert`. + """ + raise NotImplementedError @abc.abstractmethod def update(self, space_name, key, op_list, *, index=None): + """ + Reference implementation: :meth:`~tarantool.Connection.update`. + """ + raise NotImplementedError @abc.abstractmethod def ping(self, notime): + """ + Reference implementation: :meth:`~tarantool.Connection.ping`. + """ + raise NotImplementedError @abc.abstractmethod def select(self, space_name, key, *, offset=None, limit=None, index=None, iterator=None): + """ + Reference implementation: :meth:`~tarantool.Connection.select`. + """ + raise NotImplementedError @abc.abstractmethod def execute(self, query, params): + """ + Reference implementation: :meth:`~tarantool.Connection.execute`. + """ + raise NotImplementedError class Connection(ConnectionInterface): - ''' - Represents connection to the Tarantool server. - - This class is responsible for connection and network exchange with - the server. - It also provides a low-level interface for data manipulation - (insert/delete/update/select). - ''' + """ + Represents a connection to the Tarantool server. + + A connection object has methods to open and close a connection, + check its status, call procedures and evaluate Lua code on server, + make simple data manipulations and execute SQL queries. + """ + # DBAPI Extension: supply exceptions as attributes on the connection Error = Error + """ + DBAPI compatibility. + + :value: :exc:`~tarantool.error.Error` + """ + DatabaseError = DatabaseError + """ + DBAPI compatibility. + + :value: :exc:`~tarantool.error.DatabaseError` + """ + InterfaceError = InterfaceError + """ + DBAPI compatibility. + + :value: :exc:`~tarantool.error.InterfaceError` + """ + ConfigurationError = ConfigurationError + """ + DBAPI compatibility. + + :value: :exc:`~tarantool.error.ConfigurationError` + """ + SchemaError = SchemaError + """ + DBAPI compatibility. + + :value: :exc:`~tarantool.error.SchemaError` + """ + NetworkError = NetworkError + """ + DBAPI compatibility. + + :value: :exc:`~tarantool.error.NetworkError` + """ + Warning = Warning + """ + DBAPI compatibility. + + :value: :exc:`~tarantool.error.Warning` + """ + DataError = DataError + """ + DBAPI compatibility. + + :value: :exc:`~tarantool.error.DataError` + """ + OperationalError = OperationalError + """ + DBAPI compatibility. + + :value: :exc:`~tarantool.error.OperationalError` + """ + IntegrityError = IntegrityError + """ + DBAPI compatibility. + + :value: :exc:`~tarantool.error.IntegrityError` + """ + InternalError = InternalError + """ + DBAPI compatibility. + + :value: :exc:`~tarantool.error.InternalError` + """ + ProgrammingError = ProgrammingError + """ + DBAPI compatibility. + + :value: :exc:`~tarantool.error.ProgrammingError` + """ + NotSupportedError = NotSupportedError + """ + DBAPI compatibility. + + :value: :exc:`~tarantool.error.NotSupportedError` + """ def __init__(self, host, port, user=None, @@ -210,24 +349,119 @@ def __init__(self, host, port, ssl_cert_file=DEFAULT_SSL_CERT_FILE, ssl_ca_file=DEFAULT_SSL_CA_FILE, ssl_ciphers=DEFAULT_SSL_CIPHERS): - ''' - Initialize a connection to the server. - - :param str host: server hostname or IP address - :param int port: server port - :param bool connect_now: if True (default), __init__() actually - creates a network connection; if False, you have to call - connect() manually - :param str transport: set to `ssl` to enable - SSL encryption for a connection. - SSL encryption requires Python >= 3.5 - :param str ssl_key_file: path to the private SSL key file - :param str ssl_cert_file: path to the SSL certificate file - :param str ssl_ca_file: path to the trusted certificate authority - (CA) file - :param str ssl_ciphers: colon-separated (:) list of SSL cipher suites - the connection can use - ''' + """ + :param host: Server hostname or IP address. Use ``None`` for + Unix sockets. + :type host: :obj:`str` or :obj:`None` + + :param port: Server port or Unix socket path. + :type port: :obj:`int` or :obj:`str` + + :param user: User name for authentication on the Tarantool + server. + :type user: :obj:`str` or :obj:`None`, optional + + :param password: User password for authentication on the + Tarantool server. + :type password: :obj:`str` or :obj:`None`, optional + + :param socket_timeout: Timeout on blocking socket operations, + in seconds (see `socket.settimeout()`_). + :type socket_timeout: :obj:`float` or :obj:`None`, optional + + :param reconnect_max_attempts: Count of maximum attempts to + reconnect on API call if connection is lost. + :type reconnect_max_attempts: :obj:`int`, optional + + :param reconnect_delay: Delay between attempts to reconnect on + API call if connection is lost, in seconds. + :type reconnect_delay: :obj:`float`, optional + + :param bool connect_now: If ``True``, connect to server on + initialization. Otherwise, you have to call + :meth:`~tarantool.Connection.connect` manually after + initialization. + :type connect_now: :obj:`bool`, optional + + :param encoding: ``'utf-8'`` or ``None``. Use ``None`` to work + with non-UTF8 strings. + + If ``'utf-8'``, pack Unicode string (:obj:`str`) to + MessagePack string (`mp_str`_) and unpack MessagePack string + (`mp_str`_) Unicode string (:obj:`str`), pack :obj:`bytes` + to MessagePack binary (`mp_bin`_) and unpack MessagePack + binary (`mp_bin`_) to :obj:`bytes`. + + +--------------+----+----------------------------------+----+--------------+ + | Python | -> | MessagePack (Tarantool/Lua) | -> | Python | + +==============+====+==================================+====+==============+ + | :obj:`str` | -> | `mp_str`_ (``string``) | -> | :obj:`str` | + +--------------+----+----------------------------------+----+--------------+ + | :obj:`bytes` | -> | `mp_bin`_ (``binary``/``cdata``) | -> | :obj:`bytes` | + +--------------+----+----------------------------------+----+--------------+ + + If ``None``, pack Unicode string (:obj:`str`) and + :obj:`bytes` to MessagePack string (`mp_str`_), unpack + MessagePack string (`mp_str`_) and MessagePack binary + (`mp_bin`_) to :obj:`bytes`. + + +--------------+----+----------------------------------+----+--------------+ + | Python | -> | MessagePack (Tarantool/Lua) | -> | Python | + +==============+====+==================================+====+==============+ + | :obj:`bytes` | -> | `mp_str`_ (``string``) | -> | :obj:`bytes` | + +--------------+----+----------------------------------+----+--------------+ + | :obj:`str` | -> | `mp_str`_ (``string``) | -> | :obj:`bytes` | + +--------------+----+----------------------------------+----+--------------+ + | | -> | `mp_bin`_ (``binary``/``cdata``) | -> | :obj:`bytes` | + +--------------+----+----------------------------------+----+--------------+ + + :type encoding: :obj:`str` or :obj:`None`, optional + + :param use_list: + If ``True``, unpack MessagePack array (`mp_array`_) to + :obj:`list`. Otherwise, unpack to :obj:`tuple`. + :type use_list: :obj:`bool`, optional + + :param call_16: + If ``True``, enables compatibility mode with Tarantool 1.6 + and older for `call` operations. + :type call_16: :obj:`bool`, optional + + :param connection_timeout: Time to establish initial socket + connection, in seconds. + :type connection_timeout: :obj:`float` or :obj:`None`, optional + + :param transport: ``''`` or ``'ssl'``. Set to ``'ssl'`` to + enable SSL encryption for a connection (requires + Python >= 3.5). + :type transport: :obj:`str`, optional + + :param ssl_key_file: Path to a private SSL key file. Mandatory, + if the server uses a trusted certificate authorities (CA) + file. + :type ssl_key_file: :obj:`str` or :obj:`None`, optional + + :param str ssl_cert_file: Path to a SSL certificate file. + Mandatory, if the server uses a trusted certificate + authorities (CA) file. + :type ssl_cert_file: :obj:`str` or :obj:`None`, optional + + :param ssl_ca_file: Path to a trusted certificate authority (CA) + file. + :type ssl_ca_file: :obj:`str` or :obj:`None`, optional + + :param ssl_ciphers: Colon-separated (``:``) list of SSL cipher + suites the connection can use. + :type ssl_ciphers: :obj:`str` or :obj:`None`, optional + + :raise: :exc:`~tarantool.error.ConfigurationError`, + :meth:`~tarantool.Connection.connect` exceptions + + .. _socket.settimeout(): https://docs.python.org/3/library/socket.html#socket.socket.settimeout + .. _mp_str: https://github.com/msgpack/msgpack/blob/master/spec.md#str-format-family + .. _mp_bin: https://github.com/msgpack/msgpack/blob/master/spec.md#bin-format-family + .. _mp_array: https://github.com/msgpack/msgpack/blob/master/spec.md#array-format-family + """ if msgpack.version >= (1, 0, 0) and encoding not in (None, 'utf-8'): raise ConfigurationError("msgpack>=1.0.0 only supports None and " + @@ -268,31 +502,46 @@ def __init__(self, host, port, self.connect() def close(self): - ''' + """ Close a connection to the server. - ''' + """ + self._socket.close() self._socket = None def is_closed(self): - ''' - Returns the state of a Connection instance. - :rtype: Boolean - ''' + """ + Returns ``True`` if connection is closed. ``False`` otherwise. + + :rtype: :obj:`bool` + """ + return self._socket is None def connect_basic(self): + """ + Establish a connection to the host and port specified on + initialization. + + :raise: :exc:`~tarantool.error.NetworkError` + + :meta private: + """ + if self.host is None: self.connect_unix() else: self.connect_tcp() def connect_tcp(self): - ''' - Create a connection to the host and port specified in __init__(). + """ + Establish a TCP connection to the host and port specified on + initialization. + + :raise: :exc:`~tarantool.error.NetworkError` - :raise: `NetworkError` - ''' + :meta private: + """ try: # If old socket already exists - close it and re-create @@ -308,11 +557,14 @@ def connect_tcp(self): raise NetworkError(e) def connect_unix(self): - ''' - Create a connection to the host and port specified in __init__(). + """ + Create a connection to the Unix socket specified on + initialization. + + :raise: :exc:`~tarantool.error.NetworkError` - :raise: `NetworkError` - ''' + :meta private: + """ try: # If old socket already exists - close it and re-create @@ -328,12 +580,14 @@ def connect_unix(self): raise NetworkError(e) def wrap_socket_ssl(self): - ''' + """ Wrap an existing socket with an SSL socket. - :raise: SslError - :raise: `ssl.SSLError` - ''' + :raise: :exc:`~tarantool.error.SslError` + + :meta private: + """ + if not is_ssl_supported: raise SslError("Your version of Python doesn't support SSL") @@ -395,6 +649,15 @@ def password_raise_error(): raise SslError(e) def handshake(self): + """ + Process greeting with Tarantool server. + + :raise: :exc:`~ValueError`, + :exc:`~tarantool.error.NetworkError` + + :meta private: + """ + greeting_buf = self._recv(IPROTO_GREETING_SIZE) greeting = greeting_decode(greeting_buf) if greeting.protocol != "Binary": @@ -406,14 +669,17 @@ def handshake(self): self.authenticate(self.user, self.password) def connect(self): - ''' - Create a connection to the host and port specified in __init__(). - Usually, there is no need to call this method directly, - because it is called when you create a `Connection` instance. - - :raise: `NetworkError` - :raise: `SslError` - ''' + """ + Create a connection to the host and port specified on + initialization. There is no need to call this method explicitly + until you have set ``connect_now=False`` on initialization. + + :raise: :exc:`~tarantool.error.NetworkError`, + :exc:`~tarantool.error.SslError`, + :exc:`~tarantool.error.SchemaError`, + :exc:`~tarantool.error.DatabaseError` + """ + try: self.connect_basic() if self.transport == SSL_TRANSPORT: @@ -427,6 +693,18 @@ def connect(self): raise NetworkError(e) def _recv(self, to_read): + """ + Receive binary data from connection socket. + + :param to_read: Amount of data to read, in bytes. + :type to_read: :obj:`int` + + :return: Buffer with read data + :rtype: :obj:`bytes` + + :meta private: + """ + buf = b"" while to_read > 0: try: @@ -456,23 +734,37 @@ def _recv(self, to_read): return buf def _read_response(self): - ''' + """ Read response from the transport (socket). - :return: tuple of the form (header, body) - :rtype: tuple of two byte arrays - ''' + :return: Tuple of the form ``(header, body)``. + :rtype: :obj:`tuple` + + :meta private: + """ + # Read packet length length = msgpack.unpackb(self._recv(5)) # Read the packet return self._recv(length) def _send_request_wo_reconnect(self, request): - ''' - :rtype: `Response` instance or subclass + """ + Send request without trying to reconnect. + Reload schema, if required. + + :param request: Request to send. + :type request: :class:`~tarantool.request.Request` + + :rtype: :class:`~tarantool.response.Response` + + :raise: :exc:`~AssertionError`, + :exc:`~tarantool.error.SchemaError`, + :exc:`~tarantool.error.NetworkError` + + :meta private: + """ - :raise: NetworkError - ''' assert isinstance(request, Request) response = None @@ -488,11 +780,17 @@ def _send_request_wo_reconnect(self, request): return response def _opt_reconnect(self): - ''' - Check that the connection is alive - using low-level recv from libc(ctypes). - **Bug in Python: timeout is an internal Python construction. - ''' + """ + Check that the connection is alive using low-level recv from + libc(ctypes). + + :raise: :exc:`~tarantool.error.NetworkError`, + :exc:`~tarantool.error.SslError` + + :meta private: + """ + + # **Bug in Python: timeout is an internal Python construction (???). if not self._socket: return self.connect() @@ -502,7 +800,7 @@ def check(): # Check that connection is alive sock_fd = self._socket.fileno() except socket.error as e: if e.errno == errno.EBADF: - return errno.ECONNRESET + return errno.ECONNRESETtuple_value else: if os.name == 'nt': flag = socket.MSG_PEEK @@ -554,15 +852,22 @@ def check(): # Check that connection is alive self.handshake() def _send_request(self, request): - ''' + """ Send a request to the server through the socket. - Return an instance of the `Response` class. - :param request: object representing a request - :type request: `Request` instance + :param request: Request to send. + :type request: :class:`~tarantool.request.Request` + + :rtype: :class:`~tarantool.response.Response` - :rtype: `Response` instance - ''' + :raise: :exc:`~AssertionError`, + :exc:`~tarantool.error.DatabaseError`, + :exc:`~tarantool.error.SchemaError`, + :exc:`~tarantool.error.NetworkError`, + :exc:`~tarantool.error.SslError` + + :meta private: + """ assert isinstance(request, Request) self._opt_reconnect() @@ -570,28 +875,63 @@ def _send_request(self, request): return self._send_request_wo_reconnect(request) def load_schema(self): + """ + Fetch space and index schema. + + :raise: :exc:`~tarantool.error.SchemaError`, + :exc:`~tarantool.error.DatabaseError` + + :meta private: + """ + self.schema.fetch_space_all() self.schema.fetch_index_all() def update_schema(self, schema_version): + """ + Set new schema version metainfo, reload space and index schema. + + :param schema_version: New schema version metainfo. + :type schema_version: :obj:`int` + + :raise: :exc:`~tarantool.error.SchemaError`, + :exc:`~tarantool.error.DatabaseError` + + :meta private: + """ + self.schema_version = schema_version self.flush_schema() def flush_schema(self): + """ + Reload space and index schema. + + :raise: :exc:`~tarantool.error.SchemaError`, + :exc:`~tarantool.error.DatabaseError` + """ + self.schema.flush() self.load_schema() def call(self, func_name, *args): - ''' - Execute a CALL request. Call a stored Lua function. + """ + Execute a CALL request: call a stored Lua function. + + :param func_name: Stored Lua function name. + :type func_name: :obj:`str` + + :param args: Stored Lua function arguments. + :type args: :obj:`tuple` - :param func_name: stored Lua function name - :type func_name: str - :param args: list of function arguments - :type args: list or tuple + :rtype: :class:`~tarantool.response.Response` + + :raise: :exc:`~AssertionError`, + :exc:`~tarantool.error.SchemaError`, + :exc:`~tarantool.error.NetworkError`, + :exc:`~tarantool.error.SslError` + """ - :rtype: `Response` instance - ''' assert isinstance(func_name, str) # This allows to use a tuple or list as an argument @@ -603,16 +943,24 @@ def call(self, func_name, *args): return response def eval(self, expr, *args): - ''' - Execute an EVAL request. Eval a Lua expression. + """ + Execute an EVAL request: evaluate a Lua expression. + + :param expr: Lua expression. + :type expr: :obj:`str` + + :param args: Lua expression arguments. + :type args: :obj:`tuple` - :param expr: Lua expression - :type expr: str - :param args: list of function arguments - :type args: list or tuple + :rtype: :class:`~tarantool.response.Response` + + :raise: :exc:`~AssertionError`, + :exc:`~tarantool.error.DatabaseError`, + :exc:`~tarantool.error.SchemaError`, + :exc:`~tarantool.error.NetworkError`, + :exc:`~tarantool.error.SslError` + """ - :rtype: `Response` instance - ''' assert isinstance(expr, str) # This allows to use a tuple or list as an argument @@ -624,32 +972,54 @@ def eval(self, expr, *args): return response def replace(self, space_name, values): - ''' - Execute a REPLACE request. - Doesn't throw an error if there is no tuple with the specified PK. - - :param int space_name: space id to insert a record - :type space_name: int or str - :param values: record to be inserted. The tuple must contain - only scalar (integer or strings) values - :type values: tuple - - :rtype: `Response` instance - ''' + """ + Execute a REPLACE request: `replace`_ a tuple in the space. + Doesn't throw an error if there is no tuple with the specified + primary key. + + :param space_name: Space name or space id. + :type space_name: :obj:`str` or :obj:`int` + + :param values: Tuple to be replaced. + :type values: :obj:`tuple` or :obj:`list` + + :rtype: :class:`~tarantool.response.Response` + + :raise: :exc:`~AssertionError`, + :exc:`~tarantool.error.DatabaseError`, + :exc:`~tarantool.error.SchemaError`, + :exc:`~tarantool.error.NetworkError`, + :exc:`~tarantool.error.SslError` + + .. _replace: https://www.tarantool.io/en/doc/latest/reference/reference_lua/box_space/replace/ + """ + if isinstance(space_name, str): space_name = self.schema.get_space(space_name).sid request = RequestReplace(self, space_name, values) return self._send_request(request) def authenticate(self, user, password): - ''' - Execute an AUTHENTICATE request. + """ + Execute an AUTHENTICATE request: authenticate a connection. + There is no need to call this method explicitly until you want + to reauthenticate with different parameters. + + :param user: User to authenticate. + :type user: :obj:`str` + + :param password: Password for the user. + :type password: :obj:`str` - :param string user: user to authenticate - :param string password: password for the user + :rtype: :class:`~tarantool.response.Response` + + :raise: :exc:`~AssertionError`, + :exc:`~tarantool.error.DatabaseError`, + :exc:`~tarantool.error.SchemaError`, + :exc:`~tarantool.error.NetworkError`, + :exc:`~tarantool.error.SslError` + """ - :rtype: `Response` instance - ''' self.user = user self.password = password if not self._socket: @@ -663,6 +1033,19 @@ def authenticate(self, user, password): return auth_response def _join_v16(self, server_uuid): + """ + Execute a JOIN request for Tarantool 1.6 and older. + + :param server_uuid: UUID of Tarantool server to join. + :type server_uuid: :obj:`str` + + :raise: :exc:`~AssertionError`, + :exc:`~tarantool.error.DatabaseError`, + :exc:`~tarantool.error.SchemaError`, + :exc:`~tarantool.error.NetworkError`, + :exc:`~tarantool.error.SslError` + """ + request = RequestJoin(self, server_uuid) self._socket.sendall(bytes(request)) @@ -674,6 +1057,19 @@ def _join_v16(self, server_uuid): self.close() # close connection after JOIN def _join_v17(self, server_uuid): + """ + Execute a JOIN request for Tarantool 1.7 and newer. + + :param server_uuid: UUID of Tarantool server to join. + :type server_uuid: :obj:`str` + + :raise: :exc:`~AssertionError`, + :exc:`~tarantool.error.DatabaseError`, + :exc:`~tarantool.error.SchemaError`, + :exc:`~tarantool.error.NetworkError`, + :exc:`~tarantool.error.SslError` + """ + class JoinState: Handshake, Initial, Final, Done = range(4) @@ -700,12 +1096,49 @@ def _ops_process(self, space, update_ops): return new_ops def join(self, server_uuid): + """ + Execute a JOIN request: `join`_ a replicaset. + + :param server_uuid: UUID of connector "server". + :type server_uuid: :obj:`str` + + :raise: :exc:`~AssertionError`, + :exc:`~tarantool.error.DatabaseError`, + :exc:`~tarantool.error.SchemaError`, + :exc:`~tarantool.error.NetworkError`, + :exc:`~tarantool.error.SslError` + + .. _join: https://www.tarantool.io/en/doc/latest/dev_guide/internals/box_protocol/#iproto-join-0x41 + """ + self._opt_reconnect() if self.version_id < version_id(1, 7, 0): return self._join_v16(server_uuid) return self._join_v17(server_uuid) def subscribe(self, cluster_uuid, server_uuid, vclock=None): + """ + Execute a SUBSCRIBE request: `subscribe`_ to a replicaset + updates. Connection is closed after subscribing. + + :param cluster_uuid: UUID of replicaset cluster. + :type cluster_uuid: :obj:`str` + + :param server_uuid: UUID of connector "server". + :type server_uuid: :obj:`str` + + :param vclock: Connector "server" vclock. + :type vclock: :obj:`dict` or :obj:`None`, optional + + :raise: :exc:`~AssertionError`, + :exc:`~tarantool.error.DatabaseError`, + :exc:`~tarantool.error.SchemaError`, + :exc:`~tarantool.error.NetworkError`, + :exc:`~tarantool.error.SslError` + + .. _subscribe: https://www.tarantool.io/en/doc/latest/dev_guide/internals/box_protocol/#iproto-subscribe-0x42 + """ + vclock = vclock or {} request = RequestSubscribe(self, cluster_uuid, server_uuid, vclock) self._socket.sendall(bytes(request)) @@ -717,36 +1150,57 @@ def subscribe(self, cluster_uuid, server_uuid, vclock=None): self.close() # close connection after SUBSCRIBE def insert(self, space_name, values): - ''' - Execute an INSERT request. - Throws an error if there is a tuple with the same PK. - - :param int space_name: space id to insert the record - :type space_name: int or str - :param values: record to be inserted. The tuple must contain - only scalar (integer or strings) values - :type values: tuple - - :rtype: `Response` instance - ''' + """ + Execute an INSERT request: `insert`_ a tuple to the space. + Throws an error if there is already a tuple with the same + primary key. + + :param space_name: Space name or space id. + :type space_name: :obj:`str` or :obj:`int` + + :param values: Record to be inserted. + :type values: :obj:`tuple` or :obj:`list` + + :rtype: :class:`~tarantool.response.Response` + + :raise: :exc:`~AssertionError`, + :exc:`~tarantool.error.DatabaseError`, + :exc:`~tarantool.error.SchemaError`, + :exc:`~tarantool.error.NetworkError`, + :exc:`~tarantool.error.SslError` + + .. _insert: https://www.tarantool.io/en/doc/latest/reference/reference_lua/box_space/insert/ + """ + if isinstance(space_name, str): space_name = self.schema.get_space(space_name).sid request = RequestInsert(self, space_name, values) return self._send_request(request) def delete(self, space_name, key, *, index=0): - ''' - Execute DELETE request. - Delete a single record identified by `key`. If you're using a secondary - index, it must be unique. + """ + Execute a DELETE request: `delete`_ a tuple in the space. + + :param space_name: Space name or space id. + :type space_name: :obj:`str` or :obj:`int` + + :param key: Key of a tuple to be deleted. - :param space_name: space number or name to delete a record - :type space_name: int or name - :param key: key that identifies a record - :type key: int or str + :param index: Index name or index id. If you're using a + secondary index, it must be unique. Defaults to primary + index. + :type index: :obj:`str` or :obj:`int`, optional - :rtype: `Response` instance - ''' + :rtype: :class:`~tarantool.response.Response` + + :raise: :exc:`~AssertionError`, + :exc:`~tarantool.error.DatabaseError`, + :exc:`~tarantool.error.SchemaError`, + :exc:`~tarantool.error.NetworkError`, + :exc:`~tarantool.error.SslError` + + .. _delete: https://www.tarantool.io/en/doc/latest/reference/reference_lua/box_space/delete/ + """ key = check_key(key) if isinstance(space_name, str): @@ -757,68 +1211,46 @@ def delete(self, space_name, key, *, index=0): return self._send_request(request) def upsert(self, space_name, tuple_value, op_list, *, index=0): - ''' - Execute UPSERT request. + """ + Execute an UPSERT request: `upsert`_ a tuple to the space. If an existing tuple matches the key fields of - `tuple_value`, then the request has the same effect as UPDATE - and the [(field_1, symbol_1, arg_1), ...] parameter is used. - - If there is no tuple matching the key fields of - `tuple_value`, then the request has the same effect as INSERT - and the `tuple_value` parameter is used. However, unlike insert - or update, upsert will neither read the tuple nor perform error checks - before returning -- this is a design feature which enhances - throughput but requires more caution on the part of the user. - - If you're using a secondary index, it must be unique. - - The list of operations allows updating individual fields. - - For every operation, you must provide the field number to apply this - operation to. - - *Allowed operations:* - - * `+` for addition (values must be numeric) - * `-` for subtraction (values must be numeric) - * `&` for bitwise AND (values must be unsigned numeric) - * `|` for bitwise OR (values must be unsigned numeric) - * `^` for bitwise XOR (values must be unsigned numeric) - * `:` for string splice (you must provide `offset`, `count`, - and `value` for this operation) - * `!` for insertion (provide any element to insert) - * `=` for assignment (provide any element to assign) - * `#` for deletion (provide count of fields to delete) - - :param space_name: space number or name to update a record - :type space_name: int or str - :param index: index number or name to update a record - :type index: int or str - :param tuple_value: tuple - :type tuple_value: - :param op_list: list of operations. Each operation - is a tuple of three (or more) values - :type op_list: list of the form [(symbol_1, field_1, arg_1), - (symbol_2, field_2, arg_2_1, arg_2_2, arg_2_3),...] - - :rtype: `Response` instance - - Operation examples: + ``tuple_value``, then the request has the same effect as UPDATE + and the ``[(field_1, symbol_1, arg_1), ...]`` parameter is used. - .. code-block:: python + If there is no tuple matching the key fields of ``tuple_value``, + then the request has the same effect as INSERT and the + ``tuple_value`` parameter is used. However, unlike insert or + update, upsert will neither read the tuple nor perform error + checks before returning -- this is a design feature which + enhances throughput but requires more caution on the part of the + user. + + :param space_name: Space name or space id. + :type space_name: :obj:`str` or :obj:`int` + + :param tuple_value: Tuple to be upserted. + :type tuple_value: :obj:`tuple` or :obj:`list` + + :param op_list: Refer to :meth:`~tarantool.Connection.update` + :paramref:`~tarantool.Connection.update.params.op_list`. + :type op_list: :obj:`tuple` or :obj:`list` + + :param index: Index name or index id. If you're using a + secondary index, it must be unique. Defaults to primary + index. + :type index: :obj:`str` or :obj:`int`, optional + + :rtype: :class:`~tarantool.response.Response` + + :raise: :exc:`~AssertionError`, + :exc:`~tarantool.error.DatabaseError`, + :exc:`~tarantool.error.SchemaError`, + :exc:`~tarantool.error.NetworkError`, + :exc:`~tarantool.error.SslError` - # 'ADD' 55 to the second field - # Assign 'x' to the third field - [('+', 2, 55), ('=', 3, 'x')] - # 'OR' the third field with '1' - # Cut three symbols, starting from the second, - # and replace them with '!!' - # Insert 'hello, world' field before the fifth element of the tuple - [('|', 3, 1), (':', 2, 2, 3, '!!'), ('!', 5, 'hello, world')] - # Delete two fields, starting with the second field - [('#', 2, 2)] - ''' + .. _upsert: https://www.tarantool.io/en/doc/latest/reference/reference_lua/box_space/upsert/ + """ if isinstance(space_name, str): space_name = self.schema.get_space(space_name).sid @@ -830,69 +1262,74 @@ def upsert(self, space_name, tuple_value, op_list, *, index=0): return self._send_request(request) def update(self, space_name, key, op_list, *, index=0): - ''' - Execute an UPDATE request. - - The `update` function supports operations on fields — assignment, - arithmetic (if the field is unsigned numeric), cutting and pasting - fragments of the field, deleting or inserting a field. Multiple - operations can be combined in a single update request, and in this - case they are performed atomically and sequentially. Each operation - requires that you specify a field number. With multiple operations, - the field number for each operation is assumed to be relative - to the most recent state of the tuple, that is, as if all previous - operations in the multi-operation update have already been applied. - In other words, it is always safe to merge multiple update invocations - into a single invocation, with no change in semantics. - - Update a single record identified by `key`. - - The list of operations allows updating individual fields. - - For every operation, you must provide the field number to apply this - operation to. - - *Allowed operations:* - - * `+` for addition (values must be numeric) - * `-` for subtraction (values must be numeric) - * `&` for bitwise AND (values must be unsigned numeric) - * `|` for bitwise OR (values must be unsigned numeric) - * `^` for bitwise XOR (values must be unsigned numeric) - * `:` for string splice (you must provide `offset`, `count` and `value` - for this operation) - * `!` for insertion (before) (provide any element to insert) - * `=` for assignment (provide any element to assign) - * `#` for deletion (provide count of fields to delete) - - :param space_name: space number or name to update the record - :type space_name: int or str - :param index: index number or name to update the record - :type index: int or str - :param key: key that identifies the record - :type key: int or str - :param op_list: list of operations. Each operation - is a tuple of three (or more) values - :type op_list: list of the form [(symbol_1, field_1, arg_1), - (symbol_2, field_2, arg_2_1, arg_2_2, arg_2_3), ...] - - :rtype: ``Response`` instance - - Operation examples: - - .. code-block:: python - - # 'ADD' 55 to second field - # Assign 'x' to the third field - [('+', 2, 55), ('=', 3, 'x')] - # 'OR' the third field with '1' - # Cut three symbols, starting from second, - # and replace them with '!!' - # Insert 'hello, world' field before the fifth element of the tuple - [('|', 3, 1), (':', 2, 2, 3, '!!'), ('!', 5, 'hello, world')] - # Delete two fields, starting with the second field - [('#', 2, 2)] - ''' + """ + Execute an UPDATE request: `update`_ a tuple in the space. + + :param space_name: Space name or space id. + :type space_name: :obj:`str` or :obj:`int` + + :param key: Key of a tuple to be updated. + + :param op_list: The list of operations to update individual + fields. Each operation is a :obj:`tuple` of three (or more) + values: ``(operator, field_identifier, value)``. + + Possible operators are: + + * ``'+'`` for addition. values must be numeric + * ``'-'`` for subtraction. values must be numeric + * ``'&'`` for bitwise AND. values must be unsigned numeric + * ``'|'`` for bitwise OR. values must be unsigned numeric + * ``'^'`` for bitwise XOR. values must be unsigned numeric + * ``':'`` for string splice. you must provide ``offset``, + ``count``, and ``value`` for this operation + * ``'!'`` for insertion. provide any element to insert) + * ``'='`` for assignment. (provide any element to assign) + * ``'#'`` for deletion. provide count of fields to delete) + + Possible field_identifiers are: + + * Positive field number. The first field is 1, the second + field is 2, and so on. + * Negative field number. The last field is -1, the + second-last field is -2, and so on. + In other words: ``(#tuple + negative field number + 1)``. + * Name. If the space was formatted with + ``space_object:format()``, then this can be a string for + the field ``name`` (Since Tarantool 2.3.1). + + Operation examples: + + .. code-block:: python + + # 'ADD' 55 to the second field + # Assign 'x' to the third field + [('+', 2, 55), ('=', 3, 'x')] + # 'OR' the third field with '1' + # Cut three symbols, starting from the second, + # and replace them with '!!' + # Insert 'hello, world' field before the fifth element of the tuple + [('|', 3, 1), (':', 2, 2, 3, '!!'), ('!', 5, 'hello, world')] + # Delete two fields, starting with the second field + [('#', 2, 2)] + + :type op_list: :obj:`tuple` or :obj:`list` + + :param index: Index name or index id. If you're using a + secondary index, it must be unique. Defaults to primary + index. + :type index: :obj:`str` or :obj:`int`, optional + + :rtype: :class:`~tarantool.response.Response` + + :raise: :exc:`~AssertionError`, + :exc:`~tarantool.error.DatabaseError`, + :exc:`~tarantool.error.SchemaError`, + :exc:`~tarantool.error.NetworkError`, + :exc:`~tarantool.error.SslError` + + .. _update: https://www.tarantool.io/en/doc/latest/reference/reference_lua/box_space/update/ + """ key = check_key(key) if isinstance(space_name, str): @@ -904,13 +1341,23 @@ def update(self, space_name, key, op_list, *, index=0): return self._send_request(request) def ping(self, notime=False): - ''' - Execute a PING request. - Send an empty request and receive an empty response from the server. + """ + Execute a PING request: send an empty request and receive + an empty response from the server. + + :param notime: If ``False``, returns response time. + Otherwise, it returns ``'Success'``. + :type notime: :obj:`bool`, optional + + :return: Response time or ``'Success'``. + :rtype: :obj:`float` or :obj:`str` - :return: response time in seconds - :rtype: float - ''' + :raise: :exc:`~AssertionError`, + :exc:`~tarantool.error.DatabaseError`, + :exc:`~tarantool.error.SchemaError`, + :exc:`~tarantool.error.NetworkError`, + :exc:`~tarantool.error.SslError` + """ request = RequestPing(self) t0 = time.time() @@ -922,45 +1369,144 @@ def ping(self, notime=False): return t1 - t0 def select(self, space_name, key=None, *, offset=0, limit=0xffffffff, index=0, iterator=None): - ''' - Execute a SELECT request. - Select and retrieve data from the database. - - :param space_name: space to query - :type space_name: int or str - :param values: values to search by index - :type values: list, tuple, set, frozenset of tuples - :param index: index to search by (default is **0**, which - means that the **primary index** is used) - :type index: int or str - :param offset: offset in the resulting tuple set - :type offset: int - :param limit: limits the total number of returned tuples - :type limit: int - - :rtype: `Response` instance - - You can use index/space names. The driver will get - the matching id's -> names from the server. - - Select a single record (from space=0 and using index=0) - >>> select(0, 1) - - Select a single record from space=0 (with name='space') using - composite index=1 (with name '_name'). - >>> select(0, [1,'2'], index=1) - # OR - >>> select(0, [1,'2'], index='_name') - # OR - >>> select('space', [1,'2'], index='_name') - # OR - >>> select('space', [1,'2'], index=1) - - Select all records - >>> select(0) - # OR - >>> select(0, []) - ''' + """ + Execute a SELECT request: `select`_ a tuple from the space. + + :param space_name: Space name or space id. + :type space_name: :obj:`str` or :obj:`int` + + :param key: Key of a tuple to be selected. + :type key: optional + + :param offset: Number of tuples to skip. + :type offset: :obj:`int`, optional + + :param limit: Maximum number of tuples to select. + :type limit: :obj:`int`, optional + + :param index: Index name or index id to select. + Defaults to primary index. + :type limit: :obj:`str` or :obj:`int`, optional + + :param iterator: Index iterator type. + + Iterator types for TREE indexes: + + +---------------+-----------+---------------------------------------------+ + | Iterator type | Arguments | Description | + +===============+===========+=============================================+ + | ``'EQ'`` | search | The comparison operator is '==' (equal to). | + | | value | If an index key is equal to a search value, | + | | | it matches. | + | | | Tuples are returned in ascending order by | + | | | index key. This is the default. | + +---------------+-----------+---------------------------------------------+ + | ``'REQ'`` | search | Matching is the same as for ``'EQ'``. | + | | value | Tuples are returned in descending order by | + | | | index key. | + +---------------+-----------+---------------------------------------------+ + | ``'GT'`` | search | The comparison operator is '>' (greater | + | | value | than). | + | | | If an index key is greater than a search | + | | | value, it matches. | + | | | Tuples are returned in ascending order by | + | | | index key. | + +---------------+-----------+---------------------------------------------+ + | ``'GE'`` | search | The comparison operator is '>=' (greater | + | | value | than or equal to). | + | | | If an index key is greater than or equal to | + | | | a search value, it matches. | + | | | Tuples are returned in ascending order by | + | | | index key. | + +---------------+-----------+---------------------------------------------+ + | ``'ALL'`` | search | Same as ``'GE'`` | + | | value | | + | | | | + +---------------+-----------+---------------------------------------------+ + | ``'LT'`` | search | The comparison operator is '<' (less than). | + | | value | If an index key is less than a search | + | | | value, it matches. | + | | | Tuples are returned in descending order by | + | | | index key. | + +---------------+-----------+---------------------------------------------+ + | ``'LE'`` | search | The comparison operator is '<=' (less than | + | | value | or equal to). | + | | | If an index key is less than or equal to a | + | | | search value, it matches. | + | | | Tuples are returned in descending order by | + | | | index key. | + +---------------+-----------+---------------------------------------------+ + + Iterator types for HASH indexes: + + +---------------+-----------+------------------------------------------------+ + | Type | Arguments | Description | + +===============+===========+================================================+ + | ``'ALL'`` | none | All index keys match. | + | | | Tuples are returned in ascending order by | + | | | hash of index key, which will appear to be | + | | | random. | + +---------------+-----------+------------------------------------------------+ + | ``'EQ'`` | search | The comparison operator is '==' (equal to). | + | | value | If an index key is equal to a search value, | + | | | it matches. | + | | | The number of returned tuples will be 0 or 1. | + | | | This is the default. | + +---------------+-----------+------------------------------------------------+ + | ``'GT'`` | search | The comparison operator is '>' (greater than). | + | | value | If a hash of an index key is greater than a | + | | | hash of a search value, it matches. | + | | | Tuples are returned in ascending order by hash | + | | | of index key, which will appear to be random. | + | | | Provided that the space is not being updated, | + | | | one can retrieve all the tuples in a space, | + | | | N tuples at a time, by using | + | | | ``iterator='GT',limit=N`` | + | | | in each search, and using the last returned | + | | | value from the previous result as the start | + | | | search value for the next search. | + +---------------+-----------+------------------------------------------------+ + + Iterator types for BITSET indexes: + + +----------------------------+-----------+----------------------------------------------+ + | Type | Arguments | Description | + +============================+===========+==============================================+ + | ``'ALL'`` | none | All index keys match. | + | | | Tuples are returned in their order within | + | | | the space. | + +----------------------------+-----------+----------------------------------------------+ + | ``'EQ'`` | bitset | If an index key is equal to a bitset value, | + | | value | it matches. | + | | | Tuples are returned in their order within | + | | | the space. This is the default. | + +----------------------------+-----------+----------------------------------------------+ + | ``'BITS_ALL_SET'`` | bitset | If all of the bits which are 1 in the bitset | + | | value | value are 1 in the index key, it matches. | + | | | Tuples are returned in their order within | + | | | the space. | + +----------------------------+-----------+----------------------------------------------+ + | ``'BITS_ANY_SET'`` | bitset | If any of the bits which are 1 in the bitset | + | | value | value are 1 in the index key, it matches. | + | | | Tuples are returned in their order within | + | | | the space. | + +----------------------------+-----------+----------------------------------------------+ + | ``'BITS_ALL_NOT_SET'`` | bitset | If all of the bits which are 1 in the bitset | + | | value | value are 0 in the index key, it matches. | + | | | Tuples are returned in their order within | + | | | the space. | + +----------------------------+-----------+----------------------------------------------+ + + :rtype: :class:`~tarantool.response.Response` + + :raise: :exc:`~AssertionError`, + :exc:`~tarantool.error.DatabaseError`, + :exc:`~tarantool.error.SchemaError`, + :exc:`~tarantool.error.NetworkError`, + :exc:`~tarantool.error.SslError` + + .. _select: https://www.tarantool.io/en/doc/latest/reference/reference_lua/box_space/select/ + """ if iterator is None: iterator = ITERATOR_EQ @@ -982,53 +1528,75 @@ def select(self, space_name, key=None, *, offset=0, limit=0xffffffff, index=0, i return response def space(self, space_name): - ''' - Create a `Space` instance for a particular space. + """ + Create a :class:`~tarantool.space.Space` instance for a + particular space. - A `Space` instance encapsulates the identifier - of the space and provides a more convenient syntax - for accessing the database space. + :param space_name: Space name or space id. + :type space_name: :obj:`str` or :obj:`int` - :param space_name: identifier of the space - :type space_name: int or str + :rtype: :class:`~tarantool.space.Space` + + :raise: :exc:`~tarantool.error.SchemaError` + """ - :rtype: `Space` instance - ''' return Space(self, space_name) def generate_sync(self): - ''' - Need override for async io connection. - ''' + """ + Generate IPROTO_SYNC code for a request. Since the connector is + synchronous, any constant value would be sufficient. + + :return: ``0`` + :rtype: :obj:`int` + + :meta private: + """ + return 0 def execute(self, query, params=None): - ''' - Execute an SQL request. + """ + Execute an SQL request: see `documentation`_ for syntax + reference. - The Tarantool binary protocol for SQL requests - supports "qmark" and "named" param styles. - A sequence of values can be used for "qmark" style. - A mapping is used for "named" param style + The Tarantool binary protocol for SQL requests supports "qmark" + and "named" param styles. A sequence of values can be used for + "qmark" style. A mapping is used for "named" param style without the leading colon in the keys. Example for "qmark" arguments: - >>> args = ['email@example.com'] - >>> c.execute('select * from "users" where "email"=?', args) + + .. code-block:: python + + args = ['email@example.com'] + c.execute('select * from "users" where "email"=?', args) Example for "named" arguments: - >>> args = {'email': 'email@example.com'} - >>> c.execute('select * from "users" where "email"=:email', args) - :param query: SQL syntax query - :type query: str + .. code-block:: python + + args = {'email': 'email@example.com'} + c.execute('select * from "users" where "email"=:email', args) + + :param query: SQL query. + :type query: :obj:`str` + + :param params: SQL query bind values. + :type params: :obj:`dict` or :obj:`list` or :obj:`None`, + optional + + :rtype: :class:`~tarantool.response.Response` + + :raise: :exc:`~AssertionError`, + :exc:`~tarantool.error.DatabaseError`, + :exc:`~tarantool.error.SchemaError`, + :exc:`~tarantool.error.NetworkError`, + :exc:`~tarantool.error.SslError` - :param params: bind values to use in the query - :type params: list, dict + .. _documentation: https://www.tarantool.io/en/doc/latest/how-to/sql/ + """ - :return: query result data - :rtype: `Response` instance - ''' if not params: params = [] request = RequestExecute(self, query, params) diff --git a/tarantool/connection_pool.py b/tarantool/connection_pool.py index 8c6dea5b..de4a869d 100644 --- a/tarantool/connection_pool.py +++ b/tarantool/connection_pool.py @@ -1,3 +1,7 @@ +""" +This module provides API for interaction with Tarantool servers cluster. +""" + import abc import itertools import queue @@ -28,43 +32,140 @@ class Mode(Enum): + """ + Request mode. + """ + ANY = 1 + """ + Send a request to any server. + """ + RW = 2 + """ + Send a request to RW server. + """ + RO = 3 + """ + Send a request to RO server. + """ + PREFER_RW = 4 + """ + Send a request to RW server, if possible, RO server otherwise. + """ + PREFER_RO = 5 + """ + Send a request to RO server, if possible, RW server otherwise. + """ class Status(Enum): + """ + Cluster single server status. + """ + HEALTHY = 1 + """ + Server is healthy: connection is successful, + `box.info.ro`_ could be extracted, `box.info.status`_ is "running". + """ + UNHEALTHY = 2 + """ + Server is unhealthy: either connection is failed, + `box.info`_ cannot be extracted, `box.info.status`_ is not + "running". + """ @dataclass class InstanceState(): + """ + Cluster single server state. + """ + status: Status = Status.UNHEALTHY + """ + :type: :class:`~tarantool.connection_pool.Status` + """ ro: typing.Optional[bool] = None + """ + :type: :obj:`bool`, optional + """ def QueueFactory(): + """ + Build a queue-based channel. + """ + return queue.Queue(maxsize=1) @dataclass class PoolUnit(): + """ + Class to store a Tarantool server metainfo and + to work with it as a part of connection pool. + """ + addr: dict + """ + ``{"host": host, "port": port}`` info. + + :type: :obj:`dict` + """ + conn: Connection + """ + :type: :class:`~tarantool.Connection` + """ + input_queue: queue.Queue = field(default_factory=QueueFactory) + """ + Channel to pass requests for the server thread. + + :type: :obj:`queue.Queue` + """ + output_queue: queue.Queue = field(default_factory=QueueFactory) + """ + Channel to receive responses from the server thread. + + :type: :obj:`queue.Queue` + """ + thread: typing.Optional[threading.Thread] = None + """ + Background thread to process requests for the server. + + :type: :obj:`threading.Thread` + """ + state: InstanceState = field(default_factory=InstanceState) - # request_processing_enabled is used to stop requests processing - # in background thread on close or destruction. + """ + Current server state. + + :type: :class:`~tarantool.connection_pool.InstanceState` + """ + request_processing_enabled: bool = False + """ + Flag used to stop requests processing requests in the background + thread on connection close or destruction. + :type: :obj:`bool` + """ # Based on https://realpython.com/python-interface/ class StrategyInterface(metaclass=abc.ABCMeta): + """ + Defines strategy to choose a pool server based on a request mode. + """ + @classmethod def __subclasshook__(cls, subclass): return (hasattr(subclass, '__init__') and @@ -77,21 +178,42 @@ def __subclasshook__(cls, subclass): @abc.abstractmethod def __init__(self, pool): + """ + :type: :obj:`list` of + :class:`~tarantool.connection_pool.PoolUnit` objects + """ + raise NotImplementedError @abc.abstractmethod def update(self): + """ + Refresh the strategy state. + """ raise NotImplementedError @abc.abstractmethod def getnext(self, mode): + """ + Get a pool server based on a request mode. + + :param mode: Request mode. + :type mode: :class:`~tarantool.Mode` + """ + raise NotImplementedError class RoundRobinStrategy(StrategyInterface): """ - Simple round-robin connection rotation + Simple round-robin pool servers rotation. """ + def __init__(self, pool): + """ + :type: :obj:`list` of + :class:`~tarantool.connection_pool.PoolUnit` objects + """ + self.ANY_iter = None self.RW_iter = None self.RO_iter = None @@ -99,6 +221,11 @@ def __init__(self, pool): self.rebuild_needed = True def build(self): + """ + Initialize (or re-initialize) internal pools to rotate servers + based on `box.info.ro`_ state. + """ + ANY_pool = [] RW_pool = [] RO_pool = [] @@ -134,9 +261,26 @@ def build(self): self.rebuild_needed = False def update(self): + """ + Set flag to re-initialize internal pools on next + :meth:`~tarantool.connection_pool.RoundRobinStrategy.getnext` + call. + """ + self.rebuild_needed = True def getnext(self, mode): + """ + Get server based on the request mode. + + :param mode: Request mode + :type mode: :class:`~tarantool.Mode` + + :rtype: :class:`~tarantool.connection_pool.PoolUnit` + + :raise: :exc:`~tarantool.error.PoolTolopogyError` + """ + if self.rebuild_needed: self.build() @@ -173,32 +317,53 @@ def getnext(self, mode): @dataclass class PoolTask(): + """ + Store request type and arguments to pass them to some server thread. + """ + method_name: str + """ + :class:`~tarantool.Connection` method name. + + :type: :obj:`str` + """ + args: tuple + """ + :class:`~tarantool.Connection` method args. + + :type: :obj:`tuple` + """ + kwargs: dict + """ + :class:`~tarantool.Connection` method kwargs. + + :type: :obj:`dict` + """ class ConnectionPool(ConnectionInterface): - ''' - Represents the pool of connections to a cluster of Tarantool servers. - - ConnectionPool API is the same as Connection API. - On each request, a connection is chosen to execute the request. - The connection is selected based on request mode: - - * Mode.ANY chooses any instance. - * Mode.RW chooses an RW instance. - * Mode.RO chooses an RO instance. - * Mode.PREFER_RW chooses an RW instance, if possible, an RO instance - otherwise. - * Mode.PREFER_RO chooses an RO instance, if possible, an RW instance - otherwise. - - All requests that guarantee to write data (insert, replace, delete, - upsert, update) use the RW mode by default. select uses ANY by default. You - can set the mode explicitly. The call, eval, execute, and ping requests - require to set the mode explicitly. - ''' + """ + Represents the pool of connections to a cluster of Tarantool + servers. + + To work with :class:`~tarantool.connection_pool.ConnectionPool`, + `box.info`_ must be callable for the user on each server. + + :class:`~tarantool.ConnectionPool` is best suited to work with + a single replicaset. Its API is the same as a single server + :class:`~tarantool.Connection`, but requests support ``mode`` + parameter (a :class:`tarantool.Mode` value) to choose between + read-write and read-only pool instances: + + .. code-block:: python + + >>> resp = conn.select('demo', 'AAAA', mode=tarantool.Mode.PREFER_RO) + >>> resp + - ['AAAA', 'Alpha'] + """ + def __init__(self, addrs, user=None, @@ -212,46 +377,83 @@ def __init__(self, connection_timeout=CONNECTION_TIMEOUT, strategy_class=RoundRobinStrategy, refresh_delay=POOL_REFRESH_DELAY): - ''' - Initialize connections to a cluster of servers. - - :param list addrs: List of + """ + :param addrs: List of dictionaries describing server addresses: .. code-block:: python { - host: "str", # optional - port: int or "str", # mandatory - transport: "str", # optional - ssl_key_file: "str", # optional - ssl_cert_file: "str", # optional - ssl_ca_file: "str", # optional - ssl_ciphers: "str" # optional + "host': "str" or None, # mandatory + "port": int or "str", # mandatory + "transport": "str", # optional + "ssl_key_file": "str", # optional + "ssl_cert_file": "str", # optional + "ssl_ca_file": "str", # optional + "ssl_ciphers": "str" # optional } - dictionaries describing server addresses. - See similar :func:`tarantool.Connection` parameters. - :param str user: Username used to authenticate. User must be able - to call the box.info function. For example, to grant permissions to - the 'guest' user, evaluate: - box.schema.func.create('box.info') - box.schema.user.grant('guest', 'execute', 'function', 'box.info') - on Tarantool instances. - :param int reconnect_max_attempts: Max attempts to reconnect - for each connection in the pool. Be careful with reconnect - parameters in ConnectionPool since every status refresh is - also a request with reconnection. Default is 0 (fail after - first attempt). - :param float reconnect_delay: Time between reconnect - attempts for each connection in the pool. Be careful with - reconnect parameters in ConnectionPool since every status - refresh is also a request with reconnection. Default is 0. - :param StrategyInterface strategy_class: Class for choosing - instance based on request mode. By default, the round-robin - strategy is used. - :param int refresh_delay: Minimal time between RW/RO status - refreshes. - ''' + Refer to corresponding :class:`~tarantool.Connection` + parameters. + :type addrs: :obj:`list` + + :param user: Refer to + :paramref:`~tarantool.Connection.params.user`. + The value is used for each connection in the pool. + + :param password: Refer to + :paramref:`~tarantool.Connection.params.password`. + The value is used for each connection in the pool. + + :param socket_timeout: Refer to + :paramref:`~tarantool.Connection.params.socket_timeout`. + The value is used for each connection in the pool. + + :param reconnect_max_attempts: Refer to + :paramref:`~tarantool.Connection.params.reconnect_max_attempts`. + The value is used for each connection in the pool. + Be careful: it is internal :class:`~tarantool.Connection` + reconnect unrelated to pool reconnect mechanisms. + + :param reconnect_delay: Refer to + :paramref:`~tarantool.Connection.params.reconnect_delay`. + The value is used for each connection in the pool. + Be careful: it is internal :class:`~tarantool.Connection` + reconnect unrelated to pool reconnect mechanisms. + + :param connect_now: If ``True``, connect to all pool servers on + initialization. Otherwise, you have to call + :meth:`~tarantool.connection_pool.ConnectionPool.connect` + manually after initialization. + :type connect_now: :obj:`bool`, optional + + :param encoding: Refer to + :paramref:`~tarantool.Connection.params.encoding`. + The value is used for each connection in the pool. + + :param call_16: Refer to + :paramref:`~tarantool.Connection.params.call_16`. + The value is used for each connection in the pool. + + :param connection_timeout: Refer to + :paramref:`~tarantool.Connection.params.connection_timeout`. + The value is used for each connection in the pool. + + :param strategy_class: Strategy for choosing a server based on a + request mode. Defaults to the round-robin strategy. + :type strategy_class: :class:`~tarantool.connection_pool.StrategyInterface`, + optional + + :param refresh_delay: Minimal time between pool server + `box.info.ro`_ status background refreshes, in seconds. + :type connection_timeout: :obj:`float`, optional + + :raise: :exc:`~tarantool.error.ConfigurationError`, + :class:`~tarantool.Connection` exceptions + + .. _box.info.ro: + .. _box.info.status: + .. _box.info: https://www.tarantool.io/en/doc/latest/reference/reference_lua/box_info/ + """ if not isinstance(addrs, list) or len(addrs) == 0: raise ConfigurationError("addrs must be non-empty list") @@ -300,9 +502,31 @@ def __del__(self): self.close() def _make_key(self, addr): + """ + Make a unique key for a server based on its address. + + :param addr: `{"host": host, "port": port}` dictionary. + :type addr: :obj:`dict` + + :rtype: :obj:`str` + + :meta private: + """ + return '{0}:{1}'.format(addr['host'], addr['port']) def _get_new_state(self, unit): + """ + Get new pool server state. + + :param unit: Server metainfo. + :type unit: :class:`~tarantool.connection_pool.PoolUnit` + + :rtype: :class:`~tarantool.connection_pool.InstanceState` + + :meta private: + """ + conn = unit.conn if conn.is_closed(): @@ -347,6 +571,16 @@ def _get_new_state(self, unit): return InstanceState(Status.HEALTHY, ro) def _refresh_state(self, key): + """ + Refresh pool server state. + + :param key: Result of + :meth:`~tarantool.connection_pool._make_key`. + :type key: :obj:`str` + + :meta private: + """ + unit = self.pool[key] state = self._get_new_state(unit) @@ -355,6 +589,9 @@ def _refresh_state(self, key): self.strategy.update() def close(self): + """ + Stop request processing, close each connection in the pool. + """ for unit in self.pool.values(): unit.request_processing_enabled = False unit.thread.join() @@ -363,9 +600,31 @@ def close(self): unit.conn.close() def is_closed(self): + """ + Returns ``False`` if at least one connection is not closed and + is ready to process requests. Otherwise, returns ``True``. + + :rtype: :obj:`bool` + """ + return all(unit.request_processing_enabled == False for unit in self.pool.values()) def _request_process_loop(self, key, unit, last_refresh): + """ + Request process background loop for a pool server. Started in + a separate thread, one thread per server. + + :param key: Result of + :meth:`~tarantool.connection_pool._make_key`. + :type key: :obj:`str` + + :param unit: Server metainfo. + :type unit: :class:`~tarantool.connection_pool.PoolUnit` + + :param last_refresh: Time of last metainfo refresh. + :type last_refresh: :obj:`float` + """ + while unit.request_processing_enabled: if not unit.input_queue.empty(): task = unit.input_queue.get() @@ -384,6 +643,18 @@ def _request_process_loop(self, key, unit, last_refresh): last_refresh = time.time() def connect(self): + """ + Create a connection to each address specified on + initialization and start background process threads for them. + There is no need to call this method explicitly until you have + set ``connect_now=False`` on initialization. + + If some connections have failed to connect successfully or + provide `box.info`_ status (including the case when all of them + have failed), no exceptions are raised. Attempts to reconnect + and refresh the info would be processed in the background. + """ + for key in self.pool: unit = self.pool[key] @@ -399,6 +670,34 @@ def connect(self): unit.thread.start() def _send(self, mode, method_name, *args, **kwargs): + """ + Request wrapper. Choose a pool server based on mode and send + a request with arguments. + + :param mode: Request mode. + :type mode: :class:`~tarantool.Mode` + + :param method_name: :class:`~tarantool.Connection` + method name. + :type method_name: :obj:`str` + + :param args: Method args. + :type args: :obj:`tuple` + + :param kwargs: Method kwargs. + :type kwargs: :obj:`dict` + + :rtype: :class:`~tarantool.response.Response` + + :raise: :exc:`~AssertionError`, + :exc:`~tarantool.error.DatabaseError`, + :exc:`~tarantool.error.SchemaError`, + :exc:`~tarantool.error.NetworkError`, + :exc:`~tarantool.error.SslError` + + :meta private: + """ + key = self.strategy.getnext(mode) unit = self.pool[key] @@ -413,9 +712,24 @@ def _send(self, mode, method_name, *args, **kwargs): return resp def call(self, func_name, *args, mode=None): - ''' - :param tarantool.Mode mode: Request mode. - ''' + """ + Execute a CALL request on the pool server: call a stored Lua + function. Refer to :meth:`~tarantool.Connection.call`. + + :param func_name: Refer to + :paramref:`~tarantool.Connection.call.params.func_name`. + + :param args: Refer to + :paramref:`~tarantool.Connection.call.params.args`. + + :param mode: Request mode. + :type mode: :class:`~tarantool.Mode` + + :rtype: :class:`~tarantool.response.Response` + + :raise: :exc:`~ValueError`, + :meth:`~tarantool.Connection.call` exceptions + """ if mode is None: raise ValueError("Please, specify 'mode' keyword argument") @@ -423,9 +737,24 @@ def call(self, func_name, *args, mode=None): return self._send(mode, 'call', func_name, *args) def eval(self, expr, *args, mode=None): - ''' - :param tarantool.Mode mode: Request mode. - ''' + """ + Execute an EVAL request on the pool server: evaluate a Lua + expression. Refer to :meth:`~tarantool.Connection.eval`. + + :param expr: Refer to + :paramref:`~tarantool.Connection.eval.params.expr`. + + :param args: Refer to + :paramref:`~tarantool.Connection.eval.params.args`. + + :param mode: Request mode. + :type mode: :class:`~tarantool.Mode` + + :rtype: :class:`~tarantool.response.Response` + + :raise: :exc:`~ValueError`, + :meth:`~tarantool.Connection.eval` exceptions + """ if mode is None: raise ValueError("Please, specify 'mode' keyword argument") @@ -433,46 +762,154 @@ def eval(self, expr, *args, mode=None): return self._send(mode, 'eval', expr, *args) def replace(self, space_name, values, *, mode=Mode.RW): - ''' - :param tarantool.Mode mode: Request mode (default is RW). - ''' + """ + Execute a REPLACE request on the pool server: `replace`_ a tuple + in the space. Refer to :meth:`~tarantool.Connection.replace`. + + :param space_name: Refer to + :paramref:`~tarantool.Connection.replace.params.space_name`. + + :param values: Refer to + :paramref:`~tarantool.Connection.replace.params.values`. + + :param mode: Request mode. + :type mode: :class:`~tarantool.Mode`, optional + + :rtype: :class:`~tarantool.response.Response` + + :raise: :meth:`~tarantool.Connection.replace` exceptions + + .. _replace: https://www.tarantool.io/en/doc/latest/reference/reference_lua/box_space/replace/ + """ return self._send(mode, 'replace', space_name, values) def insert(self, space_name, values, *, mode=Mode.RW): - ''' - :param tarantool.Mode mode: Request mode (default is RW). - ''' + """ + Execute an INSERT request on the pool server: `insert`_ a tuple + to the space. Refer to :meth:`~tarantool.Connection.insert`. + + :param space_name: Refer to + :paramref:`~tarantool.Connection.insert.params.space_name`. + + :param values: Refer to + :paramref:`~tarantool.Connection.insert.params.values`. + + :param mode: Request mode. + :type mode: :class:`~tarantool.Mode`, optional + + :rtype: :class:`~tarantool.response.Response` + + :raise: :meth:`~tarantool.Connection.insert` exceptions + + .. _insert: https://www.tarantool.io/en/doc/latest/reference/reference_lua/box_space/insert/ + """ return self._send(mode, 'insert', space_name, values) def delete(self, space_name, key, *, index=0, mode=Mode.RW): - ''' - :param tarantool.Mode mode: Request mode (default is RW). - ''' + """ + Execute an DELETE request on the pool server: `delete`_ a tuple + in the space. Refer to :meth:`~tarantool.Connection.delete`. + + :param space_name: Refer to + :paramref:`~tarantool.Connection.delete.params.space_name`. + + :param key: Refer to + :paramref:`~tarantool.Connection.delete.params.key`. + + :param index: Refer to + :paramref:`~tarantool.Connection.delete.params.index`. + + :param mode: Request mode. + :type mode: :class:`~tarantool.Mode`, optional + + :rtype: :class:`~tarantool.response.Response` + + :raise: :meth:`~tarantool.Connection.delete` exceptions + + .. _delete: https://www.tarantool.io/en/doc/latest/reference/reference_lua/box_space/delete/ + """ return self._send(mode, 'delete', space_name, key, index=index) def upsert(self, space_name, tuple_value, op_list, *, index=0, mode=Mode.RW): - ''' - :param tarantool.Mode mode: Request mode (default is RW). - ''' + """ + Execute an UPSERT request on the pool server: `upsert`_ a tuple to + the space. Refer to :meth:`~tarantool.Connection.upsert`. + + :param space_name: Refer to + :paramref:`~tarantool.Connection.upsert.params.space_name`. + + :param tuple_value: Refer to + :paramref:`~tarantool.Connection.upsert.params.tuple_value`. + + :param op_list: Refer to + :paramref:`~tarantool.Connection.upsert.params.op_list`. + + :param index: Refer to + :paramref:`~tarantool.Connection.upsert.params.index`. + + :param mode: Request mode. + :type mode: :class:`~tarantool.Mode`, optional + + :rtype: :class:`~tarantool.response.Response` + + :raise: :meth:`~tarantool.Connection.upsert` exceptions + + .. _upsert: https://www.tarantool.io/en/doc/latest/reference/reference_lua/box_space/upsert/ + """ return self._send(mode, 'upsert', space_name, tuple_value, op_list, index=index) def update(self, space_name, key, op_list, *, index=0, mode=Mode.RW): - ''' - :param tarantool.Mode mode: Request mode (default is RW). - ''' + """ + Execute an UPDATE request on the pool server: `update`_ a tuple + in the space. Refer to :meth:`~tarantool.Connection.update`. + + :param space_name: Refer to + :paramref:`~tarantool.Connection.update.params.space_name`. + + :param key: Refer to + :paramref:`~tarantool.Connection.update.params.key`. + + :param op_list: Refer to + :paramref:`~tarantool.Connection.update.params.op_list`. + + :param index: Refer to + :paramref:`~tarantool.Connection.update.params.index`. + + :param mode: Request mode. + :type mode: :class:`~tarantool.Mode`, optional + + :rtype: :class:`~tarantool.response.Response` + + :raise: :meth:`~tarantool.Connection.upsert` exceptions + + .. _update: https://www.tarantool.io/en/doc/latest/reference/reference_lua/box_space/update/ + """ return self._send(mode, 'update', space_name, key, op_list, index=index) def ping(self, notime=False, *, mode=None): - ''' - :param tarantool.Mode mode: Request mode. - ''' + """ + Execute a PING request on the pool server: send an empty request + and receive an empty response from the server. Refer to + :meth:`~tarantool.Connection.ping`. + + :param notime: Refer to + :paramref:`~tarantool.Connection.ping.params.notime`. + + :param mode: Request mode. + :type mode: :class:`~tarantool.Mode` + + :return: Refer to :meth:`~tarantool.Connection.ping`. + + :raise: :exc:`~ValueError`, + :meth:`~tarantool.Connection.ping` exceptions + """ if mode is None: raise ValueError("Please, specify 'mode' keyword argument") @@ -481,18 +918,60 @@ def ping(self, notime=False, *, mode=None): def select(self, space_name, key, *, offset=0, limit=0xffffffff, index=0, iterator=None, mode=Mode.ANY): - ''' - :param tarantool.Mode mode: Request mode (default is - ANY). - ''' + """ + Execute a SELECT request on the pool server: `update`_ a tuple + from the space. Refer to :meth:`~tarantool.Connection.select`. + + :param space_name: Refer to + :paramref:`~tarantool.Connection.select.params.space_name`. + + :param key: Refer to + :paramref:`~tarantool.Connection.select.params.key`. + + :param offset: Refer to + :paramref:`~tarantool.Connection.select.params.offset`. + + :param limit: Refer to + :paramref:`~tarantool.Connection.select.params.limit`. + + :param index: Refer to + :paramref:`~tarantool.Connection.select.params.index`. + + :param iterator: Refer to + :paramref:`~tarantool.Connection.select.params.iterator`. + + :param mode: Request mode. + :type mode: :class:`~tarantool.Mode`, optional + + :rtype: :class:`~tarantool.response.Response` + + :raise: :meth:`~tarantool.Connection.select` exceptions + + .. _select: https://www.tarantool.io/en/doc/latest/reference/reference_lua/box_space/select/ + """ return self._send(mode, 'select', space_name, key, offset=offset, limit=limit, index=index, iterator=iterator) def execute(self, query, params=None, *, mode=None): - ''' - :param tarantool.Mode mode: Request mode (default is RW). - ''' + """ + Execute an SQL request on the pool server. Refer to + :meth:`~tarantool.Connection.execute`. + + :param query: Refer to + :paramref:`~tarantool.Connection.execute.params.query`. + + :param params: Refer to + :paramref:`~tarantool.Connection.execute.params.params`. + + :param mode: Request mode. + :type mode: :class:`~tarantool.Mode` + + :rtype: :class:`~tarantool.response.Response` + + :raise: :exc:`~ValueError`, + :meth:`~tarantool.Connection.execute` exceptions + """ if mode is None: raise ValueError("Please, specify 'mode' keyword argument") diff --git a/tarantool/dbapi.py b/tarantool/dbapi.py index 515e4689..f6f5d2ab 100644 --- a/tarantool/dbapi.py +++ b/tarantool/dbapi.py @@ -1,3 +1,9 @@ +""" +Python DB API implementation, refer to `PEP-249`_. + +.. _PEP-249: http://www.python.org/dev/peps/pep-0249/ +""" + from tarantool.connection import Connection as BaseConnection from tarantool.error import * @@ -8,8 +14,19 @@ class Cursor: + """ + Represent a database `cursor`_, which is used to manage the context + of a fetch operation. + + .. _cursor: https://peps.python.org/pep-0249/#cursor-objects + """ def __init__(self, conn): + """ + :param conn: Connection to a Tarantool server. + :type conn: :class:`~tarantool.Connection` + """ + self._c = conn self._lastrowid = None self._rowcount = None @@ -18,12 +35,16 @@ def __init__(self, conn): def callproc(self, procname, *params): """ - Call a stored database procedure with the given name. The sequence of - parameters must contain one entry for each argument that the - procedure expects. The result of the call is returned as a modified - copy of the input sequence. The input parameters are left untouched, - the output and input/output parameters replaced with - possibly new values. + **Not supported** + + Call a stored database procedure with the given name. The + sequence of parameters must contain one entry for each argument + that the procedure expects. The result of the call is returned + as a modified copy of the input sequence. The input parameters + are left untouched, the output and input/output parameters + replaced with possibly new values. + + :raises: :exc:`~tarantool.error.NotSupportedError` """ raise NotSupportedError("callproc() method is not supported") @@ -33,22 +54,45 @@ def rows(self): @property def description(self): + """ + **Not implemented** + + Call a stored database procedure with the given name. The + sequence of parameters must contain one entry for each argument + that the procedure expects. The result of the call is returned + as a modified copy of the input sequence. The input parameters + are left untouched, the output and input/output parameters + replaced with possibly new values. + + :raises: :exc:`~NotImplementedError` + """ + # FIXME Implement this method please raise NotImplementedError("description() property is not implemented") def close(self): """ Close the cursor now (rather than whenever __del__ is called). - The cursor will be unusable from this point forward; DatabaseError - exception will be raised if any operation is attempted with - the cursor. + The cursor will be unusable from this point forward; + :exc:`~tarantool.error.InterfaceError` exception will be + raised if any operation is attempted with the cursor. """ + self._c = None self._rows = None self._lastrowid = None self._rowcount = None def _check_not_closed(self, error=None): + """ + Check that cursor is not closed. Raise + :exc:`~tarantool.error.InterfaceError` otherwise. + + :param error: Custom error to be raised if cursor is closed. + :type error: optional + + :raises: :exc:`~tarantool.error.InterfaceError` + """ if self._c is None: raise InterfaceError(error or "Can not operate on a closed cursor") if self._c.is_closed(): @@ -57,8 +101,19 @@ def _check_not_closed(self, error=None): def execute(self, query, params=None): """ - Prepare and execute a database operation (query or command). + Execute an SQL request. Refer to + :meth:`~tarantool.Connection.execute`. + + :param query: Refer to + :paramref:`~tarantool.Connection.execute.params.query` + + :param params: Refer to + :paramref:`~tarantool.Connection.execute.params.params` + + :raises: :exc:`~tarantool.error.InterfaceError`, + :meth:`~tarantool.Connection.execute` exceptions """ + self._check_not_closed("Can not execute on closed cursor.") response = self._c.execute(query, params) @@ -71,6 +126,22 @@ def execute(self, query, params=None): self._lastrowid = None def executemany(self, query, param_sets): + """ + Execute several SQL requests with same query and different + parameters. Refer to :meth:`~tarantool.dbapi.Cursor.execute`. + + :param query: Refer to + :paramref:`~tarantool.dbapi.Cursor.execute.params.query`. + + :param param_sets: Set of parameters for execution. Refer to + :paramref:`~tarantool.dbapi.Cursor.execute.params.params` + for item description. + :type param sets: :obj:`list` or :obj:`tuple` + + :raises: :exc:`~tarantool.error.InterfaceError`, + :meth:`~tarantool.dbapi.Cursor.execute` exceptions + """ + self._check_not_closed("Can not execute on closed cursor.") rowcount = 0 for params in param_sets: @@ -84,25 +155,39 @@ def executemany(self, query, param_sets): @property def lastrowid(self): """ - This read-only attribute provides the rowid of the last modified row - (most databases return a rowid only when a single INSERT operation is - performed). + This read-only attribute provides the rowid of the last modified + row (most databases return a rowid only when a single INSERT + operation is performed). + + :type: :obj:`int` """ + return self._lastrowid @property def rowcount(self): """ - This read-only attribute specifies the number of rows that the last - .execute*() produced (for DQL statements like SELECT) or affected ( - for DML statements like UPDATE or INSERT). + This read-only attribute specifies the number of rows that the + last ``.execute*()`` produced (for DQL statements like SELECT) + or affected (for DML statements like UPDATE or INSERT). + + :type: :obj:`int` """ + return self._rowcount def _check_result_set(self, error=None): """ - Non-public method for raising an error when Cursor object does not have - any row to fetch. Useful for checking access after DQL requests. + Non-public method for raising an error when Cursor object does + not have any row to fetch. Useful for checking access after DQL + requests. + + :param error: Error to raise in case of fail. + :type error: optional + + :raise: :exc:`~tarantool.error.InterfaceError` + + :meta private: """ if self._rows is None: raise InterfaceError(error or "No result set to fetch from") @@ -111,16 +196,25 @@ def fetchone(self): """ Fetch the next row of a query result set, returning a single sequence, or None when no more data is available. + + :raise: :exc:`~tarantool.error.InterfaceError` """ + self._check_result_set() return self.fetchmany(1)[0] if self._rows else None def fetchmany(self, size=None): """ - Fetch the next set of rows of a query result, returning a sequence of - sequences (e.g. a list of tuples). An empty sequence is returned when - no more rows are available. + Fetch the next set of rows of a query result, returning a + sequence of sequences (e.g. a list of tuples). An empty sequence + is returned when no more rows are available. + + :param size: Count of rows to fetch. If ``None``, fetch all. + :type size: :obj:`int` or :obj:`None`, optional + + :raise: :exc:`~tarantool.error.InterfaceError` """ + self._check_result_set() size = size or self.arraysize @@ -134,10 +228,15 @@ def fetchmany(self, size=None): return items def fetchall(self): - """Fetch all (remaining) rows of a query result, returning them as a - sequence of sequences (e.g. a list of tuples). Note that the cursor's - arraysize attribute can affect the performance of this operation. """ + Fetch all (remaining) rows of a query result, returning them as + a sequence of sequences (e.g. a list of tuples). Note that + the cursor's arraysize attribute can affect the performance of + this operation. + + :raise: :exc:`~tarantool.error.InterfaceError` + """ + self._check_result_set() items = self._rows @@ -145,22 +244,51 @@ def fetchall(self): return items def setinputsizes(self, sizes): - """PEP-249 allows to not implement this method and do nothing.""" + """ + **Not implemented** (optional, refer to `PEP-249`_) + + Do nothing. + """ def setoutputsize(self, size, column=None): - """PEP-249 allows to not implement this method and do nothing.""" + """ + **Not implemented** (optional, refer to `PEP-249`_) + + Do nothing. + """ class Connection(BaseConnection): + """ + `PEP-249`_ compatible :class:`~tarantool.Connection` class wrapper. + """ def __init__(self, *args, **kwargs): + """ + :param args: :class:`~tarantool.Connection` args. + :type args: :obj:`tuple` + + :param kwargs: :class:`~tarantool.Connection` kwargs. + :type kwargs: :obj:`dict` + + :param autocommit: Enable or disable autocommit. Defaults to + ``True``. + :type autocommit: :obj:`bool`, optional + + :raise: :class:`~tarantool.Connection` exceptions + """ + super(Connection, self).__init__(*args, **kwargs) self._set_autocommit(kwargs.get('autocommit', True)) def _set_autocommit(self, autocommit): - """Autocommit is True by default and the default will be changed - to False. Set the autocommit property explicitly to True or verify - it when lean on autocommit behaviour.""" + """ + Autocommit setter. ``False`` is not supported. + + :raise: :exc:`~tarantool.error.InterfaceError`, + :exc:`~tarantool.error.NotSupportedError` + """ + if not isinstance(autocommit, bool): raise InterfaceError("autocommit parameter must be boolean, " "not %s" % autocommit.__class__.__name__) @@ -171,46 +299,79 @@ def _set_autocommit(self, autocommit): @property def autocommit(self): - """Autocommit state""" + """ + Autocommit state. + """ + return self._autocommit @autocommit.setter def autocommit(self, autocommit): - """Set autocommit state""" + """ + Set autocommit state. ``False`` is not supported. + + :raise: :exc:`~tarantool.error.InterfaceError`, + :exc:`~tarantool.error.NotSupportedError` + """ + self._set_autocommit(autocommit) def _check_not_closed(self, error=None): """ - Checks if the connection is not closed and rises an error if it is. + Checks if the connection is not closed and raises an error if it + is. + + :param error: Error to raise in case of fail. + :type error: optional + + :raise: :exc:`~tarantool.error.InterfaceError` """ if self.is_closed(): raise InterfaceError(error or "The connector is closed") def close(self): """ - Closes the connection + Close the connection. + + :raise: :exc:`~tarantool.error.InterfaceError` """ + self._check_not_closed("The closed connector can not be closed again.") super(Connection, self).close() def commit(self): """ Commit any pending transaction to the database. + + :raise: :exc:`~tarantool.error.InterfaceError` """ + self._check_not_closed("Can not commit on the closed connection") def rollback(self): """ - Roll back pending transaction + **Not supported** + + Roll back pending transaction. + + :raise: :exc:`~tarantool.error.InterfaceError`, + :exc:`~tarantool.error.NotSupportedError` """ + self._check_not_closed("Can not roll back on a closed connection") raise NotSupportedError("Transactions are not supported in this" "version of connector") def cursor(self): """ - Return a new Cursor Object using the connection. + Return a new Cursor object using the connection. + + :rtype: :class:`~tarantool.dbapi.Cursor` + + :raise: :exc:`~tarantool.error.InterfaceError`, + :class:`~tarantool.dbapi.Cursor` exceptions """ + self._check_not_closed("Cursor creation is not allowed on a closed " "connection") return Cursor(self) @@ -221,13 +382,23 @@ def connect(dsn=None, host=None, port=None, """ Constructor for creating a connection to the database. - :param str dsn: Data source name (Tarantool URI) - ([[[username[:password]@]host:]port) - :param str host: Server hostname or IP-address - :param int port: Server port - :param str user: Tarantool user - :param str password: User password - :rtype: Connection + :param dsn: **Not implemented**. Tarantool server URI: + ``[[[username[:password]@]host:]port``. + :type dsn: :obj:`str` + + :param host: Refer to :paramref:`~tarantool.Connection.params.host`. + + :param port: Refer to :paramref:`~tarantool.Connection.params.port`. + + :param user: Refer to :paramref:`~tarantool.Connection.params.user`. + + :param password: Refer to + :paramref:`~tarantool.Connection.params.password`. + + :rtype: :class:`~tarantool.Connection` + + :raise: :exc:`~NotImplementedError`, + :class:`~tarantool.Connection` exceptions """ if dsn: diff --git a/tarantool/error.py b/tarantool/error.py index 6bbe012a..b2da32e7 100644 --- a/tarantool/error.py +++ b/tarantool/error.py @@ -1,22 +1,9 @@ # pylint: disable=C0301,W0105,W0401,W0614 -''' -Python DB API compatible exceptions -http://www.python.org/dev/peps/pep-0249/ - -The PEP-249 says that database related exceptions must be inherited as follows: - - Exception - |__Warning - |__Error - |__InterfaceError - |__DatabaseError - |__DataError - |__OperationalError - |__IntegrityError - |__InternalError - |__ProgrammingError - |__NotSupportedError -''' +""" +Python DB API compatible exceptions, see `PEP-249`_. + +.. _PEP-249: http://www.python.org/dev/peps/pep-0249/ +""" import os import socket @@ -31,89 +18,96 @@ class Warning(Exception): - '''Exception raised for important warnings - like data truncations while inserting, etc. ''' + """ + Exception raised for important warnings + like data truncations while inserting, etc. + """ class Error(Exception): - '''Base class for error exceptions''' + """ + Base class for error exceptions. + """ class InterfaceError(Error): - ''' - Exception raised for errors that are related to the database interface - rather than the database itself. - ''' + """ + Exception raised for errors that are related to the database + interface rather than the database itself. + """ class DatabaseError(Error): - '''Exception raised for errors that are related to the database.''' + """ + Exception raised for errors that are related to the database. + """ class DataError(DatabaseError): - ''' - Exception raised for errors that are due to problems with the processed - data like division by zero, numeric value out of range, etc. - ''' + """ + Exception raised for errors that are due to problems with the + processed data like division by zero, numeric value out of range, + etc. + """ class OperationalError(DatabaseError): - ''' - Exception raised for errors that are related to the database's operation - and not necessarily under the control of the programmer, e.g. an - unexpected disconnect occurs, the data source name is not found, - a transaction could not be processed, a memory allocation error occurred - during processing, etc. - ''' + """ + Exception raised for errors that are related to the database's + operation and not necessarily under the control of the programmer, + e.g. an unexpected disconnect occurs, the data source name is not + found, a transaction could not be processed, a memory allocation + error occurred during processing, etc. + """ class IntegrityError(DatabaseError): - ''' - Exception raised when the relational integrity of the database is affected, - e.g. a foreign key check fails. - ''' + """ + Exception raised when the relational integrity of the database is + affected, e.g. a foreign key check fails. + """ class InternalError(DatabaseError): - ''' - Exception raised when the database encounters an internal error, e.g. the - cursor is not valid anymore, the transaction is out of sync, etc. - ''' + """ + Exception raised when the database encounters an internal error, + e.g. the cursor is not valid anymore, the transaction is out of + sync, etc. + """ class ProgrammingError(DatabaseError): - ''' - Exception raised when the database encounters an internal error, e.g. the - cursor is not valid anymore, the transaction is out of sync, etc. - ''' + """ + Exception raised when the database encounters an internal error, + e.g. the cursor is not valid anymore, the transaction is out of + sync, etc. + """ class NotSupportedError(DatabaseError): - ''' - Exception raised in case a method or database API was used which is not - supported by the database, e.g. requesting a .rollback() on a connection - that does not support transaction or has transactions turned off. - ''' + """ + Exception raised in case a method or database API was used which is + not supported by the database, e.g. requesting a .rollback() on a + connection that does not support transactions or has transactions + turned off. + """ class ConfigurationError(Error): - ''' + """ Error of initialization with a user-provided configuration. - ''' + """ class MsgpackError(Error): - ''' - Error with encoding or decoding of MP_EXT types - ''' + """ + Error with encoding or decoding of `MP_EXT`_ types. -class MsgpackWarning(UserWarning): - ''' - Warning with encoding or decoding of MP_EXT types - ''' + .. _MP_EXT: https://www.tarantool.io/en/doc/latest/dev_guide/internals/msgpack_extensions/ + """ -__all__ = ("Warning", "Error", "InterfaceError", "DatabaseError", "DataError", - "OperationalError", "IntegrityError", "InternalError", - "ProgrammingError", "NotSupportedError", "MsgpackError", - "MsgpackWarning") +class MsgpackWarning(UserWarning): + """ + Warning with encoding or decoding of `MP_EXT`_ types. + """ # Monkey patch os.strerror for win32 if sys.platform == "win32": @@ -163,13 +157,13 @@ class MsgpackWarning(UserWarning): os_strerror_orig = os.strerror def os_strerror_patched(code): - ''' + """ Return cross-platform message about socket-related errors This function exists because under Windows os.strerror returns 'Unknown error' on all socket-related errors. And socket-related exception contain broken non-ascii encoded messages. - ''' + """ message = os_strerror_orig(code) if not message.startswith("Unknown"): return message @@ -181,7 +175,15 @@ def os_strerror_patched(code): class SchemaError(DatabaseError): + """ + Error related to extracting space and index schema. + """ + def __init__(self, value): + """ + :param value: Error value. + """ + super(SchemaError, self).__init__(0, value) self.value = value @@ -190,7 +192,19 @@ def __str__(self): class SchemaReloadException(DatabaseError): + """ + Error related to outdated space and index schema. + """ + def __init__(self, message, schema_version): + """ + :param message: Error message. + :type message: :obj:`str` + + :param schema_version: Response schema version. + :type schema_version: :obj:`int` + """ + super(SchemaReloadException, self).__init__(109, message) self.code = 109 self.message = message @@ -201,9 +215,19 @@ def __str__(self): class NetworkError(DatabaseError): - '''Error related to network''' + """ + Error related to network. + """ def __init__(self, orig_exception=None, *args): + """ + :param orig_exception: Exception to wrap. + :type orig_exception: optional + + :param args: Wrapped exception arguments. + :type args: :obj:`tuple` + """ + self.errno = 0 if hasattr(orig_exception, 'errno'): self.errno = orig_exception.errno @@ -220,13 +244,25 @@ def __init__(self, orig_exception=None, *args): class NetworkWarning(UserWarning): - '''Warning related to network''' + """ + Warning related to network. + """ pass class SslError(DatabaseError): - '''Error related to SSL''' + """ + Error related to SSL. + """ def __init__(self, orig_exception=None, *args): + """ + :param orig_exception: Exception to wrap. + :type orig_exception: optional + + :param args: Wrapped exception arguments. + :type args: :obj:`tuple` + """ + self.errno = 0 if hasattr(orig_exception, 'errno'): self.errno = orig_exception.errno @@ -238,22 +274,34 @@ def __init__(self, orig_exception=None, *args): class ClusterDiscoveryWarning(UserWarning): - '''Warning related to cluster discovery''' + """ + Warning related to cluster discovery. + """ pass class ClusterConnectWarning(UserWarning): - '''Warning related to cluster pool connection''' + """ + Warning related to cluster pool connection. + """ pass class PoolTolopogyWarning(UserWarning): - '''Warning related to ro/rw cluster pool topology''' + """ + Warning related to unsatisfying `box.info.ro`_ state of + pool instances. + """ pass class PoolTolopogyError(DatabaseError): - '''Exception raised due to unsatisfying ro/rw cluster pool topology''' + """ + Exception raised due to unsatisfying `box.info.ro`_ state of + pool instances. + + .. _box.info.ro: https://www.tarantool.io/en/doc/latest/reference/reference_lua/box_info/ + """ pass @@ -262,10 +310,17 @@ class PoolTolopogyError(DatabaseError): def warn(message, warning_class): - ''' - Emit warinig message. + """ + Emit a warning message. Just like standard warnings.warn() but don't output full filename. - ''' + + :param message: Warning message. + :type message: :obj:`str` + + :param warning_class: Warning class. + :type warning_class: :class:`~tarantool.error.Warning` + """ + frame = sys._getframe(2) # pylint: disable=W0212 module_name = frame.f_globals.get("__name__") line_no = frame.f_lineno @@ -449,6 +504,16 @@ def warn(message, warning_class): def tnt_strerror(num): + """ + Parse Tarantool error to string data. + + :param num: Tarantool error code. + :type num: :obj:`int` + + :return: Tuple ``(ER_NAME, message)`` or ``"UNDEFINED"``. + :rtype: :obj:`tuple` or :obj:`str` + """ + if num in _strerror: return _strerror[num] return "UNDEFINED" diff --git a/tarantool/mesh_connection.py b/tarantool/mesh_connection.py index 33387fd2..7ce89570 100644 --- a/tarantool/mesh_connection.py +++ b/tarantool/mesh_connection.py @@ -1,7 +1,6 @@ -''' -This module provides the MeshConnection class with automatic switch -between Tarantool instances by the basic round-robin strategy. -''' +""" +This module provides API for interaction with Tarantool servers cluster. +""" import time @@ -42,6 +41,19 @@ def parse_uri(uri): + """ + Parse URI received from cluster discovery function. + + :param uri: URI received from cluster discovery function + :type uri: :obj:`str` + + :return: First value: `{"host": host, "port": port}` or ``None`` in + case of fail, second value: ``None`` or error message in case of + fail. + :rtype: first value: :obj:`dict` or ``None``, + second value: ``None`` or :obj:`str` + """ + # TODO: Support Unix sockets. def parse_error(uri, msg): msg = 'URI "%s": %s' % (uri, msg) @@ -87,6 +99,20 @@ def parse_error(uri, msg): def prepare_address(address): + """ + Validate address dictionary, fill with default values. + For format refer to + :paramref:`~tarantool.ConnectionPool.params.addrs`. + + :param address: Address dictionary. + :type address: :obj:`dict` + + :return: Address dictionary or ``None`` in case of failure, second + value: ``None`` or error message in case of failure. + :rtype: first value: :obj:`dict` or ``None``, + second value: ``None`` or :obj:`str` + """ + def format_error(address, err): return None, 'Address %s: %s' % (str(address), err) @@ -145,6 +171,16 @@ def format_error(address, err): def update_connection(conn, address): + """ + Update connection info after rotation. + + :param conn: Connection mesh to update. + :type conn: :class:`~tarantool.MeshConnection` + + :param address: New active connection address. + :type address: :obj:`dict` + """ + conn.host = address["host"] conn.port = address["port"] conn.transport = address['transport'] @@ -156,12 +192,25 @@ def update_connection(conn, address): class RoundRobinStrategy(object): """ - Simple round-robin address rotation + Defines strategy to choose next pool server after fail. """ + def __init__(self, addrs): + """ + :param addrs: Server addresses list, refer to + :paramref:`~tarantool.ConnectionPool.params.addrs`. + :type addrs: :obj:`list` of :obj:`dict` + """ self.update(addrs) def update(self, new_addrs): + """ + Refresh the strategy state with new addresses. + + :param new_addrs: Updated server addresses list. + :type addrs: :obj:`list` of :obj:`dict` + """ + # Verify new_addrs is a non-empty list. assert new_addrs and isinstance(new_addrs, list) @@ -189,64 +238,21 @@ def update(self, new_addrs): self.pos = new_pos def getnext(self): + """ + Get next cluster server. + + :return: Server address. + :rtype: :obj:`dict` + """ + self.pos = (self.pos + 1) % len(self.addrs) return self.addrs[self.pos] class MeshConnection(Connection): - ''' - Represents a connection to a cluster of Tarantool servers. - - This class uses Connection to connect to one of the nodes of the cluster. - The initial list of nodes is passed to the constructor in the - 'addrs' parameter. The class set in the 'strategy_class' parameter - is used to select a node from the list and switch nodes in case the - current node is unavailable. - - The 'cluster_discovery_function' param of the constructor sets the name - of the stored Lua function used to refresh the list of available nodes. - The function takes no parameters and returns a list of strings in the - format 'host:port'. A generic function for getting the list of nodes - looks like this: - - .. code-block:: lua - - function get_cluster_nodes() - return { - '192.168.0.1:3301', - '192.168.0.2:3302?transport=ssl&ssl_ca_file=/path/to/ca.cert', - -- ... - } - end - - You can put in this list whatever you need, depending on your - cluster topology. Chances are you'll want to derive the list of nodes - from the nodes' replication configuration. Here is an example: - - .. code-block:: lua - - local uri_lib = require('uri') - - function get_cluster_nodes() - local nodes = {} - - local replicas = box.cfg.replication - - for i = 1, #replicas do - local uri = uri_lib.parse(replicas[i]) - - if uri.host and uri.service then - table.insert(nodes, uri.host .. ':' .. uri.service) - end - end - - -- if your replication config doesn't contain the current node, - -- you have to add it manually like this: - table.insert(nodes, '192.168.0.1:3301') - - return nodes - end - ''' + """ + Represents a connection to a cluster of Tarantool servers. + """ def __init__(self, host=None, port=None, user=None, @@ -267,6 +273,147 @@ def __init__(self, host=None, port=None, strategy_class=RoundRobinStrategy, cluster_discovery_function=None, cluster_discovery_delay=CLUSTER_DISCOVERY_DELAY): + """ + :param host: Refer to + :paramref:`~tarantool.Connection.params.host`. + Value would be used to add one more server in + :paramref:`~tarantool.MeshConnection.params.addrs` list. + + :param port: Refer to + :paramref:`~tarantool.Connection.params.host`. + Value would be used to add one more server in + :paramref:`~tarantool.MeshConnection.params.addrs` list. + + :param user: Refer to + :paramref:`~tarantool.Connection.params.user`. + Value would be used to add one more server in + :paramref:`~tarantool.MeshConnection.params.addrs` list. + + :param password: Refer to + :paramref:`~tarantool.Connection.params.password`. + Value would be used to add one more server in + :paramref:`~tarantool.MeshConnection.params.addrs` list. + + :param socket_timeout: Refer to + :paramref:`~tarantool.Connection.params.socket_timeout`. + Value would be used for the current active connection. + + :param reconnect_max_attempts: Refer to + :paramref:`~tarantool.Connection.params.reconnect_max_attempts`. + Value would be used for the current active connection. + + :param reconnect_delay: Refer to + :paramref:`~tarantool.Connection.params.reconnect_delay`. + Value would be used for the current active connection. + + :param connect_now: If ``True``, connect to server on + initialization. Otherwise, you have to call + :meth:`~tarantool.MeshConnection.connect` manually after + initialization. + :type connect_now: :obj:`bool`, optional + + :param encoding: Refer to + :paramref:`~tarantool.Connection.params.encoding`. + Value would be used for the current active connection. + + :param call_16: Refer to + :paramref:`~tarantool.Connection.params.call_16`. + Value would be used for the current active connection. + + :param connection_timeout: Refer to + :paramref:`~tarantool.Connection.params.connection_timeout`. + Value would be used for the current active connection. + + :param transport: Refer to + :paramref:`~tarantool.Connection.params.transport`. + Value would be used to add one more server in + :paramref:`~tarantool.MeshConnection.params.addrs` list. + + :param ssl_key_file: Refer to + :paramref:`~tarantool.Connection.params.ssl_key_file`. + Value would be used to add one more server in + :paramref:`~tarantool.MeshConnection.params.addrs` list. + + :param ssl_cert_file: Refer to + :paramref:`~tarantool.Connection.params.ssl_cert_file`. + Value would be used to add one more server in + :paramref:`~tarantool.MeshConnection.params.addrs` list. + + :param ssl_ca_file: Refer to + :paramref:`~tarantool.Connection.params.ssl_ca_file`. + Value would be used to add one more server in + :paramref:`~tarantool.MeshConnection.params.addrs` list. + + :param ssl_ciphers: Refer to + :paramref:`~tarantool.Connection.params.ssl_ciphers`. + Value would be used to add one more server in + :paramref:`~tarantool.MeshConnection.params.addrs` list. + + :param addrs: Cluster servers addresses list. Refer to + :paramref:`~tarantool.ConnectionPool.params.addrs`. + + :param strategy_class: Strategy for choosing a server after + the current server fails. Defaults to the round-robin + strategy. + :type strategy_class: :obj:`object`, optional + + :param cluster_discovery_function: sets the name of the stored + Lua function used to refresh the list of available nodes. + The function takes no parameters and returns a list of + strings in the format ``'host:port'``. A generic function + for getting the list of nodes looks like this: + + .. code-block:: lua + + function get_cluster_nodes() + return { + '192.168.0.1:3301', + '192.168.0.2:3302?transport=ssl&ssl_ca_file=/path/to/ca.cert', + -- ... + } + end + + You can put in this list whatever you need, depending on + your cluster topology. Chances are you'll want to derive + the list of nodes from the nodes' replication configuration. + Here is an example: + + .. code-block:: lua + + local uri_lib = require('uri') + + function get_cluster_nodes() + local nodes = {} + + local replicas = box.cfg.replication + + for i = 1, #replicas do + local uri = uri_lib.parse(replicas[i]) + + if uri.host and uri.service then + table.insert(nodes, uri.host .. ':' .. uri.service) + end + end + + -- if your replication config doesn't contain the current node, + -- you have to add it manually like this: + table.insert(nodes, '192.168.0.1:3301') + + return nodes + end + + :type cluster_discovery_function: :obj:`str` or :obj:`None`, + optional + + :param cluster_discovery_delay: Minimal time between address + list refresh. + :type cluster_discovery_delay: :obj:`float`, optional + + :raises: :exc:`~tarantool.error.ConfigurationError`, + :class:`~tarantool.Connection` exceptions, + :class:`~tarantool.MeshConnection.connect` exceptions + """ + if addrs is None: addrs = [] else: @@ -323,15 +470,29 @@ def __init__(self, host=None, port=None, ssl_ciphers=addr['ssl_ciphers']) def connect(self): + """ + Create a connection to some server in the cluster. Refresh + addresses info after success. There is no need to call this + method explicitly until you have set ``connect_now=False`` on + initialization. + + :raise: :exc:`~AssertionError`, + :exc:`~tarantool.error.SchemaError`, + :exc:`~tarantool.error.NetworkError`, + :class:`~tarantool.Connection.connect` exceptions + """ super(MeshConnection, self).connect() if self.connected and self.cluster_discovery_function: self._opt_refresh_instances() def _opt_reconnect(self): - ''' - Attempt to connect "reconnect_max_attempts" times to each - available address. - ''' + """ + Attempt to connect + :paramref:`~tarantool.MeshConnection.reconnect_max_attempts` + times to each available address. + + :raise: :class:`~tarantool.Connection.connect` exceptions + """ last_error = None for _ in range(len(self.strategy.addrs)): @@ -348,10 +509,16 @@ def _opt_reconnect(self): raise last_error def _opt_refresh_instances(self): - ''' - Refresh the list of tarantool instances in a cluster. + """ + Refresh the list of Tarantool instances in a cluster. Reconnect if the current instance has disappeared from the list. - ''' + + :raise: :exc:`~AssertionError`, + :exc:`~tarantool.error.SchemaError`, + :exc:`~tarantool.error.NetworkError`, + :class:`~tarantool.MeshConnection._opt_reconnect` exceptions + """ + now = time.time() if not self.connected or not self.cluster_discovery_function or \ @@ -415,18 +582,18 @@ def _opt_refresh_instances(self): self._opt_reconnect() def _send_request(self, request): - ''' - Update the instances list if `cluster_discovery_function` - is provided and the last update was more than - `cluster_discovery_delay` seconds ago. + """ + Send a request to a Tarantool server. If required, refresh + addresses list before sending a request. + + :param request: Request to send. + :type request: :class:`~tarantool.request.Request` - After that, perform a request as usual and return an instance of - the `Response` class. + :rtype: :class:`~tarantool.response.Response` - :param request: object representing a request - :type request: `Request` instance + :raise: :class:`~tarantool.MeshConnection._opt_reconnect` exceptions, + :class:`~tarantool.Connection._send_request` exceptions + """ - :rtype: `Response` instance - ''' self._opt_refresh_instances() return super(MeshConnection, self)._send_request(request) diff --git a/tarantool/msgpack_ext/datetime.py b/tarantool/msgpack_ext/datetime.py index 70f56dc9..e47f162e 100644 --- a/tarantool/msgpack_ext/datetime.py +++ b/tarantool/msgpack_ext/datetime.py @@ -1,9 +1,44 @@ +""" +Tarantool `datetime`_ extension type support module. + +Refer to :mod:`~tarantool.msgpack_ext.types.datetime`. + +.. _datetime: https://www.tarantool.io/en/doc/latest/dev_guide/internals/msgpack_extensions/#the-datetime-type +""" + from tarantool.msgpack_ext.types.datetime import Datetime EXT_ID = 4 +""" +`datetime`_ type id. +""" def encode(obj): + """ + Encode a datetime object. + + :param obj: Datetime to encode. + :type: :obj: :class:`tarantool.Datetime` + + :return: Encoded datetime. + :rtype: :obj:`bytes` + + :raise: :exc:`tarantool.Datetime.msgpack_encode` exceptions + """ + return obj.msgpack_encode() def decode(data): + """ + Decode a datetime object. + + :param obj: Datetime to decode. + :type obj: :obj:`bytes` + + :return: Decoded datetime. + :rtype: :class:`tarantool.Datetime` + + :raise: :exc:`tarantool.Datetime` exceptions + """ + return Datetime(data) diff --git a/tarantool/msgpack_ext/decimal.py b/tarantool/msgpack_ext/decimal.py index 616024b1..80e40051 100644 --- a/tarantool/msgpack_ext/decimal.py +++ b/tarantool/msgpack_ext/decimal.py @@ -1,53 +1,85 @@ +""" +Tarantool `decimal`_ extension type support module. + +The decimal MessagePack representation looks like this: + +.. code-block:: text + + +--------+-------------------+------------+===============+ + | MP_EXT | length (optional) | MP_DECIMAL | PackedDecimal | + +--------+-------------------+------------+===============+ + +``PackedDecimal`` has the following structure: + +.. code-block:: text + + <--- length bytes --> + +-------+=============+ + | scale | BCD | + +-------+=============+ + +Here the scale is either `mp_int`_ or `mp_uint`_. Scale is the number +of digits after the decimal point + +BCD is a sequence of bytes representing decimal digits of the encoded +number (each byte has two decimal digits each encoded using 4-bit +nibbles), so ``byte >> 4`` is the first digit and ``byte & 0x0f`` is +the second digit. The leftmost digit in the array is the most +significant. The rightmost digit in the array is the least significant. + +The first byte of the ``BCD`` array contains the first digit of the number, +represented as follows: + +.. code-block:: text + + | 4 bits | 4 bits | + = 0x = the 1st digit + +(The first nibble contains ``0`` if the decimal number has an even +number of digits.) The last byte of the ``BCD`` array contains the last +digit of the number and the final nibble, represented as follows: + +.. code-block:: text + + | 4 bits | 4 bits | + = the last digit = nibble + +The final nibble represents the number’s sign: + +* ``0x0a``, ``0x0c``, ``0x0e``, ``0x0f`` stand for plus, +* ``0x0b`` and ``0x0d`` stand for minus. + +.. _decimal: https://www.tarantool.io/en/doc/latest/dev_guide/internals/msgpack_extensions/#the-decimal-type +.. _mp_int: +.. _mp_uint: https://github.com/msgpack/msgpack/blob/master/spec.md#int-format-family +""" + from decimal import Decimal from tarantool.error import MsgpackError, MsgpackWarning, warn -# https://www.tarantool.io/en/doc/latest/dev_guide/internals/msgpack_extensions/#the-decimal-type -# -# The decimal MessagePack representation looks like this: -# +--------+-------------------+------------+===============+ -# | MP_EXT | length (optional) | MP_DECIMAL | PackedDecimal | -# +--------+-------------------+------------+===============+ -# -# PackedDecimal has the following structure: -# -# <--- length bytes --> -# +-------+=============+ -# | scale | BCD | -# +-------+=============+ -# -# Here scale is either MP_INT or MP_UINT. -# scale = number of digits after the decimal point -# -# BCD is a sequence of bytes representing decimal digits of the encoded number -# (each byte has two decimal digits each encoded using 4-bit nibbles), so -# byte >> 4 is the first digit and byte & 0x0f is the second digit. The -# leftmost digit in the array is the most significant. The rightmost digit in -# the array is the least significant. -# -# The first byte of the BCD array contains the first digit of the number, -# represented as follows: -# -# | 4 bits | 4 bits | -# = 0x = the 1st digit -# -# (The first nibble contains 0 if the decimal number has an even number of -# digits.) The last byte of the BCD array contains the last digit of the number -# and the final nibble, represented as follows: -# -# | 4 bits | 4 bits | -# = the last digit = nibble -# -# The final nibble represents the number’s sign: -# -# 0x0a, 0x0c, 0x0e, 0x0f stand for plus, -# 0x0b and 0x0d stand for minus. - EXT_ID = 1 +""" +`decimal`_ type id. +""" TARANTOOL_DECIMAL_MAX_DIGITS = 38 def get_mp_sign(sign): + """ + Parse decimal sign to a nibble. + + :param sign: ``'+`` or ``'-'`` symbol. + :type sign: :obj:`str` + + :return: Decimal sigh nibble. + :rtype: :obj:`int` + + :raise: :exc:`RuntimeError` + + :meta private: + """ + if sign == '+': return 0x0c @@ -57,51 +89,91 @@ def get_mp_sign(sign): raise RuntimeError def add_mp_digit(digit, bytes_reverted, digit_count): + """ + Append decimal digit to a binary data array. + + :param digit: Digit to add. + :type digit: :obj:`int` + + :param bytes_reverted: Reverted array with binary data. + :type bytes_reverted: :obj:`bytearray` + + :param digit_count: Current digit count. + :type digit_count: :obj:`int` + + :meta private: + """ + if digit_count % 2 == 0: bytes_reverted[-1] = bytes_reverted[-1] | (digit << 4) else: bytes_reverted.append(digit) def check_valid_tarantool_decimal(str_repr, scale, first_digit_ind): -# Decimal numbers have 38 digits of precision, that is, the total number of -# digits before and after the decimal point can be 38. If there are more -# digits arter the decimal point, the precision is lost. If there are more -# digits before the decimal point, error is thrown. -# -# Tarantool 2.10.1-0-g482d91c66 -# -# tarantool> decimal.new('10000000000000000000000000000000000000') -# --- -# - 10000000000000000000000000000000000000 -# ... -# -# tarantool> decimal.new('100000000000000000000000000000000000000') -# --- -# - error: '[string "return VERSION"]:1: variable ''VERSION'' is not declared' -# ... -# -# tarantool> decimal.new('1.0000000000000000000000000000000000001') -# --- -# - 1.0000000000000000000000000000000000001 -# ... -# -# tarantool> decimal.new('1.00000000000000000000000000000000000001') -# --- -# - 1.0000000000000000000000000000000000000 -# ... -# -# In fact, there is also an exceptional case: if decimal starts with `0.`, -# 38 digits after the decimal point are supported without the loss of precision. -# -# tarantool> decimal.new('0.00000000000000000000000000000000000001') -# --- -# - 0.00000000000000000000000000000000000001 -# ... -# -# tarantool> decimal.new('0.000000000000000000000000000000000000001') -# --- -# - 0.00000000000000000000000000000000000000 -# ... + """ + Decimal numbers have 38 digits of precision, that is, the total + number of digits before and after the decimal point can be 38. If + there are more digits arter the decimal point, the precision is + lost. If there are more digits before the decimal point, error is + thrown (Tarantool 2.10.1-0-g482d91c66). + + .. code-block:: lua + + tarantool> decimal.new('10000000000000000000000000000000000000') + --- + - 10000000000000000000000000000000000000 + ... + + tarantool> decimal.new('100000000000000000000000000000000000000') + --- + - error: incorrect value to convert to decimal as 1 argument + ... + + tarantool> decimal.new('1.0000000000000000000000000000000000001') + --- + - 1.0000000000000000000000000000000000001 + ... + + tarantool> decimal.new('1.00000000000000000000000000000000000001') + --- + - 1.0000000000000000000000000000000000000 + ... + + In fact, there is also an exceptional case: if decimal starts with + ``0.``, 38 digits after the decimal point are supported without the + loss of precision. + + .. code-block:: lua + + tarantool> decimal.new('0.00000000000000000000000000000000000001') + --- + - 0.00000000000000000000000000000000000001 + ... + + tarantool> decimal.new('0.000000000000000000000000000000000000001') + --- + - 0.00000000000000000000000000000000000000 + ... + + :param str_repr: Decimal string representation. + :type str_repr: :obj:`str` + + :param scale: Decimal scale. + :type scale: :obj:`int` + + :param first_digit_ind: Index of the first digit in decimal string + representation. + :type first_digit_ind: :obj:`int` + + :return: ``True``, if decimal can be encoded to Tarantool decimal + without precision loss. ``False`` otherwise. + :rtype: :obj:`bool` + + :raise: :exc:`~tarantool.error.MsgpackError` + + :meta private: + """ + if scale > 0: digit_count = len(str_repr) - 1 - first_digit_ind else: @@ -127,6 +199,23 @@ def check_valid_tarantool_decimal(str_repr, scale, first_digit_ind): return True def strip_decimal_str(str_repr, scale, first_digit_ind): + """ + Strip decimal digits after the decimal point if decimal cannot be + represented as Tarantool decimal without precision loss. + + :param str_repr: Decimal string representation. + :type str_repr: :obj:`str` + + :param scale: Decimal scale. + :type scale: :obj:`int` + + :param first_digit_ind: Index of the first digit in decimal string + representation. + :type first_digit_ind: :obj:`int` + + :meta private: + """ + assert scale > 0 # Strip extra bytes str_repr = str_repr[:TARANTOOL_DECIMAL_MAX_DIGITS + 1 + first_digit_ind] @@ -137,6 +226,18 @@ def strip_decimal_str(str_repr, scale, first_digit_ind): return str_repr def encode(obj): + """ + Encode a decimal object. + + :param obj: Decimal to encode. + :type obj: :obj:`decimal.Decimal` + + :return: Encoded decimal. + :rtype: :obj:`bytes` + + :raise: :exc:`~tarantool.error.MsgpackError` + """ + # Non-scientific string with trailing zeroes removed str_repr = format(obj, 'f') @@ -186,6 +287,20 @@ def encode(obj): def get_str_sign(nibble): + """ + Parse decimal sign nibble to a symbol. + + :param nibble: Decimal sign nibble. + :type nibble: :obj:`int` + + :return: ``'+`` or ``'-'`` symbol. + :rtype: :obj:`str` + + :raise: :exc:`MsgpackError` + + :meta private: + """ + if nibble == 0x0a or nibble == 0x0c or nibble == 0x0e or nibble == 0x0f: return '+' @@ -195,6 +310,23 @@ def get_str_sign(nibble): raise MsgpackError('Unexpected MP_DECIMAL sign nibble') def add_str_digit(digit, digits_reverted, scale): + """ + Append decimal digit to a binary data array. + + :param digit: Digit to add. + :type digit: :obj:`int` + + :param digits_reverted: Reverted decimal string. + :type digits_reverted: :obj:`str` + + :param scale: Decimal scale. + :type scale: :obj:`int` + + :raise: :exc:`~tarantool.error.MsgpackError` + + :meta private: + """ + if not (0 <= digit <= 9): raise MsgpackError('Unexpected MP_DECIMAL digit nibble') @@ -204,6 +336,18 @@ def add_str_digit(digit, digits_reverted, scale): digits_reverted.append(str(digit)) def decode(data): + """ + Decode a decimal object. + + :param obj: Decimal to decode. + :type obj: :obj:`bytes` + + :return: Decoded decimal. + :rtype: :obj:`decimal.Decimal` + + :raise: :exc:`~tarantool.error.MsgpackError` + """ + scale = data[0] sign = get_str_sign(data[-1] & 0x0f) diff --git a/tarantool/msgpack_ext/interval.py b/tarantool/msgpack_ext/interval.py index 79b5a8de..20a791ef 100644 --- a/tarantool/msgpack_ext/interval.py +++ b/tarantool/msgpack_ext/interval.py @@ -1,9 +1,44 @@ +""" +Tarantool `datetime.interval`_ extension type support module. + +Refer to :mod:`~tarantool.msgpack_ext.types.interval`. + +.. _datetime.interval: https://www.tarantool.io/en/doc/latest/dev_guide/internals/msgpack_extensions/#the-interval-type +""" + from tarantool.msgpack_ext.types.interval import Interval EXT_ID = 6 +""" +`datetime.interval`_ type id. +""" def encode(obj): + """ + Encode an interval object. + + :param obj: Interval to encode. + :type: :obj: :class:`tarantool.Interval` + + :return: Encoded interval. + :rtype: :obj:`bytes` + + :raise: :exc:`tarantool.Interval.msgpack_encode` exceptions + """ + return obj.msgpack_encode() def decode(data): + """ + Decode an interval object. + + :param obj: Interval to decode. + :type obj: :obj:`bytes` + + :return: Decoded interval. + :rtype: :class:`tarantool.Interval` + + :raise: :exc:`tarantool.Interval` exceptions + """ + return Interval(data) diff --git a/tarantool/msgpack_ext/packer.py b/tarantool/msgpack_ext/packer.py index d41c411d..4706496f 100644 --- a/tarantool/msgpack_ext/packer.py +++ b/tarantool/msgpack_ext/packer.py @@ -1,3 +1,9 @@ +""" +Tarantool `extension`_ types encoding support. + +.. _extension: https://www.tarantool.io/en/doc/latest/dev_guide/internals/msgpack_extensions/ +""" + from decimal import Decimal from uuid import UUID from msgpack import ExtType @@ -18,6 +24,19 @@ ] def default(obj): + """ + :class:`msgpack.Packer` encoder. + + :param obj: Object to encode. + :type obj: :class:`decimal.Decimal` or :class:`uuid.UUID` or + :class:`tarantool.Datetime` or :class:`tarantool.Interval` + + :return: Encoded value. + :rtype: :class:`msgpack.ExtType` + + :raise: :exc:`~TypeError` + """ + for encoder in encoders: if isinstance(obj, encoder['type']): return ExtType(encoder['ext'].EXT_ID, encoder['ext'].encode(obj)) diff --git a/tarantool/msgpack_ext/types/datetime.py b/tarantool/msgpack_ext/types/datetime.py index d89c1cae..d84352dd 100644 --- a/tarantool/msgpack_ext/types/datetime.py +++ b/tarantool/msgpack_ext/types/datetime.py @@ -1,3 +1,43 @@ +""" +Tarantool `datetime`_ extension type support module. + +The datetime MessagePack representation looks like this: + +.. code-block:: text + + +---------+----------------+==========+-----------------+ + | MP_EXT | MP_DATETIME | seconds | nsec; tzoffset; | + | = d7/d8 | = 4 | | tzindex; | + +---------+----------------+==========+-----------------+ + +MessagePack data contains: + +* Seconds (8 bytes) as an unencoded 64-bit signed integer stored in the + little-endian order. +* The optional fields (8 bytes), if any of them have a non-zero value. + The fields include nsec (4 bytes), tzoffset (2 bytes), and + tzindex (2 bytes) packed in the little-endian order. + +``seconds`` is seconds since Epoch, where the epoch is the point where +the time starts, and is platform dependent. For Unix, the epoch is +January 1, 1970, 00:00:00 (UTC). Tarantool uses a ``double`` type, see a +structure definition in src/lib/core/datetime.h and reasons in +`datetime RFC`_. + +``nsec`` is nanoseconds, fractional part of seconds. Tarantool uses +``int32_t``, see a definition in src/lib/core/datetime.h. + +``tzoffset`` is timezone offset in minutes from UTC. Tarantool uses +``int16_t`` type, see a structure definition in src/lib/core/datetime.h. + +``tzindex`` is Olson timezone id. Tarantool uses ``int16_t`` type, see +a structure definition in src/lib/core/datetime.h. If both +``tzoffset`` and ``tzindex`` are specified, ``tzindex`` has the +preference and the ``tzoffset`` value is ignored. + +.. _datetime RFC: https://github.com/tarantool/tarantool/wiki/Datetime-internals#intervals-in-c +""" + from copy import deepcopy import pandas @@ -8,37 +48,6 @@ from tarantool.msgpack_ext.types.interval import Interval, Adjust -# https://www.tarantool.io/en/doc/latest/dev_guide/internals/msgpack_extensions/#the-datetime-type -# -# The datetime MessagePack representation looks like this: -# +---------+----------------+==========+-----------------+ -# | MP_EXT | MP_DATETIME | seconds | nsec; tzoffset; | -# | = d7/d8 | = 4 | | tzindex; | -# +---------+----------------+==========+-----------------+ -# MessagePack data contains: -# -# * Seconds (8 bytes) as an unencoded 64-bit signed integer stored in the -# little-endian order. -# * The optional fields (8 bytes), if any of them have a non-zero value. -# The fields include nsec (4 bytes), tzoffset (2 bytes), and -# tzindex (2 bytes) packed in the little-endian order. -# -# seconds is seconds since Epoch, where the epoch is the point where the time -# starts, and is platform dependent. For Unix, the epoch is January 1, -# 1970, 00:00:00 (UTC). Tarantool uses a double type, see a structure -# definition in src/lib/core/datetime.h and reasons in -# https://github.com/tarantool/tarantool/wiki/Datetime-internals#intervals-in-c -# -# nsec is nanoseconds, fractional part of seconds. Tarantool uses int32_t, see -# a definition in src/lib/core/datetime.h. -# -# tzoffset is timezone offset in minutes from UTC. Tarantool uses a int16_t type, -# see a structure definition in src/lib/core/datetime.h. -# -# tzindex is Olson timezone id. Tarantool uses a int16_t type, see a structure -# definition in src/lib/core/datetime.h. If both tzoffset and tzindex are -# specified, tzindex has the preference and the tzoffset value is ignored. - SECONDS_SIZE_BYTES = 8 NSEC_SIZE_BYTES = 4 TZOFFSET_SIZE_BYTES = 2 @@ -52,13 +61,61 @@ MONTH_IN_YEAR = 12 def get_bytes_as_int(data, cursor, size): + """ + Get integer value from binary data. + + :param data: MessagePack binary data. + :type data: :obj:`bytes` + + :param cursor: Index after last parsed byte. + :type cursor: :obj:`int` + + :param size: Integer size, in bytes. + :type size: :obj:`int` + + :return: First value: parsed integer, second value: new cursor + position. + :rtype: first value: :obj:`int`, second value: :obj:`int` + + :meta private: + """ + part = data[cursor:cursor + size] return int.from_bytes(part, BYTEORDER, signed=True), cursor + size def get_int_as_bytes(data, size): + """ + Get binary representation of integer value. + + :param data: Integer value. + :type data: :obj:`int` + + :param size: Integer size, in bytes. + :type size: :obj:`int` + + :return: Encoded integer. + :rtype: :obj:`bytes` + + :meta private: + """ + return data.to_bytes(size, byteorder=BYTEORDER, signed=True) def compute_offset(timestamp): + """ + Compute timezone offset. Offset is computed each time and not stored + since it could depend on current datetime value. It is expected that + timestamp offset is not ``None``. + + :param timestamp: Timestamp data. + :type timestamp: :class:`pandas.Timestamp` + + :return: Timezone offset, in minutes. + :rtype: :obj:`int` + + :meta private: + """ + utc_offset = timestamp.tzinfo.utcoffset(timestamp) # `None` offset is a valid utcoffset implementation, @@ -70,6 +127,28 @@ def compute_offset(timestamp): return int(utc_offset.total_seconds()) // SEC_IN_MIN def get_python_tzinfo(tz, error_class): + """ + All non-abbreviated Tarantool timezones are represented as pytz + timezones (from :func:`pytz.timezone`). All non-ambiguous + abbreviated Tarantool timezones are represented as + :class:`pytz.FixedOffset` timezones. Attempt to build timezone + info for ambiguous timezone results in raising the exception, same + as in Tarantool. + + :param tz: Tarantool timezone name. + :type tz: :obj:`str` + + :param error_class: Error class to raise in case of fail. + :type error_class: :obj:`Exception` + + :return: Timezone object. + :rtype: :func:`pytz.timezone` result or :class:`pytz.FixedOffset` + + :raise: :exc:`~tarantool.msgpack_ext.types.datetime.get_python_tzinfo.params.error_class` + + :meta private: + """ + if tz in pytz.all_timezones: return pytz.timezone(tz) @@ -81,6 +160,23 @@ def get_python_tzinfo(tz, error_class): return pytz.FixedOffset(tt_tzinfo['offset']) def msgpack_decode(data): + """ + Decode MsgPack binary data to useful timestamp and timezone data. + For internal use of :class:`~tarantool.Datetime`. + + :param data: MessagePack binary data to decode. + :type data: :obj:`bytes` + + :return: First value: timestamp data with timezone info, second + value: Tarantool timezone name. + :rtype: first value: :class:`pandas.Timestamp`, second value: + :obj:`str` + + :raises: :exc:`~tarantool.error.MsgpackError` + + :meta private: + """ + cursor = 0 seconds, cursor = get_bytes_as_int(data, cursor, SECONDS_SIZE_BYTES) @@ -113,9 +209,145 @@ def msgpack_decode(data): return datetime, '' class Datetime(): + """ + Class representing Tarantool `datetime`_ info. Internals are based + on :class:`pandas.Timestamp`. + + You can create :class:`~tarantool.Datetime` objects either from + MessagePack data or by using the same API as in Tarantool: + + .. code-block:: python + + dt1 = tarantool.Datetime(year=2022, month=8, day=31, + hour=18, minute=7, sec=54, + nsec=308543321) + + dt2 = tarantool.Datetime(timestamp=1661969274) + + dt3 = tarantool.Datetime(timestamp=1661969274, nsec=308543321) + + :class:`~tarantool.Datetime` exposes + :attr:`~tarantool.Datetime.year`, + :attr:`~tarantool.Datetime.month`, + :attr:`~tarantool.Datetime.day`, + :attr:`~tarantool.Datetime.hour`, + :attr:`~tarantool.Datetime.minute`, + :attr:`~tarantool.Datetime.sec`, + :attr:`~tarantool.Datetime.nsec`, + :attr:`~tarantool.Datetime.timestamp` and + :attr:`~tarantool.Datetime.value` (integer epoch time with + nanoseconds precision) properties if you need to convert + :class:`~tarantool.Datetime` to any other kind of datetime object: + + .. code-block:: python + + pdt = pandas.Timestamp(year=dt.year, month=dt.month, day=dt.day, + hour=dt.hour, minute=dt.minute, second=dt.sec, + microsecond=(dt.nsec // 1000), + nanosecond=(dt.nsec % 1000)) + + Use :paramref:`~tarantool.Datetime.params.tzoffset` parameter to set + up offset timezone: + + .. code-block:: python + + dt = tarantool.Datetime(year=2022, month=8, day=31, + hour=18, minute=7, sec=54, + nsec=308543321, tzoffset=180) + + You may use the :attr:`~tarantool.Datetime.tzoffset` property to + get the timezone offset of a datetime object. + + Use :paramref:`~tarantool.Datetime.params.tz` parameter to set up + timezone name: + + .. code-block:: python + + dt = tarantool.Datetime(year=2022, month=8, day=31, + hour=18, minute=7, sec=54, + nsec=308543321, tz='Europe/Moscow') + + If both :paramref:`~tarantool.Datetime.params.tz` and + :paramref:`~tarantool.Datetime.params.tzoffset` are specified, + :paramref:`~tarantool.Datetime.params.tz` is used. + + You may use the :attr:`~tarantool.Datetime.tz` property to get + the timezone name of a datetime object. + + .. _datetime: https://www.tarantool.io/en/doc/latest/dev_guide/internals/msgpack_extensions/#the-datetime-type + """ + def __init__(self, data=None, *, timestamp=None, year=None, month=None, day=None, hour=None, minute=None, sec=None, nsec=None, tzoffset=0, tz=''): + """ + :param data: MessagePack binary data to decode. If provided, + all other parameters are ignored. + :type data: :obj:`bytes`, optional + + :param timestamp: Timestamp since epoch. Cannot be provided + together with + :paramref:`~tarantool.Datetime.params.year`, + :paramref:`~tarantool.Datetime.params.month`, + :paramref:`~tarantool.Datetime.params.day`, + :paramref:`~tarantool.Datetime.params.hour`, + :paramref:`~tarantool.Datetime.params.minute`, + :paramref:`~tarantool.Datetime.params.sec`. + If :paramref:`~tarantool.Datetime.params.nsec` is provided, + it must be :obj:`int`. + :type timestamp: :obj:`float` or :obj:`int`, optional + + :param year: Datetime year value. Must be a valid + :class:`pandas.Timestamp` ``year`` parameter. + Must be provided unless the object is built with + :paramref:`~tarantool.Datetime.params.data` or + :paramref:`~tarantool.Datetime.params.timestamp`. + :type year: :obj:`int`, optional + + :param month: Datetime month value. Must be a valid + :class:`pandas.Timestamp` ``month`` parameter. + Must be provided unless the object is built with + :paramref:`~tarantool.Datetime.params.data` or + :paramref:`~tarantool.Datetime.params.timestamp`. + :type month: :obj:`int`, optional + + :param day: Datetime day value. Must be a valid + :class:`pandas.Timestamp` ``day`` parameter. + Must be provided unless the object is built with + :paramref:`~tarantool.Datetime.params.data` or + :paramref:`~tarantool.Datetime.params.timestamp`. + :type day: :obj:`int`, optional + + :param hour: Datetime hour value. Must be a valid + :class:`pandas.Timestamp` ``hour`` parameter. + :type hour: :obj:`int`, optional + + :param minute: Datetime minute value. Must be a valid + :class:`pandas.Timestamp` ``minute`` parameter. + :type minute: :obj:`int`, optional + + :param sec: Datetime seconds value. Must be a valid + :class:`pandas.Timestamp` ``second`` parameter. + :type sec: :obj:`int`, optional + + :param nsec: Datetime nanoseconds value. Quotient of a division + by 1000 (nanoseconds in microseconds) must be a valid + :class:`pandas.Timestamp` ``microsecond`` parameter, + remainder of a division by 1000 must be a valid + :class:`pandas.Timestamp` ``nanosecond`` parameter. + :type sec: :obj:`int`, optional + + :param tzoffset: Timezone offset. Ignored, if provided together + with :paramref:`~tarantool.Datetime.params.tz`. + :type tzoffset: :obj:`int`, optional + + :param tz: Timezone name from Olson timezone database. + :type tz: :obj:`str`, optional + + :raise: :exc:`ValueError`, :exc:`~tarantool.error.MsgpackError`, + :class:`pandas.Timestamp` exceptions + """ + if data is not None: if not isinstance(data, bytes): raise ValueError('data argument (first positional argument) ' + @@ -172,6 +404,22 @@ def __init__(self, data=None, *, timestamp=None, year=None, month=None, self._tz = '' def _interval_operation(self, other, sign=1): + """ + Implementation of :class:`~tarantool.Interval` addition and + subtraction. + + :param other: Interval to add or subtract. + :type other: :class:`~tarantool.Interval` + + :param sign: Right operand multiplier: ``1`` for addition, + ``-1`` for subtractiom. + :type sign: :obj:`int` + + :rtype: :class:`~tarantool.Datetime` + + :meta private: + """ + self_dt = self._datetime # https://github.com/tarantool/tarantool/wiki/Datetime-Internals#date-adjustions-and-leap-years @@ -206,12 +454,94 @@ def _interval_operation(self, other, sign=1): tzoffset=tzoffset, tz=self.tz) def __add__(self, other): + """ + Valid operations: + + * :class:`~tarantool.Datetime` + :class:`~tarantool.Interval` + = :class:`~tarantool.Datetime` + + Since :class:`~tarantool.Interval` could contain + :paramref:`~tarantool.Interval.params.month` and + :paramref:`~tarantool.Interval.params.year` fields and such + operations could be ambiguous, you can use the + :paramref:`~tarantool.Interval.params.adjust` field to tune the + logic. The behavior is the same as in Tarantool, see + `Interval arithmetic RFC`_. + + * :attr:`tarantool.IntervalAdjust.NONE ` + -- only truncation toward the end of month is performed (default + mode). + + .. code-block:: python + + >>> dt = tarantool.Datetime(year=2022, month=3, day=31) + datetime: Timestamp('2022-03-31 00:00:00'), tz: "" + >>> di = tarantool.Interval(month=1, adjust=tarantool.IntervalAdjust.NONE) + >>> dt + di + datetime: Timestamp('2022-04-30 00:00:00'), tz: "" + + * :attr:`tarantool.IntervalAdjust.EXCESS ` + -- overflow mode, + without any snap or truncation to the end of month, straight + addition of days in month, stopping over month boundaries if + there is less number of days. + + .. code-block:: python + + >>> dt = tarantool.Datetime(year=2022, month=1, day=31) + datetime: Timestamp('2022-01-31 00:00:00'), tz: "" + >>> di = tarantool.Interval(month=1, adjust=tarantool.IntervalAdjust.EXCESS) + >>> dt + di + datetime: Timestamp('2022-03-02 00:00:00'), tz: "" + + * :attr:`tarantool.IntervalAdjust.LAST ` + -- mode when day snaps to the end of month, if it happens. + + .. code-block:: python + + >>> dt = tarantool.Datetime(year=2022, month=2, day=28) + datetime: Timestamp('2022-02-28 00:00:00'), tz: "" + >>> di = tarantool.Interval(month=1, adjust=tarantool.IntervalAdjust.LAST) + >>> dt + di + datetime: Timestamp('2022-03-31 00:00:00'), tz: "" + + :param other: Second operand. + :type other: :class:`~tarantool.Interval` + + :rtype: :class:`~tarantool.Datetime` + + :raise: :exc:`TypeError` + + .. _Interval arithmetic RFC: https://github.com/tarantool/tarantool/wiki/Datetime-Internals#interval-arithmetic + """ + if not isinstance(other, Interval): raise TypeError(f"unsupported operand type(s) for +: '{type(self)}' and '{type(other)}'") return self._interval_operation(other, sign=1) def __sub__(self, other): + """ + Valid operations: + + * :class:`~tarantool.Datetime` - :class:`~tarantool.Interval` + = :class:`~tarantool.Datetime` + * :class:`~tarantool.Datetime` - :class:`~tarantool.Datetime` + = :class:`~tarantool.Interval` + + Refer to :meth:`~tarantool.Datetime.__add__` for interval + adjustment rules. + + :param other: Second operand. + :type other: :class:`~tarantool.Interval` or + :class:`~tarantool.Datetime` + + :rtype: :class:`~tarantool.Datetime` or + :class:`~tarantool.Interval` + + :raise: :exc:`TypeError` + """ + if isinstance(other, Datetime): self_dt = self._datetime other_dt = other._datetime @@ -249,6 +579,16 @@ def __sub__(self, other): raise TypeError(f"unsupported operand type(s) for -: '{type(self)}' and '{type(other)}'") def __eq__(self, other): + """ + Datetimes are equal when underlying datetime infos are equal. + + :param other: Second operand. + :type other: :class:`~tarantool.Datetime` or + :class:`~pandas.Timestamp` + + :rtype: :obj:`bool` + """ + if isinstance(other, Datetime): return self._datetime == other._datetime elif isinstance(other, pandas.Timestamp): @@ -278,52 +618,123 @@ def __deepcopy__(self, memo): @property def year(self): + """ + Datetime year. + + :rtype: :obj:`int` + """ + return self._datetime.year @property def month(self): + """ + Datetime month. + + :rtype: :obj:`int` + """ + return self._datetime.month @property def day(self): + """ + Datetime day. + + :rtype: :obj:`int` + """ + return self._datetime.day @property def hour(self): + """ + Datetime day. + + :rtype: :obj:`int` + """ + return self._datetime.hour @property def minute(self): + """ + Datetime minute. + + :rtype: :obj:`int` + """ + return self._datetime.minute @property def sec(self): + """ + Datetime seconds. + + :rtype: :obj:`int` + """ + return self._datetime.second @property def nsec(self): - # microseconds + nanoseconds + """ + Datetime nanoseconds (everything less than seconds is included). + + :rtype: :obj:`int` + """ + return self._datetime.value % NSEC_IN_SEC @property def timestamp(self): + """ + Datetime time since epoch, in seconds. + + :rtype: :obj:`float` + """ + return self._datetime.timestamp() @property def tzoffset(self): + """ + Datetime current timezone offset. + + :rtype: :obj:`int` + """ + if self._datetime.tzinfo is not None: return compute_offset(self._datetime) return 0 @property def tz(self): + """ + Datetime timezone name. + + :rtype: :obj:`str` + """ + return self._tz @property def value(self): + """ + Datetime time since epoch, in nanoseconds. + + :rtype: :obj:`int` + """ + return self._datetime.value def msgpack_encode(self): + """ + Encode a datetime object. + + :rtype: :obj:`bytes` + """ + seconds = self.value // NSEC_IN_SEC nsec = self.nsec tzoffset = self.tzoffset diff --git a/tarantool/msgpack_ext/types/interval.py b/tarantool/msgpack_ext/types/interval.py index d7caeb9f..62d98145 100644 --- a/tarantool/msgpack_ext/types/interval.py +++ b/tarantool/msgpack_ext/types/interval.py @@ -1,37 +1,48 @@ +""" +Tarantool `datetime.interval`_ extension type support module. + +The interval MessagePack representation looks like this: + +.. code-block:: text + + +--------+-------------------------+-------------+----------------+ + | MP_EXT | Size of packed interval | MP_INTERVAL | PackedInterval | + +--------+-------------------------+-------------+----------------+ + +Packed interval consists of: + +* Packed number of non-zero fields. +* Packed non-null fields. + +Each packed field has the following structure: + +.. code-block:: text + + +----------+=====================+ + | field ID | field value | + +----------+=====================+ + +The number of defined (non-null) fields can be zero. In this case, +the packed interval will be encoded as integer 0. + +List of the field IDs: + +* 0 – year +* 1 – month +* 2 – week +* 3 – day +* 4 – hour +* 5 – minute +* 6 – second +* 7 – nanosecond +* 8 – adjust +""" + import msgpack from enum import Enum from tarantool.error import MsgpackError -# https://www.tarantool.io/en/doc/latest/dev_guide/internals/msgpack_extensions/#the-interval-type -# -# The interval MessagePack representation looks like this: -# +--------+-------------------------+-------------+----------------+ -# | MP_EXT | Size of packed interval | MP_INTERVAL | PackedInterval | -# +--------+-------------------------+-------------+----------------+ -# Packed interval consists of: -# - Packed number of non-zero fields. -# - Packed non-null fields. -# -# Each packed field has the following structure: -# +----------+=====================+ -# | field ID | field value | -# +----------+=====================+ -# -# The number of defined (non-null) fields can be zero. In this case, -# the packed interval will be encoded as integer 0. -# -# List of the field IDs: -# - 0 – year -# - 1 – month -# - 2 – week -# - 3 – day -# - 4 – hour -# - 5 – minute -# - 6 – second -# - 7 – nanosecond -# - 8 – adjust - id_map = { 0: 'year', 1: 'month', @@ -46,16 +57,87 @@ # https://github.com/tarantool/c-dt/blob/cec6acebb54d9e73ea0b99c63898732abd7683a6/dt_arithmetic.h#L34 class Adjust(Enum): - EXCESS = 0 # DT_EXCESS in c-dt, "excess" in Tarantool - NONE = 1 # DT_LIMIT in c-dt, "none" in Tarantool - LAST = 2 # DT_SNAP in c-dt, "last" in Tarantool + """ + Interval adjustment mode for year and month arithmetic. Refer to + :meth:`~tarantool.Datetime.__add__`. + """ + + EXCESS = 0 + """ + Overflow mode. + """ + + NONE = 1 + """ + Only truncation toward the end of month is performed. + """ + + LAST = 2 + """ + Mode when day snaps to the end of month, if it happens. + """ class Interval(): + """ + Class representing Tarantool `datetime.interval`_ info. + + You can create :class:`~tarantool.Interval` objects either + from MessagePack data or by using the same API as in Tarantool: + + .. code-block:: python + + di = tarantool.Interval(year=-1, month=2, day=3, + hour=4, minute=-5, sec=6, + nsec=308543321, + adjust=tarantool.IntervalAdjust.NONE) + + Its attributes (same as in init API) are exposed, so you can + use them if needed. + + .. _datetime.interval: https://www.tarantool.io/en/doc/latest/dev_guide/internals/msgpack_extensions/#the-interval-type + """ + def __init__(self, data=None, *, year=0, month=0, week=0, day=0, hour=0, minute=0, sec=0, nsec=0, adjust=Adjust.NONE): - # If msgpack data does not contain a field value, it is zero. - # If built not from msgpack data, set argument values later. + """ + :param data: MessagePack binary data to decode. If provided, + all other parameters are ignored. + :type data: :obj:`bytes`, optional + + :param year: Interval year value. + :type year: :obj:`int`, optional + + :param month: Interval month value. + :type month: :obj:`int`, optional + + :param week: Interval week value. + :type week: :obj:`int`, optional + + :param day: Interval day value. + :type day: :obj:`int`, optional + + :param hour: Interval hour value. + :type hour: :obj:`int`, optional + + :param minute: Interval minute value. + :type minute: :obj:`int`, optional + + :param sec: Interval seconds value. + :type sec: :obj:`int`, optional + + :param nsec: Interval nanoseconds value. + :type nsec: :obj:`int`, optional + + :param adjust: Interval adjustment rule. Refer to + :meth:`~tarantool.Datetime.__add__`. + :type adjust: :class:`~tarantool.IntervalAdjust`, optional + + :raise: :exc:`ValueError` + """ + + # If MessagePack data does not contain a field value, it is zero. + # If built not from MessagePack data, set argument values later. self.year = 0 self.month = 0 self.week = 0 @@ -107,6 +189,22 @@ def __init__(self, data=None, *, year=0, month=0, week=0, self.adjust = adjust def __add__(self, other): + """ + Valid operations: + + * :class:`~tarantool.Interval` + :class:`~tarantool.Interval` + = :class:`~tarantool.Interval` + + Adjust of the first operand is used in result. + + :param other: Second operand. + :type other: :class:`~tarantool.Interval` + + :rtype: :class:`~tarantool.Interval` + + :raise: :exc:`TypeError` + """ + if not isinstance(other, Interval): raise TypeError(f"unsupported operand type(s) for +: '{type(self)}' and '{type(other)}'") @@ -139,6 +237,22 @@ def __add__(self, other): ) def __sub__(self, other): + """ + Valid operations: + + * :class:`~tarantool.Interval` - :class:`~tarantool.Interval` + = :class:`~tarantool.Interval` + + Adjust of the first operand is used in result. + + :param other: Second operand. + :type other: :class:`~tarantool.Interval` + + :rtype: :class:`~tarantool.Interval` + + :raise: :exc:`TypeError` + """ + if not isinstance(other, Interval): raise TypeError(f"unsupported operand type(s) for -: '{type(self)}' and '{type(other)}'") @@ -171,6 +285,15 @@ def __sub__(self, other): ) def __eq__(self, other): + """ + Compare equality of each field, no casts. + + :param other: Second operand. + :type other: :class:`~tarantool.Interval` + + :rtype: :obj:`bool` + """ + if not isinstance(other, Interval): return False @@ -198,6 +321,12 @@ def __repr__(self): __str__ = __repr__ def msgpack_encode(self): + """ + Encode an interval object. + + :rtype: :obj:`bytes` + """ + buf = bytes() count = 0 diff --git a/tarantool/msgpack_ext/types/timezones/__init__.py b/tarantool/msgpack_ext/types/timezones/__init__.py index b5f2faf1..c0c4ce7e 100644 --- a/tarantool/msgpack_ext/types/timezones/__init__.py +++ b/tarantool/msgpack_ext/types/timezones/__init__.py @@ -1,3 +1,7 @@ +""" +Tarantool timezones module. +""" + from tarantool.msgpack_ext.types.timezones.timezones import ( TZ_AMBIGUOUS, indexToTimezone, diff --git a/tarantool/msgpack_ext/types/timezones/gen-timezones.sh b/tarantool/msgpack_ext/types/timezones/gen-timezones.sh index 66610c4c..5de5a51a 100755 --- a/tarantool/msgpack_ext/types/timezones/gen-timezones.sh +++ b/tarantool/msgpack_ext/types/timezones/gen-timezones.sh @@ -22,7 +22,10 @@ wget -O ${SRC_FILE} \ # So we can do the same and don't worry, be happy. cat < ${DST_FILE} -# Automatically generated by gen-timezones.sh +""" +Tarantool timezone info. Automatically generated by +\`\`gen-timezones.sh\`\`. +""" TZ_UTC = 0x01 TZ_RFC = 0x02 diff --git a/tarantool/msgpack_ext/types/timezones/timezones.py b/tarantool/msgpack_ext/types/timezones/timezones.py index bbb5df5c..0e3ff770 100644 --- a/tarantool/msgpack_ext/types/timezones/timezones.py +++ b/tarantool/msgpack_ext/types/timezones/timezones.py @@ -1,4 +1,7 @@ -# Automatically generated by gen-timezones.sh +""" +Tarantool timezone info. Automatically generated by +``gen-timezones.sh``. +""" TZ_UTC = 0x01 TZ_RFC = 0x02 diff --git a/tarantool/msgpack_ext/types/timezones/validate_timezones.py b/tarantool/msgpack_ext/types/timezones/validate_timezones.py index 0626d8c3..943437f8 100644 --- a/tarantool/msgpack_ext/types/timezones/validate_timezones.py +++ b/tarantool/msgpack_ext/types/timezones/validate_timezones.py @@ -1,3 +1,8 @@ +""" +Script to validate that each Tarantool timezone is either a valid pytz +timezone or an addreviated timezone with explicit offset provided. +""" + import pytz from timezones import timezoneToIndex, timezoneAbbrevInfo diff --git a/tarantool/msgpack_ext/unpacker.py b/tarantool/msgpack_ext/unpacker.py index ff3bdcb8..bc1fb0a0 100644 --- a/tarantool/msgpack_ext/unpacker.py +++ b/tarantool/msgpack_ext/unpacker.py @@ -1,3 +1,9 @@ +""" +Tarantool `extension`_ types decoding support. + +.. _extension: https://www.tarantool.io/en/doc/latest/dev_guide/internals/msgpack_extensions/ +""" + import tarantool.msgpack_ext.decimal as ext_decimal import tarantool.msgpack_ext.uuid as ext_uuid import tarantool.msgpack_ext.datetime as ext_datetime @@ -11,6 +17,22 @@ } def ext_hook(code, data): + """ + :class:`msgpack.Unpacker` decoder. + + :param code: MessagePack extension type code. + :type code: :obj:`int` + + :param data: MessagePack extension type data. + :type data: :obj:`bytes` + + :return: Decoded value. + :rtype: :class:`decimal.Decimal` or :class:`uuid.UUID` or + :class:`tarantool.Datetime` or :class:`tarantool.Interval` + + :raise: :exc:`NotImplementedError` + """ + if code in decoders: return decoders[code](data) raise NotImplementedError("Unknown msgpack type: %d" % (code,)) diff --git a/tarantool/msgpack_ext/uuid.py b/tarantool/msgpack_ext/uuid.py index c489a3fc..8a1951d0 100644 --- a/tarantool/msgpack_ext/uuid.py +++ b/tarantool/msgpack_ext/uuid.py @@ -1,17 +1,47 @@ -from uuid import UUID +""" +Tarantool `uuid`_ extension type support module. + +The UUID MessagePack representation looks like this: + +.. code-block:: text -# https://www.tarantool.io/en/doc/latest/dev_guide/internals/msgpack_extensions/#the-uuid-type -# -# The UUID MessagePack representation looks like this: -# +--------+------------+-----------------+ -# | MP_EXT | MP_UUID | UuidValue | -# | = d8 | = 2 | = 16-byte value | -# +--------+------------+-----------------+ + +--------+------------+-----------------+ + | MP_EXT | MP_UUID | UuidValue | + | = d8 | = 2 | = 16-byte value | + +--------+------------+-----------------+ + +.. _uuid: https://www.tarantool.io/en/doc/latest/dev_guide/internals/msgpack_extensions/#the-uuid-type +""" + +from uuid import UUID EXT_ID = 2 +""" +`uuid`_ type id. +""" def encode(obj): + """ + Encode an UUID object. + + :param obj: UUID to encode. + :type obj: :obj:`uuid.UUID` + + :return: Encoded UUID. + :rtype: :obj:`bytes` + """ + return obj.bytes def decode(data): + """ + Decode an UUID object. + + :param data: UUID to decode. + :type data: :obj:`bytes` + + :return: Decoded UUID. + :rtype: :obj:`uuid.UUID` + """ + return UUID(bytes=data) diff --git a/tarantool/request.py b/tarantool/request.py index 419f7832..68d49714 100644 --- a/tarantool/request.py +++ b/tarantool/request.py @@ -1,7 +1,8 @@ # pylint: disable=C0301,W0105,W0401,W0614 -''' -Request types definitions -''' +""" +Request types definitions. For internal use only, there is no API to +send pre-build request objects. +""" import sys import msgpack @@ -56,18 +57,23 @@ from tarantool.msgpack_ext.packer import default as packer_default class Request(object): - ''' + """ Represents a single request to the server in compliance with the - Tarantool protocol. - Responsible for data encapsulation and builds binary packet - to be sent to the server. + Tarantool protocol. Responsible for data encapsulation and building + the binary packet to be sent to the server. + + This is the abstract base class. Specific request types are + implemented in the inherited classes. + """ - This is the abstract base class. Specific request types - are implemented by the inherited classes. - ''' request_type = None def __init__(self, conn): + """ + :param conn: Request sender. + :type conn: :class:`~tarantool.Connection` + """ + self._bytes = None self.conn = conn self._sync = None @@ -120,6 +126,10 @@ def __init__(self, conn): self.packer = msgpack.Packer(**packer_kwargs) def _dumps(self, src): + """ + Encode MsgPack data. + """ + return self.packer.pack(src) def __bytes__(self): @@ -129,15 +139,27 @@ def __bytes__(self): @property def sync(self): - ''' - :type: int + """ + :type: :obj:`int` - Required field in the server request. Contains request header IPROTO_SYNC. - ''' + """ + return self._sync def header(self, length): + """ + Pack total (header + payload) length info together with header + itself. + + :param length: Payload length. + :type: :obj:`int` + + :return: MsgPack data with encoded total (header + payload) + length info and header. + :rtype: :obj:`bytes` + """ + self._sync = self.conn.generate_sync() header = self._dumps({IPROTO_CODE: self.request_type, IPROTO_SYNC: self._sync, @@ -147,15 +169,27 @@ def header(self, length): class RequestInsert(Request): - ''' - Represents INSERT request - ''' + """ + Represents INSERT request. + """ + request_type = REQUEST_TYPE_INSERT # pylint: disable=W0231 def __init__(self, conn, space_no, values): - ''' - ''' + """ + :param conn: Request sender. + :type conn: :class:`~tarantool.Connection` + + :param space_no: Space id. + :type space_no: :obj:`int` + + :param values: Record to be inserted. + :type values: :obj:`tuple` or :obj:`list` + + :raise: :exc:`~AssertionError` + """ + super(RequestInsert, self).__init__(conn) assert isinstance(values, (tuple, list)) @@ -166,12 +200,29 @@ def __init__(self, conn, space_no, values): class RequestAuthenticate(Request): - ''' - Represents AUTHENTICATE request - ''' + """ + Represents AUTHENTICATE request. + """ + request_type = REQUEST_TYPE_AUTHENTICATE def __init__(self, conn, salt, user, password): + """ + :param conn: Request sender. + :type conn: :class:`~tarantool.Connection` + + :param salt: base64-encoded session salt. + :type salt: :obj:`str` + + :param user: User name for authentication on the Tarantool + server. + :type user: :obj:`str` + + :param password: User password for authentication on the + Tarantool server. + :type password: :obj:`str` + """ + super(RequestAuthenticate, self).__init__(conn) def sha1(values): @@ -193,6 +244,18 @@ def sha1(values): self._body = request_body def header(self, length): + """ + Pack total (header + payload) length info together with header + itself. + + :param length: Payload length. + :type: :obj:`int` + + :return: MsgPack data with encoded total (header + payload) + length info and header. + :rtype: :obj:`bytes` + """ + self._sync = self.conn.generate_sync() # Set IPROTO_SCHEMA_ID: 0 to avoid SchemaReloadException # It is ok to use 0 in auth every time. @@ -204,15 +267,27 @@ def header(self, length): class RequestReplace(Request): - ''' - Represents REPLACE request - ''' + """ + Represents REPLACE request. + """ + request_type = REQUEST_TYPE_REPLACE # pylint: disable=W0231 def __init__(self, conn, space_no, values): - ''' - ''' + """ + :param conn: Request sender. + :type conn: :class:`~tarantool.Connection` + + :param space_no: Space id. + :type space_no: :obj:`int` + + :param values: Record to be replaced. + :type values: :obj:`tuple` or :obj:`list` + + :raise: :exc:`~AssertionError` + """ + super(RequestReplace, self).__init__(conn) assert isinstance(values, (tuple, list)) @@ -223,15 +298,30 @@ def __init__(self, conn, space_no, values): class RequestDelete(Request): - ''' - Represents DELETE request - ''' + """ + Represents DELETE request. + """ + request_type = REQUEST_TYPE_DELETE # pylint: disable=W0231 def __init__(self, conn, space_no, index_no, key): - ''' - ''' + """ + :param conn: Request sender. + :type conn: :class:`~tarantool.Connection` + + :param space_no: Space id. + :type space_no: :obj:`int` + + :param index_no: Index id. + :type index_no: :obj:`int` + + :param key: Key of a tuple to be deleted. + :type key: :obj:`list` + + :raise: :exc:`~AssertionError` + """ + super(RequestDelete, self).__init__(conn) request_body = self._dumps({IPROTO_SPACE_ID: space_no, @@ -242,13 +332,40 @@ def __init__(self, conn, space_no, index_no, key): class RequestSelect(Request): - ''' - Represents SELECT request - ''' + """ + Represents SELECT request. + """ + request_type = REQUEST_TYPE_SELECT # pylint: disable=W0231 def __init__(self, conn, space_no, index_no, key, offset, limit, iterator): + """ + :param conn: Request sender. + :type conn: :class:`~tarantool.Connection` + + :param space_no: Space id. + :type space_no: :obj:`int` + + :param index_no: Index id. + :type index_no: :obj:`int` + + :param key: Key of a tuple to be selected. + :type key: :obj:`list` + + :param offset: Number of tuples to skip. + :type offset: :obj:`int` + + :param limit: Maximum number of tuples to select. + :type limit: :obj:`int` + + :param iterator: Index iterator type, see + :paramref:`~tarantool.Connection.select.params.iterator`. + :type iterator: :obj:`str` + + :raise: :exc:`~AssertionError` + """ + super(RequestSelect, self).__init__(conn) request_body = self._dumps({IPROTO_SPACE_ID: space_no, IPROTO_INDEX_ID: index_no, @@ -261,14 +378,35 @@ def __init__(self, conn, space_no, index_no, key, offset, limit, iterator): class RequestUpdate(Request): - ''' - Represents UPDATE request - ''' + """ + Represents UPDATE request. + """ request_type = REQUEST_TYPE_UPDATE # pylint: disable=W0231 def __init__(self, conn, space_no, index_no, key, op_list): + """ + :param conn: Request sender. + :type conn: :class:`~tarantool.Connection` + + :param space_no: Space id. + :type space_no: :obj:`int` + + :param index_no: Index id. + :type index_no: :obj:`int` + + :param key: Key of a tuple to be updated. + :type key: :obj:`list` + + :param op_list: The list of operations to update individual + fields, refer to + :paramref:`~tarantool.Connection.update.params.op_list`. + :type op_list: :obj:`tuple` or :obj:`list` + + :raise: :exc:`~AssertionError` + """ + super(RequestUpdate, self).__init__(conn) request_body = self._dumps({IPROTO_SPACE_ID: space_no, @@ -280,13 +418,31 @@ def __init__(self, conn, space_no, index_no, key, op_list): class RequestCall(Request): - ''' - Represents CALL request - ''' + """ + Represents CALL request. + """ + request_type = REQUEST_TYPE_CALL # pylint: disable=W0231 def __init__(self, conn, name, args, call_16): + """ + :param conn: Request sender. + :type conn: :class:`~tarantool.Connection` + + :param name: Stored Lua function name. + :type func_name: :obj:`str` + + :param args: Stored Lua function arguments. + :type args: :obj:`tuple` + + :param call_16: If ``True``, use compatibility mode with + Tarantool 1.6 or older. + :type call_16: :obj:`bool` + + :raise: :exc:`~AssertionError` + """ + if call_16: self.request_type = REQUEST_TYPE_CALL16 super(RequestCall, self).__init__(conn) @@ -299,13 +455,27 @@ def __init__(self, conn, name, args, call_16): class RequestEval(Request): - ''' - Represents EVAL request - ''' + """ + Represents EVAL request. + """ + request_type = REQUEST_TYPE_EVAL # pylint: disable=W0231 def __init__(self, conn, name, args): + """ + :param conn: Request sender. + :type conn: :class:`~tarantool.Connection` + + :param name: Lua expression. + :type func_name: :obj:`str` + + :param args: Lua expression arguments. + :type args: :obj:`tuple` + + :raise: :exc:`~AssertionError` + """ + super(RequestEval, self).__init__(conn) assert isinstance(args, (list, tuple)) @@ -316,25 +486,52 @@ def __init__(self, conn, name, args): class RequestPing(Request): - ''' - Ping body is empty, so body_length == 0 and there's no body - ''' + """ + Represents a ping request with the empty body. + """ + request_type = REQUEST_TYPE_PING def __init__(self, conn): + """ + :param conn: Request sender. + :type conn: :class:`~tarantool.Connection` + """ + super(RequestPing, self).__init__(conn) self._body = b'' class RequestUpsert(Request): - ''' - Represents UPSERT request - ''' + """ + Represents UPSERT request. + """ request_type = REQUEST_TYPE_UPSERT # pylint: disable=W0231 def __init__(self, conn, space_no, index_no, tuple_value, op_list): + """ + :param conn: Request sender. + :type conn: :class:`~tarantool.Connection` + + :param space_no: Space id. + :type space_no: :obj:`int` + + :param index_no: Index id. + :type index_no: :obj:`int` + + :param tuple_value: Tuple to be upserted. + :type tuple_value: :obj:`tuple` or :obj:`list` + + :param op_list: The list of operations to update individual + fields, refer to + :paramref:`~tarantool.Connection.update.params.op_list`. + :type op_list: :obj:`tuple` or :obj:`list` + + :raise: :exc:`~AssertionError` + """ + super(RequestUpsert, self).__init__(conn) request_body = self._dumps({IPROTO_SPACE_ID: space_no, @@ -346,26 +543,52 @@ def __init__(self, conn, space_no, index_no, tuple_value, op_list): class RequestJoin(Request): - ''' - Represents JOIN request - ''' + """ + Represents JOIN request. + """ + request_type = REQUEST_TYPE_JOIN # pylint: disable=W0231 def __init__(self, conn, server_uuid): + """ + :param conn: Request sender. + :type conn: :class:`~tarantool.Connection` + + :param server_uuid: UUID of connector "server". + :type server_uuid: :obj:`str` + """ + super(RequestJoin, self).__init__(conn) request_body = self._dumps({IPROTO_SERVER_UUID: server_uuid}) self._body = request_body class RequestSubscribe(Request): - ''' - Represents SUBSCRIBE request - ''' + """ + Represents SUBSCRIBE request. + """ + request_type = REQUEST_TYPE_SUBSCRIBE # pylint: disable=W0231 def __init__(self, conn, cluster_uuid, server_uuid, vclock): + """ + :param conn: Request sender. + :type conn: :class:`~tarantool.Connection` + + :param server_uuid: UUID of connector "server". + :type server_uuid: :obj:`str` + + :param server_uuid: UUID of connector "server". + :type server_uuid: :obj:`str` + + :param vclock: Connector "server" vclock. + :type vclock: :obj:`dict` + + :raise: :exc:`~AssertionError` + """ + super(RequestSubscribe, self).__init__(conn) assert isinstance(vclock, dict) @@ -378,13 +601,22 @@ def __init__(self, conn, cluster_uuid, server_uuid, vclock): class RequestOK(Request): - ''' - Represents OK acknowledgement - ''' + """ + Represents OK acknowledgement. + """ + request_type = REQUEST_TYPE_OK # pylint: disable=W0231 def __init__(self, conn, sync): + """ + :param conn: Request sender. + :type conn: :class:`~tarantool.Connection` + + :param sync: Previous request sync id. + :type sync: :obj:`int` + """ + super(RequestOK, self).__init__(conn) request_body = self._dumps({IPROTO_CODE: self.request_type, IPROTO_SYNC: sync}) @@ -392,12 +624,26 @@ def __init__(self, conn, sync): class RequestExecute(Request): - ''' - Represents EXECUTE SQL request - ''' + """ + Represents EXECUTE SQL request. + """ + request_type = REQUEST_TYPE_EXECUTE def __init__(self, conn, sql, args): + """ + :param conn: Request sender. + :type conn: :class:`~tarantool.Connection` + + :param sql: SQL query. + :type sql: :obj:`str` + + :param args: SQL query bind values. + :type args: :obj:`dict` or :obj:`list` + + :raise: :exc:`~TypeError` + """ + super(RequestExecute, self).__init__(conn) if isinstance(args, Mapping): args = [{":%s" % name: value} for name, value in args.items()] diff --git a/tarantool/response.py b/tarantool/response.py index 4566acd5..2832fba9 100644 --- a/tarantool/response.py +++ b/tarantool/response.py @@ -1,4 +1,7 @@ # pylint: disable=C0301,W0105,W0401,W0614 +""" +Request response types definitions. +""" from collections.abc import Sequence @@ -26,24 +29,24 @@ from tarantool.msgpack_ext.unpacker import ext_hook as unpacker_ext_hook class Response(Sequence): - ''' + """ Represents a single response from the server in compliance with the - Tarantool protocol. - Responsible for data encapsulation (i.e. received list of tuples) - and parsing of binary packets received from the server. - ''' + Tarantool protocol. Responsible for data encapsulation (i.e. + received list of tuples) and parsing of binary packets received from + the server. + """ def __init__(self, conn, response): - ''' - Create an instance of `Response` - using the data received from the server. + """ + :param conn: Request sender. + :type conn: :class:`~tarantool.Connection` - __init__() reads data from the socket, parses the response body, and - sets the appropriate instance attributes. + :param response: Response binary data. + :type response: :obj:`bytes` - :param body: body of the response - :type body: array of bytes - ''' + :raise: :exc:`~tarantool.error.DatabaseError`, + :exc:`~tarantool.error.SchemaReloadException` + """ # This is not necessary, because underlying list data structures are # created in the __new__(). @@ -51,7 +54,7 @@ def __init__(self, conn, response): unpacker_kwargs = dict() - # Decode msgpack arrays into Python lists by default (not tuples). + # Decode MsgPack arrays into Python lists by default (not tuples). # Can be configured in the Connection init unpacker_kwargs['use_list'] = conn.use_list @@ -147,115 +150,120 @@ def __reversed__(self): return reversed(self._data) def index(self, *args): + """ + Refer to :class:`collections.abc.Sequence`. + + :raises: :exc:`~tarantool.error.InterfaceError.` + """ + if self._data is None: raise InterfaceError("Trying to access data when there's no data") return self._data.index(*args) def count(self, item): + """ + Refer to :class:`collections.abc.Sequence`. + + :raises: :exc:`~tarantool.error.InterfaceError` + """ + if self._data is None: raise InterfaceError("Trying to access data when there's no data") return self._data.count(item) @property def rowcount(self): - ''' - :type: int + """ + :type: :obj:`int` Number of rows affected or returned by a query. - ''' + """ + return len(self) @property def body(self): - ''' - :type: dict + """ + :type: :obj:`dict` + + Raw response body. + """ - Required field in the server response. - Contains the raw response body. - ''' return self._body @property def code(self): - ''' - :type: int + """ + :type: :obj:`int` + + Response type id. + """ - Required field in the server response. - Contains the response type id. - ''' return self._code @property def sync(self): - ''' - :type: int + """ + :type: :obj:`int` + + Response header IPROTO_SYNC. + """ - Required field in the server response. - Contains the response header IPROTO_SYNC. - ''' return self._sync @property def return_code(self): - ''' - :type: int - - Required field in the server response. - If the request was successful, - the value of :attr:`return_code` is ``0``. - Otherwise, :attr:`return_code` contains an error code. - If :attr:`return_code` is non-zero, :attr:`return_message` - contains an error message. - ''' + """ + :type: :obj:`int` + + If the request was successful, the value of is ``0``. + Otherwise, it contains an error code. If the value is non-zero, + :attr:`return_message` contains an error message. + """ + return self._return_code @property def data(self): - ''' - :type: object + """ + :type: :obj:`object` + + Contains the list of tuples for SELECT, REPLACE and DELETE + requests and arbitrary data for CALL. + """ - Required field in the server response. - Contains the list of tuples for SELECT, REPLACE and DELETE requests - and arbitrary data for CALL. - ''' return self._data @property def strerror(self): - ''' - :type: str + """ + Refer to :func:`~tarantool.error.tnt_strerror`. + """ - Contains ER_OK if the request was successful, - or contains an error code string. - ''' return tnt_strerror(self._return_code) @property def return_message(self): - ''' - :type: str + """ + :type: :obj:`str` + + The error message returned by the server in case of non-zero + :attr:`return_code` (empty string otherwise). + """ - The error message returned by the server in case - :attr:`return_code` is non-zero. - ''' return self._return_message @property def schema_version(self): - ''' - :type: int + """ + :type: :obj:`int` + + Request current schema version. + """ - Current schema version of request. - ''' return self._schema_version def __str__(self): - ''' - Return a user-friendy string representation of the object. - Useful for interactive sessions and debuging. - - :rtype: str or None - ''' if self.return_code: return json.dumps({ 'error': { @@ -274,16 +282,20 @@ def __str__(self): class ResponseExecute(Response): + """ + Represents an SQL EXECUTE request response. + """ + @property def autoincrement_ids(self): """ - Returns a list with the new primary-key value - (or values) for an INSERT in a table defined with - PRIMARY KEY AUTOINCREMENT - (NOT result set size). + A list with the new primary-key value (or values) for an + INSERT in a table defined with PRIMARY KEY AUTOINCREMENT (NOT + result set size). - :rtype: list or None + :rtype: :obj:`list` or :obj:`None` """ + if self._return_code != 0: return None info = self._body.get(IPROTO_SQL_INFO) @@ -298,11 +310,12 @@ def autoincrement_ids(self): @property def affected_row_count(self): """ - Returns the number of changed rows for responses - to DML requests and None for DQL requests. + The number of changed rows for responses to DML requests and + ``None`` for DQL requests. - :rtype: int + :rtype: :obj:`int` or :obj:`None` """ + if self._return_code != 0: return None info = self._body.get(IPROTO_SQL_INFO) diff --git a/tarantool/schema.py b/tarantool/schema.py index 3c06963d..d4f13f6b 100644 --- a/tarantool/schema.py +++ b/tarantool/schema.py @@ -1,8 +1,8 @@ # pylint: disable=R0903 -''' -This module provides the :class:`~tarantool.schema.Schema` class. -It is a Tarantool schema description. -''' +""" +Schema types definitions. For internal use only, there is no API to use +pre-build schema objects. +""" from tarantool.error import ( Error, @@ -13,28 +13,50 @@ class RecursionError(Error): - """Report the situation when max recursion depth is reached. + """ + Report the situation when max recursion depth is reached. - This is an internal error of caller, - and it should be re-raised properly be the caller. + This is an internal error of + :func:`~tarantool.schema.to_unicode_recursive` caller and it should + be re-raised properly by the caller. """ def to_unicode(s): + """ + Decode :obj:`bytes` to unicode :obj:`str`. + + :param s: Value to convert. + + :return: Decoded unicode :obj:`str`, if value is :obj:`bytes`. + Otherwise, it returns the original value. + + :meta private: + """ + if isinstance(s, bytes): return s.decode(encoding='utf-8') return s def to_unicode_recursive(x, max_depth): - """Same as to_unicode(), but traverses recursively over dictionaries, - lists and tuples. + """ + Recursively decode :obj:`bytes` to unicode :obj:`str` over + :obj:`dict`, :obj:`list` and :obj:`tuple`. - x: value to convert + :param x: Value to convert. - max_depth: 1 accepts a scalar, 2 accepts a list of scalars, - etc. + :param max_depth: Maximum depth recursion. + :type max_depth: :obj:`int` + + :return: The same structure where all :obj:`bytes` are replaced + with unicode :obj:`str`. + + :raise: :exc:`~tarantool.schema.RecursionError` + + :meta private: """ + if max_depth <= 0: raise RecursionError('Max recursion depth is reached') @@ -59,7 +81,21 @@ def to_unicode_recursive(x, max_depth): class SchemaIndex(object): + """ + Contains schema for a space index. + """ + def __init__(self, index_row, space): + """ + :param index_row: Index format data received from Tarantool. + :type index_row: :obj:`list` or :obj:`tuple` + + :param space: Related space schema. + :type space: :class:`~tarantool.schema.SchemaSpace` + + :raise: :exc:`~tarantool.error.SchemaError` + """ + self.iid = index_row[1] self.name = index_row[2] self.name = to_unicode(index_row[2]) @@ -89,13 +125,31 @@ def __init__(self, index_row, space): self.space.indexes[self.name] = self def flush(self): + """ + Clean existing index data. + """ + del self.space.indexes[self.iid] if self.name: del self.space.indexes[self.name] class SchemaSpace(object): + """ + Contains schema for a space. + """ + def __init__(self, space_row, schema): + """ + :param space_row: Space format data received from Tarantool. + :type space_row: :obj:`list` or :obj:`tuple` + + :param schema: Related server schema. + :type schema: :class:`~tarantool.schema.Schema` + + :raise: :exc:`~tarantool.error.SchemaError` + """ + self.sid = space_row[0] self.arity = space_row[1] self.name = to_unicode(space_row[2]) @@ -116,17 +170,42 @@ def __init__(self, space_row, schema): self.format[part_id ] = part def flush(self): + """ + Clean existing space data. + """ + del self.schema[self.sid] if self.name: del self.schema[self.name] class Schema(object): + """ + Contains Tarantool server spaces schema. + """ + def __init__(self, con): + """ + :param con: Related Tarantool server connection. + :type con: :class:`~tarantool.Connection` + """ + self.schema = {} self.con = con def get_space(self, space): + """ + Get space schema. If it exists in the local schema, return local + data, otherwise fetch data from the Tarantool server. + + :param space: Space name or space id. + :type space: :obj:`str` or :obj:`int` + + :rtype: :class:`~tarantool.schema.SchemaSpace` + + :raises: :meth:`~tarantool.schema.Schema.fetch_space` exceptions + """ + space = to_unicode(space) try: @@ -137,6 +216,19 @@ def get_space(self, space): return self.fetch_space(space) def fetch_space(self, space): + """ + Fetch a single space schema from the Tarantool server and build + a schema object. + + :param space: Space name or space id to fetch. + :type space: :obj:`str` or :obj:`int` + + :rtype: :class:`~tarantool.schema.SchemaSpace` + + :raises: :exc:`~tarantool.error.SchemaError`, + :meth:`~tarantool.schema.Schema.fetch_space_from` exceptions + """ + space_row = self.fetch_space_from(space) if len(space_row) > 1: @@ -155,6 +247,19 @@ def fetch_space(self, space): return SchemaSpace(space_row, self.schema) def fetch_space_from(self, space): + """ + Fetch space schema from the Tarantool server. + + :param space: Space name or space id to fetch. If ``None``, + fetch all spaces. + :type space: :obj:`str` or :obj:`int` or :obj:`None` + + :return: Space format data received from Tarantool. + :rtype: :obj:`list` or :obj:`tuple` + + :raises: :meth:`~tarantool.Connection.select` exceptions + """ + _index = None if isinstance(space, str): _index = const.INDEX_SPACE_NAME @@ -181,11 +286,33 @@ def fetch_space_from(self, space): return space_row def fetch_space_all(self): + """ + Fetch all spaces schema from the Tarantool server and build + corresponding schema objects. + + :raises: :meth:`~tarantool.schema.Schema.fetch_space_from` + exceptions + """ + space_rows = self.fetch_space_from(None) for row in space_rows: SchemaSpace(row, self.schema) def get_index(self, space, index): + """ + Get space index schema. If it exists in the local schema, return + local data, otherwise fetch data from the Tarantool server. + + :param space: Space id or space name. + :type space: :obj:`str` or :obj:`int` + + :param index: Index id or index name. + :type index: :obj:`str` or :obj:`int` + + :rtype: :class:`~tarantool.schema.SchemaIndex` + + :raises: :meth:`~tarantool.schema.Schema.fetch_index` exceptions + """ space = to_unicode(space) index = to_unicode(index) @@ -198,6 +325,22 @@ def get_index(self, space, index): return self.fetch_index(_space, index) def fetch_index(self, space_object, index): + """ + Fetch a single index space schema from the Tarantool server and + build a schema object. + + :param space: Space schema. + :type space: :class:`~tarantool.schema.SchemaSpace` + + :param index: Index name or id. + :type index: :obj:`str` or :obj:`int` + + :rtype: :class:`~tarantool.schema.SchemaIndex` + + :raises: :exc:`~tarantool.error.SchemaError`, + :meth:`~tarantool.schema.Schema.fetch_index_from` exceptions + """ + index_row = self.fetch_index_from(space_object.sid, index) if len(index_row) > 1: @@ -218,11 +361,35 @@ def fetch_index(self, space_object, index): return SchemaIndex(index_row, space_object) def fetch_index_all(self): + """ + Fetch all spaces indexes schema from the Tarantool server and + build corresponding schema objects. + + :raises: :meth:`~tarantool.schema.Schema.fetch_index_from` + exceptions + """ index_rows = self.fetch_index_from(None, None) for row in index_rows: SchemaIndex(row, self.schema[row[0]]) def fetch_index_from(self, space, index): + """ + Fetch space index schema from the Tarantool server. + + :param space: Space id. If ``None``, fetch all spaces + index schema. + :type space: :obj:`int` or :obj:`None` + + :param index: Index name or id. If ``None``, fetch all space + indexes schema. + :type index: :obj:`str` or :obj:`int` or :obj:`None` + + :return: Space index format data received from Tarantool. + :rtype: :obj:`list` or :obj:`tuple` + + :raises: :meth:`~tarantool.Connection.select` exceptions + """ + _index = None if isinstance(index, str): _index = const.INDEX_INDEX_NAME @@ -257,6 +424,21 @@ def fetch_index_from(self, space, index): return index_row def get_field(self, space, field): + """ + Get space field format info. + + :param space: Space name or space id. + :type space: :obj:`str` or :obj:`int` + + :param field: Field name or field id. + :type field: :obj:`str` or :obj:`int` + + :return: Field format info. + :rtype: :obj:`dict` + + :raises: :exc:`~tarantool.error.SchemaError`, + :meth:`~tarantool.schema.Schema.fetch_space` exceptions + """ space = to_unicode(space) field = to_unicode(field) @@ -273,4 +455,8 @@ def get_field(self, space, field): return field def flush(self): + """ + Clean existing schema data. + """ + self.schema.clear() diff --git a/tarantool/space.py b/tarantool/space.py index 5ebe297c..0fa61198 100644 --- a/tarantool/space.py +++ b/tarantool/space.py @@ -1,82 +1,76 @@ # pylint: disable=C0301,W0105,W0401,W0614 -''' -This module provides the :class:`~tarantool.space.Space` class. -It is an object-oriented wrapper for requests to a Tarantool space. -''' +""" +Space type definition. It is an object-oriented wrapper for requests to +a Tarantool server space. +""" class Space(object): - ''' + """ Object-oriented wrapper for accessing a particular space. - Encapsulates the identifier of the space and provides a more convenient - syntax for database operations. - ''' + Encapsulates the identifier of the space and provides a more + convenient syntax for database operations. + """ def __init__(self, connection, space_name): - ''' - Create Space instance. + """ + :param connection: Connection to the server. + :type connection: :class:`~tarantool.Connection` - :param connection: object representing connection to the server - :type connection: :class:`~tarantool.connection.Connection` instance - :param int space_name: space no or name to insert a record - :type space_name: int or str - ''' + :param space_name: Space name or space id to bind. + :type space_name: :obj:`str` or :obj:`int` + + :raises: :meth:`~tarantool.schema.Schema.get_space` exceptions + """ self.connection = connection self.space_no = self.connection.schema.get_space(space_name).sid def insert(self, *args, **kwargs): - ''' - Execute an INSERT request. + """ + Refer to :meth:`~tarantool.Connection.insert`. + """ - See `~tarantool.connection.insert` for more information. - ''' return self.connection.insert(self.space_no, *args, **kwargs) def replace(self, *args, **kwargs): - ''' - Execute a REPLACE request. + """ + Refer to :meth:`~tarantool.Connection.replace`. + """ - See `~tarantool.connection.replace` for more information. - ''' return self.connection.replace(self.space_no, *args, **kwargs) def delete(self, *args, **kwargs): - ''' - Execute a DELETE request. + """ + Refer to :meth:`~tarantool.Connection.delete`. + """ - See `~tarantool.connection.delete` for more information. - ''' return self.connection.delete(self.space_no, *args, **kwargs) def update(self, *args, **kwargs): - ''' - Execute an UPDATE request. + """ + Refer to :meth:`~tarantool.Connection.update`. + """ - See `~tarantool.connection.update` for more information. - ''' return self.connection.update(self.space_no, *args, **kwargs) def upsert(self, *args, **kwargs): - ''' - Execute an UPDATE request. + """ + Refer to :meth:`~tarantool.Connection.upsert`. + """ - See `~tarantool.connection.upsert` for more information. - ''' return self.connection.upsert(self.space_no, *args, **kwargs) def select(self, *args, **kwargs): - ''' - Execute a SELECT request. + """ + Refer to :meth:`~tarantool.Connection.select`. + """ - See `~tarantool.connection.select` for more information. - ''' return self.connection.select(self.space_no, *args, **kwargs) def call(self, func_name, *args, **kwargs): - ''' - Execute a CALL request. Call a stored Lua function. + """ + **Deprecated**, use :meth:`~tarantool.Connection.call` instead. + """ - Deprecated, use `~tarantool.connection.call` instead. - ''' return self.connection.call(func_name, *args, **kwargs) diff --git a/tarantool/utils.py b/tarantool/utils.py index 1427d9d0..3f8275ba 100644 --- a/tarantool/utils.py +++ b/tarantool/utils.py @@ -8,9 +8,33 @@ from base64 import decodebytes as base64_decode def strxor(rhs, lhs): + """ + XOR two strings. + + :param rhs: String to XOR. + :type rhs: :obj:`str` or :obj:`bytes` + + :param lhs: Another string to XOR. + :type lhs: :obj:`str` or :obj:`bytes` + + :rtype: :obj:`bytes` + """ + return bytes([x ^ y for x, y in zip(rhs, lhs)]) def check_key(*args, **kwargs): + """ + Validate request key types and map. + + :param args: Method args. + :type args: :obj:`tuple` + + :param kwargs: Method kwargs. + :type kwargs: :obj:`dict` + + :rtype: :obj:`list` + """ + if 'first' not in kwargs: kwargs['first'] = True if 'select' not in kwargs: @@ -29,9 +53,36 @@ def check_key(*args, **kwargs): def version_id(major, minor, patch): + """ + :param major: Version major number. + :type major: :obj:`int` + + :param minor: Version minor number. + :type minor: :obj:`int` + + :param patch: Version patch number. + :type patch: :obj:`int` + + :return: Unique version identificator for 8-bytes major, minor, + patch numbers. + :rtype: :obj:`int` + """ + return (((major << 8) | minor) << 8) | patch def greeting_decode(greeting_buf): + """ + Decode Tarantool server greeting. + + :param greeting_buf: Binary greetings data. + :type greeting_buf: :obj:`bytes` + + :rtype: ``Greeting`` dataclass with ``version_id``, ``protocol``, + ``uuid``, ``salt`` fields + + :raise: :exc:`~Exception` + """ + class Greeting: version_id = 0 protocol = None From bed2c158956ad95ebcedb4bea7ea15d2218377b1 Mon Sep 17 00:00:00 2001 From: Georgy Moiseev Date: Fri, 7 Oct 2022 14:04:34 +0300 Subject: [PATCH 08/14] doc: describe tarantool module API Update core module docstring and autodoc parameters. Part of #67 --- CHANGELOG.md | 1 + doc/api/module-tarantool.rst | 2 -- tarantool/__init__.py | 58 ++++++++++++++++++++++++++++-------- 3 files changed, 47 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 09fb119b..fa6d38ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -139,6 +139,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 The only reason of this bump is various vulnerability fixes, msgpack>=0.4.0 and msgpack-python==0.4.0 are still supported. - Change documentation HTML theme (#67). +- Update API documentation strings (#67). ### Fixed diff --git a/doc/api/module-tarantool.rst b/doc/api/module-tarantool.rst index 0fad1230..426e31a8 100644 --- a/doc/api/module-tarantool.rst +++ b/doc/api/module-tarantool.rst @@ -2,5 +2,3 @@ module :py:mod:`tarantool` ========================== .. automodule:: tarantool - :members: - :undoc-members: diff --git a/tarantool/__init__.py b/tarantool/__init__.py index 9cef1d01..307c72ff 100644 --- a/tarantool/__init__.py +++ b/tarantool/__init__.py @@ -49,16 +49,40 @@ def connect(host="localhost", port=33013, user=None, password=None, ssl_cert_file=DEFAULT_SSL_CERT_FILE, ssl_ca_file=DEFAULT_SSL_CA_FILE, ssl_ciphers=DEFAULT_SSL_CIPHERS): - ''' + """ Create a connection to the Tarantool server. - :param str host: Server hostname or IP-address - :param int port: Server port + :param host: Refer to :paramref:`~tarantool.Connection.params.host`. - :rtype: :class:`~tarantool.connection.Connection` + :param port: Refer to :paramref:`~tarantool.Connection.params.port`. - :raise: `NetworkError` - ''' + :param user: Refer to :paramref:`~tarantool.Connection.params.user`. + + :param password: Refer to + :paramref:`~tarantool.Connection.params.password`. + + :param encoding: Refer to + :paramref:`~tarantool.Connection.params.encoding`. + + :param transport: Refer to + :paramref:`~tarantool.Connection.params.transport`. + + :param ssl_key_file: Refer to + :paramref:`~tarantool.Connection.params.ssl_key_file`. + + :param ssl_cert_file: Refer to + :paramref:`~tarantool.Connection.params.ssl_cert_file`. + + :param ssl_ca_file: Refer to + :paramref:`~tarantool.Connection.params.ssl_ca_file`. + + :param ssl_ciphers: Refer to + :paramref:`~tarantool.Connection.params.ssl_ciphers`. + + :rtype: :class:`~tarantool.Connection` + + :raise: :class:`~tarantool.Connection` exceptions + """ return Connection(host, port, user=user, @@ -77,15 +101,25 @@ def connect(host="localhost", port=33013, user=None, password=None, def connectmesh(addrs=({'host': 'localhost', 'port': 3301},), user=None, password=None, encoding=ENCODING_DEFAULT): - ''' - Create a connection to a mesh of Tarantool servers. + """ + Create a connection to a cluster of Tarantool servers. + + :param addrs: Refer to + :paramref:`~tarantool.MeshConnection.params.addrs`. + + :param user: Refer to + :paramref:`~tarantool.MeshConnection.params.user`. + + :param password: Refer to + :paramref:`~tarantool.MeshConnection.params.password`. - :param list addrs: List of maps: {'host':(HOSTNAME|IP_ADDR), 'port':PORT}. + :param encoding: Refer to + :paramref:`~tarantool.MeshConnection.params.encoding`. - :rtype: :class:`~tarantool.mesh_connection.MeshConnection` + :rtype: :class:`~tarantool.MeshConnection` - :raise: `NetworkError` - ''' + :raise: :class:`~tarantool.MeshConnection` exceptions + """ return MeshConnection(addrs=addrs, user=user, From 2c846ea08cd0253a33cd8e88d807d18d4a57516e Mon Sep 17 00:00:00 2001 From: Georgy Moiseev Date: Fri, 7 Oct 2022 14:12:37 +0300 Subject: [PATCH 09/14] doc: workaround duplicate object descriptions Due to DBAPI requirements, Connection and MeshConnection classes expose exceptions as attributes. tarantool module also exposes several exceptions. sphinx raises warnings about duplicate object description due to multiple exception references. Since there is no way to put ``:noindex:`` in docstrings [1] and other solutions (like ``:meta private:``) are completely remove attribute descriptions, this patch removes indexes with rst-level workarounds. 1. https://stackoverflow.com/questions/66736786/can-i-use-noindex-in-python-docstring-with-sphinx-autodoc Part of #67 --- doc/api/module-tarantool.rst | 111 ++++++++++++++++++++++++++ doc/api/submodule-connection.rst | 46 +++++++++++ doc/api/submodule-mesh-connection.rst | 46 +++++++++++ 3 files changed, 203 insertions(+) diff --git a/doc/api/module-tarantool.rst b/doc/api/module-tarantool.rst index 426e31a8..9134d9f0 100644 --- a/doc/api/module-tarantool.rst +++ b/doc/api/module-tarantool.rst @@ -2,3 +2,114 @@ module :py:mod:`tarantool` ========================== .. automodule:: tarantool + :exclude-members: Connection, MeshConnection, + Error, DatabaseError, NetworkError, NetworkWarning, + SchemaError + + .. autoclass:: tarantool.Connection + :exclude-members: Error, DatabaseError, InterfaceError, + ConfigurationError, SchemaError, NetworkError, + Warning, DataError, OperationalError, IntegrityError, + InternalError, ProgrammingError, NotSupportedError + + .. autoattribute:: Error + :noindex: + + .. autoattribute:: DatabaseError + :noindex: + + .. autoattribute:: InterfaceError + :noindex: + + .. autoattribute:: ConfigurationError + :noindex: + + .. autoattribute:: SchemaError + :noindex: + + .. autoattribute:: NetworkError + :noindex: + + .. autoattribute:: Warning + :noindex: + + .. autoattribute:: DataError + :noindex: + + .. autoattribute:: OperationalError + :noindex: + + .. autoattribute:: IntegrityError + :noindex: + + .. autoattribute:: InternalError + :noindex: + + .. autoattribute:: ProgrammingError + :noindex: + + .. autoattribute:: NotSupportedError + :noindex: + + .. autoclass:: tarantool.MeshConnection + :exclude-members: Error, DatabaseError, InterfaceError, + ConfigurationError, SchemaError, NetworkError, + Warning, DataError, OperationalError, IntegrityError, + InternalError, ProgrammingError, NotSupportedError + + .. autoattribute:: Error + :noindex: + + .. autoattribute:: DatabaseError + :noindex: + + .. autoattribute:: InterfaceError + :noindex: + + .. autoattribute:: ConfigurationError + :noindex: + + .. autoattribute:: SchemaError + :noindex: + + .. autoattribute:: NetworkError + :noindex: + + .. autoattribute:: Warning + :noindex: + + .. autoattribute:: DataError + :noindex: + + .. autoattribute:: OperationalError + :noindex: + + .. autoattribute:: IntegrityError + :noindex: + + .. autoattribute:: InternalError + :noindex: + + .. autoattribute:: ProgrammingError + :noindex: + + .. autoattribute:: NotSupportedError + :noindex: + + .. autoexception:: Error + :noindex: + + .. autoexception:: DatabaseError + :noindex: + + .. autoexception:: DatabaseError + :noindex: + + .. autoexception:: NetworkError + :noindex: + + .. autoexception:: NetworkWarning + :noindex: + + .. autoexception:: SchemaError + :noindex: diff --git a/doc/api/submodule-connection.rst b/doc/api/submodule-connection.rst index 3d4bfb7c..a0211d55 100644 --- a/doc/api/submodule-connection.rst +++ b/doc/api/submodule-connection.rst @@ -2,3 +2,49 @@ module :py:mod:`tarantool.connection` ===================================== .. automodule:: tarantool.connection + :exclude-members: Connection + + .. autoclass:: tarantool.connection.Connection + :exclude-members: Error, DatabaseError, InterfaceError, + ConfigurationError, SchemaError, NetworkError, + Warning, DataError, OperationalError, IntegrityError, + InternalError, ProgrammingError, NotSupportedError + + .. autoattribute:: Error + :noindex: + + .. autoattribute:: DatabaseError + :noindex: + + .. autoattribute:: InterfaceError + :noindex: + + .. autoattribute:: ConfigurationError + :noindex: + + .. autoattribute:: SchemaError + :noindex: + + .. autoattribute:: NetworkError + :noindex: + + .. autoattribute:: Warning + :noindex: + + .. autoattribute:: DataError + :noindex: + + .. autoattribute:: OperationalError + :noindex: + + .. autoattribute:: IntegrityError + :noindex: + + .. autoattribute:: InternalError + :noindex: + + .. autoattribute:: ProgrammingError + :noindex: + + .. autoattribute:: NotSupportedError + :noindex: diff --git a/doc/api/submodule-mesh-connection.rst b/doc/api/submodule-mesh-connection.rst index 8a20c19d..2eb59e84 100644 --- a/doc/api/submodule-mesh-connection.rst +++ b/doc/api/submodule-mesh-connection.rst @@ -2,3 +2,49 @@ module :py:mod:`tarantool.mesh_connection` ========================================== .. automodule:: tarantool.mesh_connection + :exclude-members: MeshConnection + + .. autoclass:: tarantool.mesh_connection.MeshConnection + :exclude-members: Error, DatabaseError, InterfaceError, + ConfigurationError, SchemaError, NetworkError, + Warning, DataError, OperationalError, IntegrityError, + InternalError, ProgrammingError, NotSupportedError + + .. autoattribute:: Error + :noindex: + + .. autoattribute:: DatabaseError + :noindex: + + .. autoattribute:: InterfaceError + :noindex: + + .. autoattribute:: ConfigurationError + :noindex: + + .. autoattribute:: SchemaError + :noindex: + + .. autoattribute:: NetworkError + :noindex: + + .. autoattribute:: Warning + :noindex: + + .. autoattribute:: DataError + :noindex: + + .. autoattribute:: OperationalError + :noindex: + + .. autoattribute:: IntegrityError + :noindex: + + .. autoattribute:: InternalError + :noindex: + + .. autoattribute:: ProgrammingError + :noindex: + + .. autoattribute:: NotSupportedError + :noindex: From 42d58705eef2de54f8d77a78e7f797f18f44a74f Mon Sep 17 00:00:00 2001 From: Georgy Moiseev Date: Fri, 7 Oct 2022 14:20:24 +0300 Subject: [PATCH 10/14] doc: update index, quick start and guide pages Sidebar with links was removed since it overlaps other elements. Some descriptions were reduced since all required info is now provided in API documentation. Part of #67 --- CHANGELOG.md | 1 + doc/dev-guide.rst | 113 +++++++++++++++++ doc/guide.rst | 286 -------------------------------------------- doc/index.rst | 24 ++-- doc/quick-start.rst | 199 ++++++++++++++++++++++-------- 5 files changed, 273 insertions(+), 350 deletions(-) create mode 100644 doc/dev-guide.rst delete mode 100644 doc/guide.rst diff --git a/CHANGELOG.md b/CHANGELOG.md index fa6d38ae..18014b32 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -140,6 +140,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 msgpack>=0.4.0 and msgpack-python==0.4.0 are still supported. - Change documentation HTML theme (#67). - Update API documentation strings (#67). +- Update documentation index, quick start and guide pages (#67). ### Fixed diff --git a/doc/dev-guide.rst b/doc/dev-guide.rst new file mode 100644 index 00000000..a3f7d704 --- /dev/null +++ b/doc/dev-guide.rst @@ -0,0 +1,113 @@ +.. encoding: utf-8 + +Developer's guide +================= + +Tarantool database basic concepts +--------------------------------- + +To understand, what is "space", "tuple" and what basic operations are, +refer to `Tarantool data model documentation`_. + +Field types +----------- + +Tarantool uses `MessagePack`_ as a format for receiving requests and sending +responses. Refer to `Lua versus MessagePack`_ to see how types are encoded +and decoded. + +While working with Tarantool from Python with this connector, +each request data is encoded to MessagePack and each response data +is decoded from MessagePack with the `Python MessagePack`_ module. See its +documentation to explore how basic types are encoded and decoded. + +There are several cases when you may tune up the behavior. +Use :class:`tarantool.Connection` parameters to set Python MessagePack +module options. + +Use :paramref:`~tarantool.Connection.params.encoding` to tune +behavior for string encoding. + +``encoding='utf-8'`` (default): + + +--------------+----+----------------------------------+----+--------------+ + | Python | -> | MessagePack (Tarantool/Lua) | -> | Python | + +==============+====+==================================+====+==============+ + | :obj:`str` | -> | `mp_str`_ (``string``) | -> | :obj:`str` | + +--------------+----+----------------------------------+----+--------------+ + | :obj:`bytes` | -> | `mp_bin`_ (``binary``/``cdata``) | -> | :obj:`bytes` | + +--------------+----+----------------------------------+----+--------------+ + +``encoding=None`` (work with non-UTF8 strings): + + +--------------+----+----------------------------------+----+--------------+ + | Python | -> | MessagePack (Tarantool/Lua) | -> | Python | + +==============+====+==================================+====+==============+ + | :obj:`bytes` | -> | `mp_str`_ (``string``) | -> | :obj:`bytes` | + +--------------+----+----------------------------------+----+--------------+ + | :obj:`str` | -> | `mp_str`_ (``string``) | -> | :obj:`bytes` | + +--------------+----+----------------------------------+----+--------------+ + | | -> | `mp_bin`_ (``binary``/``cdata``) | -> | :obj:`bytes` | + +--------------+----+----------------------------------+----+--------------+ + +Use :paramref:`~tarantool.Connection.params.use_list` to tune +behavior for `mp_array`_ (Lua ``table``) decoding. + +``use_list='True'`` (default): + + +--------------+----+-----------------------------+----+--------------+ + | Python | -> | MessagePack (Tarantool/Lua) | -> | Python | + +==============+====+=============================+====+==============+ + | :obj:`list` | -> | `mp_array`_ (``table``) | -> | :obj:`list` | + +--------------+----+-----------------------------+----+--------------+ + | :obj:`tuple` | -> | `mp_array`_ (``table``) | -> | :obj:`list` | + +--------------+----+-----------------------------+----+--------------+ + +``use_list='False'``: + + +--------------+----+-----------------------------+----+--------------+ + | Python | -> | MessagePack (Tarantool/Lua) | -> | Python | + +==============+====+=============================+====+==============+ + | :obj:`list` | -> | `mp_array`_ (``table``) | -> | :obj:`tuple` | + +--------------+----+-----------------------------+----+--------------+ + | :obj:`tuple` | -> | `mp_array`_ (``table``) | -> | :obj:`tuple` | + +--------------+----+-----------------------------+----+--------------+ + +Tarantool implements several `extension types`_. In Python, +they are represented with in-built and custom types: + + +-----------------------------+----+-------------+----+-----------------------------+ + | Python | -> | Tarantool | -> | Python | + +=============================+====+=============+====+=============================+ + | :obj:`decimal.Decimal` | -> | `DECIMAL`_ | -> | :obj:`decimal.Decimal` | + +-----------------------------+----+-------------+----+-----------------------------+ + | :obj:`uuid.UUID` | -> | `UUID`_ | -> | :obj:`uuid.UUID` | + +-----------------------------+----+-------------+----+-----------------------------+ + | :class:`tarantool.Datetime` | -> | `DATETIME`_ | -> | :class:`tarantool.Datetime` | + +-----------------------------+----+-------------+----+-----------------------------+ + | :class:`tarantool.Interval` | -> | `INTERVAL`_ | -> | :class:`tarantool.Interval` | + +-----------------------------+----+-------------+----+-----------------------------+ + +Request response +---------------- + +Server requests (except for :meth:`~tarantool.Connection.ping`) +return :class:`~tarantool.response.Response` instance in case +of success. + +:class:`~tarantool.response.Response` is inherited from +:class:`collections.abc.Sequence`, so you can index response data +and iterate through it as with any other serializable object. + +.. _Tarantool data model documentation: https://www.tarantool.io/en/doc/latest/concepts/data_model/ +.. _MessagePack: https://msgpack.org/ +.. _Lua versus MessagePack: https://www.tarantool.io/en/doc/latest/concepts/data_model/value_store/#lua-versus-msgpack +.. _Python MessagePack: https://pypi.org/project/msgpack/ +.. _mp_str: https://github.com/msgpack/msgpack/blob/master/spec.md#str-format-family +.. _mp_bin: https://github.com/msgpack/msgpack/blob/master/spec.md#bin-format-family +.. _mp_array: https://github.com/msgpack/msgpack/blob/master/spec.md#array-format-family +.. _extension types: https://www.tarantool.io/en/doc/latest/dev_guide/internals/msgpack_extensions/ +.. _DECIMAL: https://www.tarantool.io/en/doc/latest/dev_guide/internals/msgpack_extensions/#the-decimal-type +.. _UUID: https://www.tarantool.io/en/doc/latest/dev_guide/internals/msgpack_extensions/#the-uuid-type +.. _DATETIME: https://www.tarantool.io/en/doc/latest/dev_guide/internals/msgpack_extensions/#the-datetime-type +.. _INTERVAL: https://www.tarantool.io/en/doc/latest/dev_guide/internals/msgpack_extensions/#the-interval-type diff --git a/doc/guide.rst b/doc/guide.rst deleted file mode 100644 index d4d8aab9..00000000 --- a/doc/guide.rst +++ /dev/null @@ -1,286 +0,0 @@ -.. encoding: utf-8 - -Developer's guide -================= - -Basic concepts --------------- - -Spaces -^^^^^^ - -A space is a collection of tuples. -Usually, tuples in one space represent objects of the same type, -although not necessarily. - -.. note:: Spaces are analogous to tables in traditional (SQL) databases. - -Spaces have integer identifiers defined in the server configuration. -One of the ways to access a space as a named object is by using the method -:meth:`Connection.space() ` -and an instance of :class:`~tarantool.space.Space`. - -Example:: - - >>> customer = connection.space(0) - >>> customer.insert(('FFFF', 'Foxtrot')) - - -Field types -^^^^^^^^^^^ - -Three field types are supported in Tarantool: ``STR``, ``NUM``, and ``NUM64``. -These types are used only for index configuration. -They are neither saved in the tuple data nor transferred between the client and the server. -Thus, from the client point of view, fields are raw byte arrays -without explicitly defined types. - -For a Python developer, it is much easier to use native types: -``int``, ``long``, ``unicode`` (``int`` and ``str`` for Python 3.x). -For raw binary data, use ``bytes`` (in this case, type casting is not performed). - -Tarantool data types corresponds to the following Python types: - • ``RAW`` - ``bytes`` - • ``STR`` - ``unicode`` (``str`` for Python 3.x) - • ``NUM`` - ``int`` - • ``NUM64`` - ``int`` or ``long`` (``int`` for Python 3.x) - -To enable automatic type casting, please define a schema for the spaces: - - >>> import tarantool - >>> schema = { - 0: { # Space description - 'name': 'users', # Space name - 'default_type': tarantool.STR, # Type that is used to decode fields not listed below - 'fields': { - 0: ('numfield', tarantool.NUM), # (field name, field type) - 1: ('num64field', tarantool.NUM64), - 2: ('strfield', tarantool.STR), - #2: { 'name': 'strfield', 'type': tarantool.STR }, # Alternative syntax - #2: tarantool.STR # Alternative syntax - }, - 'indexes': { - 0: ('pk', [0]), # (name, [field_no]) - #0: { 'name': 'pk', 'fields': [0]}, # Alternative syntax - #0: [0], # Alternative syntax - } - } - } - >>> connection = tarantool.connect(host = 'localhost', port=33013, schema = schema) - >>> demo = connection.space('users') - >>> demo.insert((0, 12, u'this is a unicode string')) - >>> demo.select(0) - [(0, 12, u'this is a unicode string')] - -As you can see, original "raw" fields were cast to native types as defined in the schema. - -A Tarantool tuple can contain any number of fields. -If some fields are not defined, then ``default_type`` will be used. - -To prevent implicit type casting for strings, use the ``RAW`` type. -Raw byte fields should be used if the application uses binary data -(like images or Python objects packed with ``pickle``). - -You can also specify a schema for CALL results: - - >>> ... - # Copy schema decription from the 'users' space - >>> connection.call("box.select", '0', '0', 0L, space_name='users'); - [(0, 12, u'this is unicode string')] - # Provide schema description explicitly - >>> field_defs = [('numfield', tarantool.NUM), ('num64field', tarantool.NUM)] - >>> connection.call("box.select", '0', '1', 184L, field_defs = field_defs, default_type = tarantool.STR); - [(0, 12, u'this is unicode string')] - -.. note:: - - Python 2.6 adds :class:`bytes` as a synonym for the :class:`str` type, and it also supports the ``b''`` notation. - - -.. note:: **utf-8** is always used for type conversion between ``unicode`` and ``bytes``. - - - -Request response -^^^^^^^^^^^^^^^^ - -Requests (:meth:`insert() `, -:meth:`delete() `, -:meth:`update() `, -:meth:`select() `) return a -:class:`~tarantool.response.Response` instance. - -The class :class:`~tarantool.response.Response` inherits from `list`, -so a response is, in fact, a list of tuples. - -In addition, a :class:`~tarantool.response.Response` instance has the ``rowcount`` attribute. -The value of ``rowcount`` equals to the number of records affected by the request. -For example, for :meth:`delete() `, -the request ``rowcount`` equals to ``1`` if a record was deleted. - - - -Connect to a server -------------------- - -To connect to a server, use the :meth:`tarantool.connect` method. -It returns a :class:`~tarantool.connection.Connection` instance. - -Example:: - - >>> import tarantool - >>> connection = tarantool.connect("localhost", 33013) - >>> type(connection) - - - - -Data manipulation ------------------ - -Tarantool supports four basic operations: -**insert**, **delete**, **update** and **select**. - - -Inserting and replacing records -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -To insert or replace records, use the :meth:`Space.insert() ` -method. - -Example:: - - >>> user.insert((user_id, email, int(time.time()))) - -The first element of a tuple is always its unique primary key. - -If an entry with the same key already exists, it will be replaced -without any warning or error message. - -.. note:: For ``insert`` requests, ``Response.rowcount`` always equals ``1``. - - -Deleting records -^^^^^^^^^^^^^^^^ - -To delete records, use the :meth:`Space.delete() ` method. - -Example:: - - >>> user.delete(primary_key) - -.. note:: If the record was deleted, ``Response.rowcount`` equals ``1``. - If the record was not found, ``Response.rowcount`` equals ``0``. - - -Updating records -^^^^^^^^^^^^^^^^ - -An *update* request in Tarantool allows updating multiple -fields of a tuple simultaneously and atomically. - -To update records, use the :meth:`Space.update() ` -method. - -Example:: - - >>> user.update(1001, [('=', 1, 'John'), ('=', 2, 'Smith')]) - -In this example, fields ``1`` and ``2`` are assigned new values. - -The :meth:`Space.update() ` method allows changing -multiple fields of the tuple at a time. - -Tarantool supports the following update operations: - • ``'='`` – assign new value to the field - • ``'+'`` – add argument to the field (*both arguments are treated as signed 32-bit ints*) - • ``'^'`` – bitwise AND (*only for 32-bit integers*) - • ``'|'`` – bitwise XOR (*only for 32-bit integers*) - • ``'&'`` – bitwise OR (*only for 32-bit integers*) - • ``'splice'`` – implementation of `Perl splice `_ - - -.. note:: The 0th field of the tuple cannot be updated, because it is the primary key. - -.. seealso:: See :meth:`Space.update() ` documentation for details. - -.. warning:: The ``'splice'`` operation is not implemented yet. - - -Selecting records -^^^^^^^^^^^^^^^^^ - -To select records, use the :meth:`Space.select() ` method. -A *SELECT* query can return one or many records. - - -.. rubric:: Select by primary key - -Select a record using its primary key, ``3800``:: - - >>> world.select(3800) - [(3800, u'USA', u'Texas', u'Dallas', 1188580)] - - -.. rubric:: Select by a secondary index - -:: - - >>> world.select('USA', index=1) - [(3796, u'USA', u'Texas', u'Houston', 1953631), - (3801, u'USA', u'Texas', u'Huston', 10000), - (3802, u'USA', u'California', u'Los Angeles', 10000), - (3805, u'USA', u'California', u'San Francisco', 776733), - (3800, u'USA', u'Texas', u'Dallas', 1188580), - (3794, u'USA', u'California', u'Los Angeles', 3694820)] - - -The argument ``index=1`` indicates that a secondary index (``1``) should be used. -The primary key (``index=0``) is used by default. - -.. note:: Secondary indexes must be explicitly declared in the server configuration. - - -.. rubric:: Select by several keys - -.. note:: This conforms to ``where key in (k1, k2, k3...)``. - -Select records with primary key values ``3800``, ``3805`` and ``3796``:: - - >>> world.select([3800, 3805, 3796]) - [(3800, u'USA', u'Texas', u'Dallas', 1188580), - (3805, u'USA', u'California', u'San Francisco', 776733), - (3796, u'USA', u'Texas', u'Houston', 1953631)] - - -.. rubric:: Retrieve a record by using a composite index - -Select data on cities in Texas:: - - >>> world.select([('USA', 'Texas')], index=1) - [(3800, u'USA', u'Texas', u'Dallas', 1188580), (3796, u'USA', u'Texas', u'Houston', 1953631)] - - -.. rubric:: Select records by explicitly specifying field types - -Tarantool has no strict schema, so all fields are raw binary byte arrays. -You can specify field types in the ``schema`` parameter of the connection. - -Call server-side functions --------------------------- - -A server-side function written in Lua can select and modify data, -access configuration, and perform administrative tasks. - -To call a stored function, use the -:meth:`Connection.call() ` method. -(This method has an alias, :meth:`Space.call() `.) - -Example:: - - >>> server.call("box.select_range", (1, 3, 2, 'AAAA')) - [(3800, u'USA', u'Texas', u'Dallas', 1188580), (3794, u'USA', u'California', u'Los Angeles', 3694820)] - -.. seealso:: - - Tarantool documentation » `Insert one million tuples with a Lua stored procedure `_ diff --git a/doc/index.rst b/doc/index.rst index 93095b01..a3be248e 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -5,22 +5,16 @@ Python client library for Tarantool :Version: |version| -.. sidebar:: Download - - * `PyPI`_ - * `GitHub`_ - - **Install** - - .. code-block:: bash - - $ pip install tarantool +`Tarantool`_ is an in-memory computing platform originally designed by +`VK`_ and released under the terms of `BSD license`_. +Install Tarantool Python connector with ``pip`` (`PyPI`_ page): -`Tarantool`_ is a damn fast in-memory computing platform originally designed by -`VK`_ and released under the terms of `BSD license`_. +.. code-block:: bash + $ pip install tarantool +Source code is available on `GitHub`_. Documentation ------------- @@ -28,11 +22,10 @@ Documentation :maxdepth: 1 quick-start - guide + dev-guide .. seealso:: `Tarantool documentation`_ - API Reference ------------- .. toctree:: @@ -52,8 +45,6 @@ API Reference api/submodule-space.rst api/submodule-utils.rst - - .. Indices and tables .. ================== .. @@ -61,7 +52,6 @@ API Reference .. * :ref:`modindex` .. * :ref:`search` - .. _`Tarantool`: .. _`Tarantool homepage`: https://tarantool.io .. _`Tarantool documentation`: https://www.tarantool.io/en/doc/latest/ diff --git a/doc/quick-start.rst b/doc/quick-start.rst index 4f024ef3..0f22a7bf 100644 --- a/doc/quick-start.rst +++ b/doc/quick-start.rst @@ -4,94 +4,199 @@ Quick start Connecting to the server ------------------------ -Create a connection to the server:: +Create a connection to the server: + +.. code-block:: python >>> import tarantool - >>> server = tarantool.connect("localhost", 33013) + >>> conn = tarantool.Connection('localhost', 3301, user='user', password='pass') +Data manipulation +----------------- -Creating a space instance -------------------------- +Select +^^^^^^ -An instance of :class:`~tarantool.space.Space` is a named object to access -the key space. +:meth:`~tarantool.Connection.select` a tuple with id ``'AAAA'`` from +the space ``demo`` using primary index: -Create a ``demo`` object that will be used to access the space ``cool_space`` :: +.. code-block:: python - >>> demo = server.space(cool_space) + >>> resp = conn.select('demo', 'AAAA') + >>> len(resp) + 1 + >>> resp[0] + ['AAAA', 'Alpha'] -All subsequent operations with ``cool_space`` are performed using the methods of ``demo``. +:meth:`~tarantool.Connection.select` a tuple with secondary index +key ``'Alpha'`` from the space ``demo`` with secondary index ``sec``: +.. code-block:: python -Data manipulation ------------------ + >>> resp = conn.select('demo', 'Alpha', index='sec') + >>> resp + - ['AAAA', 'Alpha'] -Select +Insert ^^^^^^ -Select one single record with id ``'AAAA'`` from the space ``demo`` -using primary key (index zero):: +:meth:`~tarantool.Connection.insert` the tuple ``('BBBB', 'Bravo')`` +into the space ``demo``: - >>> demo.select('AAAA') +.. code-block:: python -Select several records using primary index:: + >>> conn.insert('demo', ('BBBB', 'Bravo')) + - ['BBBB', 'Bravo'] - >>> demo.select(['AAAA', 'BBBB', 'CCCC']) - [('AAAA', 'Alpha'), ('BBBB', 'Bravo'), ('CCCC', 'Charlie')] +Throws an error if there is already a tuple with the same primary key. +.. code-block:: python -Insert -^^^^^^ + >>> try: + ... conn.insert('demo', ('BBBB', 'Bravo')) + ... except Exception as exc: + ... print(exc) + ... + (3, 'Duplicate key exists in unique index "pk" in space "demo" with old tuple - ["BBBB", "Bravo"] and new tuple - ["BBBB", "Bravo"]') + +Replace +^^^^^^^ -Insert the tuple ``('DDDD', 'Delta')`` into the space ``demo``:: +:meth:`~tarantool.Connection.replace` inserts the tuple +``('CCCC', 'Charlie')`` into the space ``demo``, if there is no tuple +with primary key ``'CCCC'``: - >>> demo.insert(('DDDD', 'Delta')) +.. code-block:: python -The first element is the primary key for the tuple. + >>> conn.replace('demo', ('CCCC', 'Charlie')) + - ['CCCC', 'Charlie'] +If there is already a tuple with the same primary key, replaces it: + +.. code-block:: python + + >>> conn.replace('demo', ('CCCC', 'Charlie-2')) + - ['CCCC', 'Charlie-2'] Update ^^^^^^ -Update the record with id ``'DDDD'`` placing the value ``'Denver'`` -into the field ``1``:: +:meth:`~tarantool.Connection.update` the tuple with id ``'BBBB'`` placing +the value ``'Bravo-2'`` into the field ``1``: + +.. code-block:: python + + >>> conn.update('demo', 'BBBB', [('=', 1, 'Bravo-2')]) + - ['BBBB', 'Bravo-2'] + +Field numeration starts from zero, so the field ``0`` is the first element +in the tuple. Tarantool 2.3.1 and newer supports field name identifiers. + +Upsert +^^^^^^ + +:meth:`~tarantool.Connection.upsert` inserts the tuple, if tuple with +id ``'DDDD'`` not exists. Otherwise, updates tuple fields. + +.. code-block:: python - >>> demo.update('DDDD', [(1, '=', 'Denver')]) - [('DDDD', 'Denver')] + >>> conn.upsert('demo', ('DDDD', 'Delta'), [('=', 1, 'Delta-2')]) -To find the record, :meth:`~tarantool.space.Space.update` always uses -the primary index. -Field numeration starts from zero, so the field ``0`` is the first element in the tuple. + >>> conn.select('demo', 'DDDD') + - ['DDDD', 'Delta'] + >>> conn.upsert('demo', ('DDDD', 'Delta'), [('=', 1, 'Delta-2')]) + >>> conn.select('demo', 'DDDD') + - ['DDDD', 'Delta-2'] Delete ^^^^^^ -Delete a single record identified by id ``'DDDD'``:: +:meth:`~tarantool.Connection.delete` a tuple identified by id ``'AAAA'``: - >>> demo.delete('DDDD') - [('DDDD', 'Denver')] +.. code-block:: python -To find the record, :meth:`~tarantool.space.Space.delete` always uses -the primary index. + >>> conn.delete('demo', 'AAAA') + - [('AAAA', 'Alpha')] +Creating a space instance +------------------------- + +An instance of :class:`~tarantool.space.Space` is a named object to access +the key space. + +Create a ``demo`` object that will be used to access the space +with id ``'demo'``: + +.. code-block:: python + + >>> demo = conn.space('demo') + +You can use the space instance to do data manipulations without +specifying space id. + +.. code-block:: python + + >>> demo.select('AAAA') + - ['AAAA', 'Alpha'] + >>> demo.insert(('BBBB', 'Bravo')) + - ['BBBB', 'Bravo'] Call server-side functions -------------------------- -One of the ways to call a stored function is using -:meth:`Connection.call() `:: +:meth:`~tarantool.Connection.call` a stored Lua procedure: + +.. code-block:: python - >>> server.call("box.select_range", (0, 0, 2, 'AAAA')) - [('AAAA', 'Alpha'), ('BBBB', 'Bravo')] + >>> conn.call("my_add", (1, 2)) + - 3 -Another way is using -:meth:`Space.call() `:: +Evaluate Lua code +----------------- + +:meth:`~tarantool.Connection.eval` arbitrary Lua code on a server: + +.. code-block:: python + + >>> lua_code = r""" + ... local a, b = ... + ... return a + b + ... """ + >>> conn.eval(lua_code, (1, 2)) + - 3 + +Execute SQL query +----------------- + +:meth:`~tarantool.Connection.execute` SQL query on a Tarantool server: - >>> demo = server.space(``cool_space``) - >>> demo.call("box.select_range", (0, 0, 2, 'AAAA')) - [('AAAA', 'Alpha'), ('BBBB', 'Bravo')] +.. code-block:: python -The method :meth:`Space.call() ` is just -an alias for -:meth:`Connection.call() `. + >>> conn.execute('insert into "demo" values (:id, :name)', {'id': 'BBBB', 'name': 'Bravo'}) + + +Connecting to a cluster of servers +---------------------------------- + +Create a connection to several servers: + +.. code-block:: python + + >>> import tarantool + >>> conn = tarantool.ConnectionPool( + ... [{'host':'localhost', 'port':3301}, + ... {'host':'localhost', 'port':3302}], + ... user='user', password='pass') + +:class:`~tarantool.ConnectionPool` is best suited to work with +a single replicaset. Its API is the same as a single server +:class:`~tarantool.Connection`, but requests support ``mode`` +parameter (a :class:`tarantool.Mode` value) to choose between +read-write and read-only pool instances: + +.. code-block:: python + + >>> resp = conn.select('demo', 'AAAA', mode=tarantool.Mode.PREFER_RO) + >>> resp + - ['AAAA', 'Alpha'] From 14b728b29b2aaea109895135e5dcc37a6cbddaf5 Mon Sep 17 00:00:00 2001 From: Georgy Moiseev Date: Fri, 7 Oct 2022 14:25:00 +0300 Subject: [PATCH 11/14] doc: add favicon Part of #67 --- .../favicon/apple-touch-icon-114x114.png | Bin 0 -> 10003 bytes .../favicon/apple-touch-icon-120x120.png | Bin 0 -> 10722 bytes .../favicon/apple-touch-icon-144x144.png | Bin 0 -> 14138 bytes .../favicon/apple-touch-icon-152x152.png | Bin 0 -> 15200 bytes .../favicon/apple-touch-icon-57x57.png | Bin 0 -> 3743 bytes .../favicon/apple-touch-icon-60x60.png | Bin 0 -> 3994 bytes .../favicon/apple-touch-icon-72x72.png | Bin 0 -> 5145 bytes .../favicon/apple-touch-icon-76x76.png | Bin 0 -> 5571 bytes doc/_static/favicon/generate-png.sh | 9 ++ doc/_static/favicon/icon-128x128.png | Bin 0 -> 11989 bytes doc/_static/favicon/icon-16x16.png | Bin 0 -> 1037 bytes doc/_static/favicon/icon-196x196.png | Bin 0 -> 21876 bytes doc/_static/favicon/icon-32x32.png | Bin 0 -> 1901 bytes doc/_static/favicon/icon-96x96.png | Bin 0 -> 7747 bytes doc/_static/favicon/icon.svg | 99 ++++++++++++++++++ doc/conf.py | 92 +++++++++++++++- requirements-doc.txt | 1 + 17 files changed, 199 insertions(+), 2 deletions(-) create mode 100644 doc/_static/favicon/apple-touch-icon-114x114.png create mode 100644 doc/_static/favicon/apple-touch-icon-120x120.png create mode 100644 doc/_static/favicon/apple-touch-icon-144x144.png create mode 100644 doc/_static/favicon/apple-touch-icon-152x152.png create mode 100644 doc/_static/favicon/apple-touch-icon-57x57.png create mode 100644 doc/_static/favicon/apple-touch-icon-60x60.png create mode 100644 doc/_static/favicon/apple-touch-icon-72x72.png create mode 100644 doc/_static/favicon/apple-touch-icon-76x76.png create mode 100755 doc/_static/favicon/generate-png.sh create mode 100644 doc/_static/favicon/icon-128x128.png create mode 100644 doc/_static/favicon/icon-16x16.png create mode 100644 doc/_static/favicon/icon-196x196.png create mode 100644 doc/_static/favicon/icon-32x32.png create mode 100644 doc/_static/favicon/icon-96x96.png create mode 100644 doc/_static/favicon/icon.svg diff --git a/doc/_static/favicon/apple-touch-icon-114x114.png b/doc/_static/favicon/apple-touch-icon-114x114.png new file mode 100644 index 0000000000000000000000000000000000000000..054be521bdfd2c362b713cb479d52ab472406ef0 GIT binary patch literal 10003 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

7V>FYs7+`TsSkAnepU$bj<2C51=sf+J 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 literal 0 HcmV?d00001 diff --git a/doc/_static/favicon/apple-touch-icon-152x152.png b/doc/_static/favicon/apple-touch-icon-152x152.png new file mode 100644 index 0000000000000000000000000000000000000000..1b4cda08a833ea58c6b4e9dafdf36c063120ae2d GIT binary patch literal 15200 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 NNQlac)ClPZ{tx_1`{V!s literal 0 HcmV?d00001 diff --git a/doc/_static/favicon/apple-touch-icon-57x57.png b/doc/_static/favicon/apple-touch-icon-57x57.png new file mode 100644 index 0000000000000000000000000000000000000000..b2a09aaa64e9aed9d5090831b1266b4acd636dc1 GIT binary patch literal 3743 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 literal 0 HcmV?d00001 diff --git a/doc/_static/favicon/apple-touch-icon-60x60.png b/doc/_static/favicon/apple-touch-icon-60x60.png new file mode 100644 index 0000000000000000000000000000000000000000..1588e24fb9221b3d01db4af23ad2f38c6b21b2da GIT binary patch literal 3994 zcmZ{nS2P?9)5n*^>Y@aT=pv$I7g0Bl-g^yEVzt#*FClujNYqu6=+UFIh!W9z4a;s2 zQJ$zl)V!X{@8X+tW`1Yp%zx%;E+#==SB;9Cg&Y6?P-&>LCCS9t{9o-j%|)0RTS;0I+Lwr$Pe&OrH522LBBr zvDH#jzT=%;C}G0xK<24#?gIc&_y6YvgUAZ|yCTR}Lq`R)MGm6i7E*lMdbfqzUPD>I z*lBG%Zv|<&==U|u^_`fHqk1Bf!Q6PJ+i(km72V5q%`U0z7V0U+M~NNVMiwO=)|bY= zYyQe}EH8GmDiAU|MYPCA5IS3Z`?$K$Vmp}fU_OQ0Lc;IEqqlTOrHLQ;U8Wpo()nPQ zA+Vuu8Znz^pyNd0%`fEY*|KYNvMq^RZp_<;_gfI33X_D54I2a`m~!*c*H9adC1!PE zIO&H-_=P&*zrCKMQ3(W*AeW0UPEu8%-Ti8^2BI+@ED0udXpoa5&EYxiN1h@n0~g_q zk5QbfFWGVDrQDuD)W(86($POX_t_ldBDmfYW}G0jI1Q*p`u3x?NTQboXdyGrq!3C} z6f-`8VJQ5eD!m)kPe%wQ{u^;;m(J5#B#}k^RMx1Ejj1A*Nuis}pE{6eDNe&u|6X@M z|As0OoU!VjoE|pB+sR1KNQnlP(YD8b3z+&%M^~D^jI%?J`b1}D))CW$b!AJ{-k!P2gA@PnvKIdcI!< z`zHsOMOw72)$=Y2c78F|zfeBAcXs9%#`*8(82#b_p+joML62q7J?w=k~(4d$g-6>1uQddy90^~r2bjC}92`%~4PT`#!~(+!QnCkN0taZ%8QSjOh@nfXM($8oNz_1pMH zL%^*W#lzRZC@TW@a9Doct_5`I-ou~wzWv2-_*eW$i_)V*C&QtUWt;F4C78mz1)(44 z1j;sopMHoEu=@T}p@#wlnMik-XWo$N$yHc+yiQt z&<1cHnt?S}m|q{z@+l;#wYj7R;n0@fW619Khb_W`NQW8=^y`bnCE+P_q!uC#aZqj> zAri2ypxle-D7`eiGGozx;AJD$-j@(bHb!BYhS$>-$A~pw4G<_%SIj)|#HIi-0UP`1 z%adgTzgH*07uc=!e`s=mRWgy7wO6~6ni1xgp~naswmz)K?^$Oy{RFb$tThNZO*?ap zb_K^0x8@@ZPt4k>PtPeV#2s9!94_T(mER-`jk+)~ zbBUm}sja|!6S-^G;eJz3U zEB#o$rR5b#JjYtxk{=D!AST_&)3@{L(+=^U9hlJE3cUeptREz4;gk!d3${Iyf;r;H zKPZ=&a+jhQEeKX-3Px?hK{^m3Q;Wl8lQvPBQtB=PD*3;E1OmV3>coN zinWZL1>s|;kX@2Mq;*vDLuK8ZN5Z0p1!e0llq&3A0!3h#*vi2!TS?0-oS0> z+DAJyU%lG{dwq8$Sv2)-P5c2XeT^L6otK>kH6I=OgrM+QL$vfj)%2V!2em>Vsm)m^LnZa>?g zG}GbcJanZRV-0-b)kJ8nGANobWjtlHc(rWN%=MKc;~uuNRnxt_w^yn-2F|P_Of1%f zrMGB%QbwBG@;G3rKBvf$*FHo#_eaPDV}D7t{#I^^_pqLqIlr@c3H0Vn5i?XzI%iSH z5$c~B7W7l*Ir7P@wpdCALy}Od!N74~)xw8{=yPMtua?6(7gZI*!sNQ6>|uyMBieztnMC+!O-&2rDf((UaM~$0X>T%2Dr?&iEq-JfN znDnpkdp-&Z58|X)s4O&Kf%W=L4EuSxRnA<@@==%&A!wAFd?24^Hg9%_MGu0Pzjuu1 zZh0+Ad|CBpv(}S2h7@^4r(}TBWhO+Yu18+#EhUDS)JmyYYvLFh0UPXegAr= ze~X!CuXNo@P${=+aqsA2eo@b18Mq(9RmHolWCx=i*Pm9k`POjiI&8npnK4J{eub~EABUkkC&76NbFIB} zJ+|x!&3VIw2tLIwq-z4%Gv=$1K{yMLqf8+3`64wxan%DCt5yS-;eOa_-()%I8{mZQ zgs8EL*Six4&ANtVQbW@t4MC|&Kg|4sV`ZP*wZ*LbK+Jwb6FFr?+R{!VoSMszQmr?A>)hgLpOwJ;iZTlQy4M$VCrTQDL=yPEGyxxnXATDzz3BQgPF!8cSMiF_q-O**)HLit(P8 zNR_nH8PP&|A1hKGb2+-oQq&K0&)TRfDL<>& z^5CwkKvuRmBb%G8wc{9kVsAS8(noPVdSP;35ziBfz;9;J?d}|429#^){+&m3mAh_ z39EQJA0kdy_S?ZVL*-BdV~_~pOCoCb^mQHMf+>x2-)G@BZIp|Od+@BxoOw0#ER9$! zp+S2@h7lum%6C2ND0s}Mk2=#-%wwDRZ&~@YVpZZgAgE7KTPMv^4^{TXGAr)SCzz-# zPdaj{NtMl8LIi-5xBnS_0DQOd9UaHNr?6U={oRzzg3%Q0;O@&z9lK?i=jn9ep>`V_ z#->xui=X_x%z~V}3@UacsLL@fnl6UU1emz-UDEf;>~P zbY~2`O!3~ab3(&94beU>#itR}D>7Bo1P{c7A8{vy=x?SG9OeKIr}GE@J$+NNLOz59 z*a7)QFubG2fiKTbDBs;XM}53#p|wMse65LPf@hdyRUCK2B4cys=p5_lLN||OsI)L) zn*^RN>JBG!-TypmlplgXWupom~;%mTZHW-nh_UmEkDSD;A zUD~L7gpVa-C|+tpsBR>E=kLhJcI_f(yf7Y9vh$Z#MWForJZC~J08kkZ1MW-XVV+XR zULE6P=P_QKqwq^DZt0tSxi z#VXDh_b+BH=1O26xH=gVPR;VEH_wm0ymaUvz<~QEbFz zq_1;}<%#WxcDyyxa8GC`r5WB^ID~A#bFy&@=j(M#aYxR1DWMt%Ll7!w!8x0`Pwu sjF67L0dQ{zKtMo%h_i>Qj~(38K?LcI$lHgp+%*9-RCJYV6>TE_2O+st00000 literal 0 HcmV?d00001 diff --git a/doc/_static/favicon/apple-touch-icon-72x72.png b/doc/_static/favicon/apple-touch-icon-72x72.png new file mode 100644 index 0000000000000000000000000000000000000000..26b1f1a49cd1f2732574dc378da050b4b2c203ac GIT binary patch literal 5145 zcmZ{oRag^_*T*-yJ4VSsq;qsi_mJ2~rBixU8bm@Gq#Lmi5Q)*kL}HYH z@aK2=UcBdd&iOnizR$Tk7bnTsNQaV)l?(s?Q0nPwn%+_OKPMr&i}3vxojVY?sX^5M zfTj%cYX`!+9PFfP3IzZ{c>#c^SODPit`zkP00yaTD9u2ld4KsEHA1IK);o$rdofqGCa;%zcwa&8gn{Dd3;fNn!iQw)u`dhdn<q&212Z5v z3CzMu{Fj@+PbIqyKjPp`huJY%OzG0=kCA1<&j~mi$U9>SruyyhUaCO+VAoUo$AUA| z6r0~L`2B>KM<$}#iXk1M5^MrI(^0k>n~XhIDpH1-pRMrA`w%-{ixD#zHV+7`dTVd<*AooNF@^bYV!EAYDC=73l$x-FV=Bibs_L12ZYw1&Ns z($G?Fy>z?Z{iSgN{v5#}3(qBCBPhYVM5kkWb%i-=U9WVP$H=%zD8Pd0m=Y2GYr^-pzAd@ZC=1#CrU<3tcEhjsFoO~;V zQgZjZym2J_&#X`JH-No?60V4dm52%PsnUsMmip^&g&Hf$O}X!ryP!?Fl|LmPboF#? z)~%XBVUo`ouBe#i#Yrrt08BwD;w4anxk#0wp+uy$cqdX^(QbrbumY5YV~^btM3ktz zxzcx_E}TSFI#x3zuA(b|3&I6h1i?3X^hb>0-1jOz?>FsDCKz=JG*G*QeK%Xro3+3I z6Mq9i2-DU2f>jir7KnQne` zmpN)sq*M-No%>A)1FG79)9mjbU%vOT=S>i}+H{D17fDi(q{bWlm1Z>Jq3Et*{Cr*o zXr(SUPjM_mxMr86%) z=<4|R z`n(rSUuzPg{FKdE;rUQdc-)?+Z(hzF7+d8p@a8BbMAu{?+_?X7-HDKO! z7%FlU-njK)|ICRNoXn!B#lX2zwf=>ZCxgtJoQC5XOa^EpDIliw_4=j3i(HZdmKSXP z?CzjB?*NMNO4&ZbjBFf^i4B|_4$$gF#fe;8y&4ePDb1qtdqSAP{#xv7(BE!mdZOtw z+T61BvDxA*!5%;M53~S<@#aRtz5=A85U^A&t0M5t`E6V5L_e_WE&iA0lkiWaM~iFD z9NI6IPn}C=&K_IvRsQT7a#i)ahvQbK?V06>ZwagIB6gw)q0IiLX4*A*3sP<*>r`at zt5kIjXH&jal8YjEr-m$=cPauk<_-43L&B(BwUsQlvO%D_0cVcd@ChTuge{`_RxtMJ z3D4vp({zJP^K=$yBylLW`VY*QV8lMjPfP0Vh?Q@QoCV`DP(}n9O|wy)JqH;&wY_l5 zxEh5QH^MLW$`D@c%6*p!ak^H^^&Djp)NqQ2NXtGXL zM4Dc!x&c-yC^l7a(LWd;syk^<5t>c6Xy-kMJ?Gl9VP2mN>x>iWc=l9LHGu72>8nqJ zu6tVYOjCg_g-IcCWH!^f@#ey1OtV}?Z}yDgXq<4f(Rh-(XC7mE6SrhJ@=G;6|2Py^Gxr%M9$XJDSL@Q^F{PVR_T z4E5qCj5s>^e(ezxDHB@uU5Ulf!yQ!)|3ybyt%9_N44+1DylKry81lwWaZXs z-!TjXDYfUzwGq(;#N(&zYFSEE&7DaT&C!eh(tsok1GG61$2s{DrD1uHw2)>|Ocxz^ z%YvB)HXv`9&ox6i`*a%9c{rAuVb39FGU{BIHA%Mj9^0vY+r3?`meA#@eW7&70ki-N zmw=TWYW*`uxZZmaGN#~k34(o$p)oWz+|Z==%U{q9M0pFF`cGC!8^4nM9st3n1;PoG z%;1{2QZ2(pl03;EE^ar0S3JBp&zjlpAZmsvN2xarLaFWn-8CleIrFs&0z=IcjUM;$ zyweuS2fONCN5E0swN1gP<1;gDvA?(|L-R^s7b=*ynVQSkEERf6Y?@X&=`gwKyk$ka z4V5(M0st}MPpjSELkkoS3~m*

&PmJIf6~CT&s}B7Z zj^ifjY+Wi?#yngp*_vBoonjLh>fG?s3a7C=(^V{l(s+F730OJZrCE!NK}cAyQRm2i z5NuBxUW2(ib$IIhtvg}8EwOVp*<+RT^9hsVtyF!=(bv^C+1GQFiE6>td$l&sIT|}M zFwRTRi*RqvxKSGsYcfZX-ZL-&uXQ=SXKYHjI3A|5~TXR@B%YV!!?~SYePH3Ul#Oc*NcfOk7 z5fl|(dQsbjNI(MCHJNkLii~LgNveANa?=<-8HHxQ!q(-Gv|(7WaJzDJ3AO{!4t` zw++=_dpC@_UxneRb=|88Te=5k0yAsf=i;n^z&s%JBGkqp%ESA3H@MV>Nw=+rBmtRc zzs+GijN1Lxr{v5<2(a)ZN4<M;*?YVv3_6;U zdWOdoTXofm(heM{VP=PGc)G{F2u0_N<<*@&m}!i4zo*)Ck*0F5-lT1lJ%fj6@uH%? zn|Va?7i_!A*Mj>YQ1+M7kA{US0$9xASrw6sOyDU$MOC~e96o5^U(hduBzLt){nO3w zfcvdCYO6HJ30>LV`o#sGr&<;3#Y62aVtC9Q!JzdIy+?K2GjU(u+Ei?b{qqW=h~n;B zXvDhA=1J)&9vNn(wgnah$d(nIJX4IMkeNMyxd=ZDE$w z3o<(GJ}tDtMqweG^RJsg3)9%+g^piC+ri zCm((bg!(Xyiu7Qv)hA}?l&DU7Oz8ZPHa)n{@7un=vxnjlLV-_Wy z1G`!9_;s(bB9n)oSy>F=rj8K9@#}D8Lpj{#-_Z`btdNxrS&#YnTf%EdV=8GH86+qq zvM#(JGybAH)8n^v1^(o-M{|KeUqfOfza6^03Qc*3?$#5T)J@fz#8OF5XE94qQjr#M zi&FglhJ71aV0P?UIQk69lSZ3=q!>%wk}~1+{v7n~9de%yE7Z@`_w9 zWs!K4vW7Wad+&F(hXYTz=SiF>#;|M@4?I4vf(IW2EV3mQ8+t10)-nxzopnEoM_)fr z9p}hBt)fbMurxu!2`?5r5fJls&Ei1FI}D{s4w3~~QK{MAiq`~Z=kYr)H;}-lIDTVK zj4E3w`MWndp|$n;|8CAhj))}$+l`-uBb@as>K|$Bwc}AJf)FZ7{JsffQwi`msJ2y* z*{n-pQN!QEK6)0L;pgGm>2HY+%v|+|%awXARTq+6l%qoTC#1KfailBRZp}iFQ3o>9zJ=S(*g8hD1)V!;TK9+bLotHH(jALH zp^mkpOA!M}`TAIqLh3>N5Ms*oO#f&~UY2Y)odh@44rQkIDH4ZUM+I9Y+9yud2IcA|$>}wT=gEh-qtSAm)pMg;} zg>8oJS-K?3#9K*D1j!#@f3HAQEuvpJ9tWEwvb-JC4~w?6J*U3>EEYQ^&HiE+C?x(= z;r#MF_8jPW?OxdeJ!)R-sh0^1E!wA$u?z;ExE#=i;ZCG6 zK^RKJ6CMa%00Q8_?!D38B7FjQt7r^DqtX@ez?i)lpF2l4My7Ayuf(|8ls< zP{#0O292g&X<7%%Id;~JU8KLr_MLK*n;@)oPHX8>)s9_m8#G*1{Yy;o&LjuEc++>U zbSGorl#nprExr>JG z=~Q8y5{hd^po-~%GTG$`cko3K+i21n*?C80d!bV{=Op|pQ;)DcbspWPb*ghy=+1v`8R6h(6?kR$d5ZSH2eEfwNHWBj3=)U3WRPxx#7Pxw@#>QyF_|Yy z)1MypHpJ82&zN{HB1WWc9pv%TEokM1)oS{~x9uBKp{wXQ9ms1oUOFAru#ZHpieun@ zw2?}OkmSL2#^ZlCk+|zee=2e~!Ii~cZ@axh%UZX(4fO1#$77#+KawBk=Zh_Hcdv{|i3u zlk?vRSpM5!=Ia_5?0|3q1P2F;diZz-I63&ai25SjP=AzI?}h++T1J`;>h>}J16s#= AX8-^I literal 0 HcmV?d00001 diff --git a/doc/_static/favicon/apple-touch-icon-76x76.png b/doc/_static/favicon/apple-touch-icon-76x76.png new file mode 100644 index 0000000000000000000000000000000000000000..be802bce850e7712f4183056c0e8d2d038929bc2 GIT binary patch literal 5571 zcmZ{oRaDfC)5m|*E{#h_tRP*w#ENuCFWnsi%OWY=wS+9)ASIxrv~){@gn)!dBOnq| z(&eA$^1XQHoSDy=_@23%n~Bm?S0o{%BLo0|L@3&V~Ka4Ga?b%Y5?HJ4glzI0Jyp@p??E_Cm#UpSO9=%1^`gIik=joUf0HBOhl9Sf;nV-oq zN?|qd91PyFvC;F?I~eg8mz;{72*qbLLQ~iDMBo#zaVT;EQWs0rcQki&XE-rYhZHoQ z=Lo|g_%yiqumZa=RgLl4krQK+Umb&F=Qeu!r}|4%5~0u}vmbhyLI<~7Mt56_@-+1~ zcTCoTA5w`a6#wS`Ej0&;fZ<^~tbC-Rp$IAjnkgKf|IUvR%bt?}emLbHgDpe2$M~jI zTGRLi{g7s5m?PG1WJQQ1b_Zt2DKX*89`+xPY~k8=VX=tN~3aaGMx3hZC#`YqIjRqr|J3~?cz0ECVKv4s5tcUIF3 zl&)z8NMi3}dFJMcU|T$X8dk=rjs1+|%5L>9=Sw*sg644L#Y*Y(YV{wVA5m8zXUv)n zi~Qs4L5rYetYsM==mzL()v6%DJFkK{!~zgB6e`2#jdMd_21Os17AEJ`pxGS3`xV? zu(vFMfmW#1t8<4ubW;O?Ce8pFk|-kAiP`a-82brhs5xhlc2Go=q59fm4@D1Qa_s6Y zBf*YEp+iKaxp*(+B7I_N?c9)hzBYvG{o0Hl(?jVxNdqXXPf}jrau`@sUI_LGX|*V; zjFg6QgCM+FR$tKpL_Q?e$xF+BC}aG#EPEhtSpL0C4XeCST9*$W5*~W1Wj@Fs=9gn7 zX^K@qpkL*SGu}`VqA}P;m`ErG1$WX*aCIN}$J$_#DY=`7pnFT|b{k(_oIT z1|q%G6&pf8RPeRDLPTEg^!<_w=ids)3>p;fnz&15ir`7hJQ`eRX#opeVDov<8;?a@ z^*k9P?mx|vYyFyTj;FT+_ZPf*F+yqgPd35%a8TMKGMKnx8@6BWr+JrHcmkihl?7E1 zcd(60dz`UyyRH-T-@9FbvWZTK`HDA>E3dApDah_p-?)F28s;JMl`&CQo)-XRGDTGn zL}r53#A0rLWi}+Q z2ptP-S;^TPid3_N>K+DhPdv~dW9Ywgs+Kd$&($p>#fCX2LTlGB6WNkwZ5+Ae_Qw@Bi;4{iyG}$60w0kDH3>S5Uj;}na_Nfr`Uz*v;!7l6P8BE z#}F0|6m=IqIplt_T(mXd1#>BD$10&;r`NrDX%DrPmo#^x)yU#4%#DA<)<>?)$inXd zdP>^D)DDRO+rGzr%wc}3K6QV$J|g{gbj`ey`cBaNKX!=Gd}|@BGSFkvevBE3&u|TL z^|lsVVYk}Xex}1I;zGo>f^k~E7ML4@*ouJnT+(@c)`NI)M?9xcJ9+hVq~E*m7RcbP=ymYI07mFE^zN1)r-9nVH*9y{Zv*|5ipCi)L=%oB;=U?- z9Busbi|rK$qaOH_L&LbLUs8LhTeMjU6zN_=+AJ__jbouE61qHvx8ODh4S+xR&~wSy z|E9BEW`$H<4VUkzE1`x-p+01fL8?r_&E{5YiNY+LAl&taQ;;$Dp2b!-K+Jx$t zBuOt%62`U0Kv@)pygkFU<5zWb4tZZjIEya?Vl=oNed)1p4skBN3$5NNP6*pW{|1r# zI!(oUac8-sC32XsC7aHcxbpn{MErv?gL~lulbhZ>`27!IR3wX0Yq-)!=9`?$ z{FEgVjgoZ%#{Jy&j@}QpCpu65Zn$8FNsn`W5VIe~5 zfp&W;6_z(`P#0$kL6bnP8on=k-&N8vP_88RY33LRrklw&(WN_>BFRowA=KWZFUWT` z8^=r7c98Ys7^S3XNbqOUe}TOVnYrpuOdnx*%|e2GT!k#4YQZbnO{@E&kr!OC3aJSA zG|F){yTa)?O-gEMDtQtliP_$j+wi$H-Q;ZeLs@}(g&1yHOG7Wd&w+9U!8zpfmh_88 z=i1Evs*}csf9=%&^b&g|@mFYQ~g( zC=)KB7kl+?g55HPAiV`)HXK%46tA57njpX7S)RmlxiENSA#s25Sm35n|NI*vUzVdz zT>8h~gghfPgdi2AEPUGNpVRL-vx0wi>!#Mq$k0!wZtgu@@Dk=cSgUovLT~0%N&QVG zBch2sQ=-Ga6QT%ciG=ITTQs&S%gHZ*n41R^-qn|+XZ2&hXTU5cIRNLncPtg!-zINTkR*9n~#05Ybb ztf!!`))6TY0vxIJ#YC-ScH|^7hnp>n_zS7RGcu68-LWFL89O7nDC6l9RYhT%<_rp` zMzCMMLw2%lOw1ANE1mQ-WD?g?*Z?TakNDbx?Xo<2{lI#44zb=dgQ>gKsPPzh3DJ(T zohR3qo+ihW%4ngP;Y1|Knnr)wmY0hBipNm;rlxWw!VW@(x424v7B!TgY=kLg=}w?k z{At@t=-{=u)fpOYEA6J)Z>u0L#O2WDPKhn&+|XSM!#^V>76}Nnl|{<)kK8orfxQ z|BfN6jzDCllJLlh#ZI#7^ny_PEs*p@d{1)6jLb*t$kT4ty^n}Yw{zF9mx1jAf6`$4 z6^)4zn*l#-lHTT$e&+URCv^#dql-4Y{5q^O$M(G?T^^^fSNEv|GsP?P&4}XEnZ&u<^`xTB;wk2{OVrenTHB#iFd#xvo zZ=a3t(eF@}>`X~k{RXZ$!J28YBgJ3KCna)|P%B!V%?Lw*0&Db``D)YaDi%vj82cq| z{1lhIWnEHjP5p4fvXNEYGJD-D2i|j^0ft7Njx9XFgB)BjysU+a_@<`-O+{i}0S6~& z+JiGfYvd9126+S3Z}@PqTR(tPO*d5HpC`kQ{b%&(u0w5>!TH#yOyWGS|Nwriy(7yH1Gf$F# zhF1Pn4oABgrX_8Es;$1J!pr)&el{$qHCvd%Fe--M7V;iIO#b}@oy%?Q@g~1YpW*K7 zg-qFCq8nxGIX5b2-nfbl(k>;wJ&lh#de)kZR)c~3lKu^SI9jfHj zzQzC9)_eAyz~QVx30(RV@5}HOQ8`Uk3X*utIzX^>Jp47aoBicQc7htmk7Nh&L>j3u z`+tiH{1V3QL2LsG8?U-u;3Eb(4fRv0UXCB-zBYMN10AXz1%h8GOgtYFtZaVQZdW%O zd?oVAR?mKVT-|M7yj{GtN6)CJY>d(JjhuC2z1Gs2HiEGc{XXQRc;_bIVv}#7hON6s zGP7R(CxTU$6QTAw)uZ!#MMSS6riSi1Cy+Fq+bnslGH%4Z?DE2pt>M~l(~*U`HvuwcSkp)}I}>V(#FcZ7eaWe0 zI@vDDi5{uLVaKQXFnN$Ws9kL`)PU@p9d0F!g4EK_7Kicx&mGxLxJL@L52E5vDEGYi zjIN4OCBBnYswkhIt>W!Qkn>GRz!qY0pDTMXwcb3@&nQ4@d(jJ7|ABwFfxBA$c?P}F zE6z_DfnW_BSHAetir5?n7nEPx+n@40bv-q+y=>Pw?6@C*R@#TD4D29}VhcIKXO^zh zf#8^VF|Ov{(qLfxqlMYVf{iI-(fr_qL}I4O9RJj`f`ZR!kLb%bzp%Hj(laWiqf}ir z^BK>&(aoG_TD^^7P4wU-3IKPAid^ z!9>V)tR1+}yhc-Thun*%L{PlX!Qk=nc%FZZdvA7XLG0YgrvlRF6Y+hBJ6eS_zjjMV zDTBuLXO-ulO#19%hmiUFiC8;6s@oCDs8f~0VN0&%@IPjAtE72+dxyw~%xA&{@AEox zwWcR$$;E7V$*NzOpk^D>{;s?WVKL#a ziCytS9ct%X{SK`s;WBmf?;3cTdTuD{{-O*%labDD%_Ac~M8Z>?2KyLeCsz>QWfQ_X z%0pi6(Y=HJRt!LQyr}apwfq-IE0*jCgSSwI@nD$@{?fTU*QGaAUQ?ZI5-#V&3LMwg zBqlK^;DYMfdK5rOfiWRPTT45=b;x z>`RB1u&?WG@_!N(mD9z^K7Yq$jJpJbNC0~zJ*)Xf#;F$_ra#SiV7Da!N7VeYL9t0F zxu+bTxSMQO@KYLlX z1*q2x!cEo2BhqZndzddrrQA&hTu18)3UobEGHe**X%`w?YgO`DXoVLxH3G|u%j&0H zrp9XWQ_w`NHcUhiPK*V=p(@ARS%DHPs)d)LeX##T&3A3~gX+rKw?!{iry)?yGS)8| z>!nH8LfCmRisV|$@IVVAQ(v>)WH~gCYTTn#D@$FcPcDqth0_e%s-`BzgvQU|V9BQG zl91I8w7q`leCeh>i>FSN)mNc|Qj2iK5ABVbU|y?Ib&dHk+Zt=?tYfs=OG1rF9aQWZ z58-8k2Uwy15SinDVh?p%z=sBkA43CeWo>ns=()QuBF~%axI_ulIN#Lby~U%d-4nb} zF68^*X=7C4u-j@8b(MHirGA_=dQ5RitVSMtVcRYHRG43k)EAl(7;Z1A_rf`>J#Gkg zbM<9viM~iS;HWFkB+nsx%un!YBC%E;psV2p}L$jc$rEVx_Rk&5k%X&|YQH4aO&CInLCDd#uKg;Sg#8CI z`AF<*M&Z}45qOy-+QlR{@!8J6isGp#ty*K{FEQtbUL$*jnL?U@L`m0OI1|+crg*ex zySWL{Vpn>92>6=tKYLKO*i^y4vZu6eVg|2h{l{C5V|k9AUp|NFSSbr#h6W9Z_TFe$ z6&(y0HB!~l6W=6g-tv@rquB>e>b<Ed*5DG@<=z(&}rMGL2yw~Tz4Zknb)7%YY!cs1xv78yO;K}dnz5X6#53~U_a;_>Bq#t>oT<4qU=`WP9*JjE*}B zDYsGemXotfniG}%L!}^iu-z9XpV{g9?oue2wfe?!EI#m}+0`!Q8*)$u^J*LG?y)!T ztsr+sgW~yK`};XmNLUcZx%CZE<%g?(VL|y;yN~{{20E4_`8yGf8%H z9x|KR$($%?d;|ahfGjH`q52Pl{xdk3f8&6#@x(s>T8b))0s!^#h;PQw|K_Bo zGOCIIfDiRQzfb_+b`| zPBPlA007Fse+Dt)SZ4Nb5!OvsQ4;nL0Tz**Q7p0L9}82itc0k#*V=iutS623jXy|A6Jh2T=Uc(jmF_%hw@~8du_v`f|2I;*tV(~dHwSEF}C{d`s5y6tZ z5y*^43kt@{NKpS#=sc*g3xFF6w1TSUSi@UM{QA4g0AkcxOgZPaQWW5l(vJv+{}DVH z%(nv+%>aP1(6S)KCE`Tml!YYt!1qA6!=ChKUz`uxO6&l?*Z%kvgI^55kyb?`*jA_w zm{o}8V5=06ly`dcDwgR0I*<-R!7X&=sGd{rm7fkNh&yh~GEo(=>^_$%PCMAy+YLC( zpRZh!2Qos6MN{eo69Qg8c!A-(yDA1r(-$j-BDJ++@-j$ns>YUif+X&A=k`1PDX3r( z;4{DrwH5JK+-!+So)2}DGKaeICjulrDPx)MuF1Z3#YKQdp@>B}r?CD30lvR#AgKC- z#>y=l`o7kr9Gcn{KghC|LS01qfMW;Z3afne=wdDQJ&~0mr6ANpr{x;9vZ3)LbuWaos@s*w5%?kIrlQhDFB$LBXP7da z`pYl*&r#YTe7W$9GrlKmqAsuS25fiHqO>5NcgU&m3i@zbnZGfM!7NY)fgP{~{f<5= z0@Dq(A-*!adY^ZwTU+z^*{6oM(w{lE-YeZp*3U5uODh5u5Iriczz#8H=r&*uVFf7! zH7{v~=tb~^`@}Iz@~snx%OVp$X^YpJ&4;M)ncj`*JQ*=7S}79OhOy~GJf0ko%0K7Fw(4)8G#$}A0|R< zj!4Y1kWt}GCK>1eUsB-J^`<@x0N{NjhvaZ`tUn>n*ZU}gyP9N-d^oB%kMO*Nk2n~PS457cx zSYGYxA5+`Qb-})j(2BrZP|OD`%-r6f)=UM-!S%lcW|!mc86`1pu(USuWIrp)ITPzf z-2#aduBcoE{w%Nw@=}@`^ow+*1~vP6Jqqmz$ox5?aeSehE~Xa+Idido!Ey;!Am#Yb z$Pzazj@u*XEsI~P6shvHmzyO z%0f6u)XJBvHDdqVw`|NAeUSaWf_;c=1QqQ9wD-)DSRoi)ngPGe(S*r!V+m;rMH2-I zStz0d)WbwV8-49pH!^6q?La6O!N_V(&~SbuuptkvsT|o6N`(_PRnJ z_Ydv+>2NEj!-dA+9&c)edo4b5RUd3^)P9ZU#zHIlUDy3mo>bn5oK>Ay!Dxr8Km0eG zkSp_oz=jHba9f_PBpkW z`@>ns*eJhtwjM( zS+Df4brht$1Lxa_rU(MEV#x@x~sLf(eVYU7M5 z;B_g9;L|Tq2)gatDGIr5 zr|hBTjx}^YYV7DB*^6=L*+`W1nQG;$rX&Q7LM-T4l(WIW50PfM6-FXu1?)8vk%OPm z(M$moT=N zka84x_Jr6gwEn~T*%nsP4*ROKFUWF1G9ml-%>CBU8M?eZJu`o6YV@P`tnI(ai&T`d zwZ=$&Q2x4)@px^2ZO)ezg3hm$;{%a6VI6V9B;Zbp*Z!u3fAqe zsU7)?9W(?Re}17yI1mqly+To=fg=mie6cn36!pq`@^p1J7}LwLz<+A(OAE{HhxekG zse|0_+)8^4vJbUB&FmSg#17J6dwmLAu6*9`JXnc;%GZ1xiGnivnx=95_jELML9LTi z$32+3P)Wv>gooDY5*A&?&N+bddP!IfEl4p%J6a9;Z>!kAZ-(UE-&HN;WOlxoL+4O? zGnA@*JOi~l&#ZltMBedcXH6^ZoKeW%Y_bL)MY*0UfoRak zh>@QzCtlX+%-g!ww_ z0DYD=1s57;}qw#)FMzi|pA7GCfl zJ5&SJ9~#2;z417~8T0P%`b1J-YKIh5AzJ*GmG{Zk=Q_zdr)|ve9y<~BFnV5l&1ldY z1uBL(rE`PZkDVpbUwIHdL5k%^ch6D8n#b>wd6wl+{HD1j$inYY&Y7JnTmB&F_qKQR zuK1%d`YoZmoM|m%eAVgvOFJUPs`=^PMmf-;RE*umYPkpwuG$d16FsPf!maK!D#61m zi=?P~tF=8Ov4)bB1yT8tR0ElVvzDOXsZskgM(WfWT*)e&?K(Oct*TuY@1Uo?K^{iI z(b3)j$S=A|_m+r5cs;d~-Lyl8%ph7JG+{O;n@xc1A`@so=+HlTQ|70^r*QO$ZP{tWFo4 zZH}(LiTwav>#hfKpY;1O)S!;Q#346g{Py(kAoObIg+@x~zd6;@w0&Fb@EMsKtqpK} zK5uCRBbP{J&?8l)v50l~j5*8#mQ?Lx=%R4cvW5_lbLENF4K>PdnQjdgV%{Tbi;$3$n>fPbbNZ@en%b zJK7`n`y;=3Za1GU_*o)jaPu~yobk!pP!q3l8dbqUM1=GV;AuIzfkac&8m~)f*IOHp z=6_~Tjm+BQJcLEq^x4#z@xju40;-N_ROVLFDlh`l3wO3sRXirfmoghkS0FQhs!-*6 z5dwZ)MF?SH#`?HDD)7WYBgL=ei^`z^H8FxLzM8u_K)~tSJNa#`b+esK-p_sqlCAA< zPwqTlsFK=2&WYV?ct39-EGPp@371(hrtO;+T)#6{Pc<1}DJs2NQ3HGGm!=akCGVqW zAtK2|f6o)z$DPp13Cr8yUv5Oiobz4a$Bg)sW3H$}epY)$t*!0X*cWj_Lt><_79#iL z9$eBwLA@L$!}>q`Ry`+rePA!D2Ob>W^s`4YQx$QF zi^)M>AMdH?a(4W<2yo6`5pBW5V5`EYwde7* zGCj-OiTgo_z3{H^qwey%@parmw!)u@Zy<{LL!Qcg0eG))z7W(p1qf~fHt(Tp9%q5;f|#O=1Z?=Bm>+8yAZ zIn#u7*g6GHNTTb)S2fN22GF=Zy`cqo0t|@;wnpKzI1ARA48CG&ObE7|WgiL(jb%(B z=rk9lrbOx>VT(thKk~r^X*f#{K8dL!*sECmj75<{T(U)_SNGIKf;PPNsLhKqK%S{4 z$Sm&fTS;vgE@Y>^x9M095wLZjjA@nG)BS7tS+uAAd<1*tX(;f$fycu+0KH<0A6bqb znkhM1nN&x4Ekrx5P+Lh8F_untkj-F`JmsQm8*3WA92BGq&7cH_ph#OJDH@|EVCFV@ zqCESs9cLk9IEAf_zXRj5R9Q}&ot=Gn^t#puYy^$WTEqOce>%8$e$ZRe|6C<|+rtyY z=l`CN+EfRhASWx5`71H@gbK9AUaBZn0o^rd^8H@n@zkK2%=O=5HS1ia2MEMUNOwqXJ{Y5Fa~)W(ifcARap>HvZSeUu`Ble8BvU5PPA zA_UJ>1}6a`=v0YGabw3tpjoo0IN7NziMQom>O3h#{D&a*MGm3@O%)LiCHTD^8=*y- zHzYoL)t+k{JJto1Hm+l?KTr;^av! z4pW%J&>Xxulei|i{F=sj(YBDqO22rFjTcSy!fXqtJmn@~9ASAaQ7QQrhguyB0r4~H z2>``Fb2mfEw^NLa%&69;IqR zl4;$6&8Xe@q6c0&l$@d{rnoFMlN?*l@1~mHUZD-WR?`;U739s7*)!IPB}KO=CLJ0R z%$1PcSV8He=p?nt7!=S^a8i0kG$`v8Ax}H;p)X9xPIVSmNFSWo2~UKJolVhWh)IkC zm|-#W26IuLUjYV5gT8;69cYS@=F(#0G80#uT|wqj^qHs_OF3Ynd0T{x-azs^aP@r7 z)MYdi(d`8L4}#tn+?}Qb%g8cv;ql)V)9rW#QN^ruF!Eo9-ONMCN)bMeqZ79P5p4V7 zHDCh3ym_S^rdGQTDe3o%o9|I@-LlbmG^bLp9xkI`r8;^P8Y?Av3*2Zbf$q__j4@>7 zdPq!e;BIt0y1_Sq#C0>n#>Hb6X2puXHS$+yhg^k|jL1F{h91Ku^S8)FFG7$N(oN(u zK9NNK{LZ`frRE&FB^C={cc5hwHQr6iIhj1gW|T{kmg2Q*v}llnB}!c&Xa=2Dri!Q_ zvBwO7JiY!zsz0WX(2~7WEVmw}H9W^xoUp^@Cl$i{Q~Y;_@4uA7N+#{s73a&_+2T${ zPWd5Sg+)kp5)u+Cz95fmfd}7kahZR_0w|zJp+yI4yg18=ox}2bsF6wam^VDsMlJUw zQaaVg=gluwQ_^xn(q%IqwUcaPRNAEICDn7VJG><(Q7_42KSE>U;#hwe7wFUA)AzW1 z{>WtjZ4SUAl*gz^u%C7v3zdB6CHJp zR8{D2``IVtsI~hz8F+F32nsNd9zp9f+Dg$W%QWS7;3VvbQA8zw(^UTVBf<*uh`mX}YhVEBq-cc_4y2?cHuEhK!#0oPKkzQ2P^@7p zJ<`(ScLZw^H1K3TtAGZqewb1D(r^$HD|vYcn)8m~D!Jw$AyS*llTgWG*M&$0;~8n58aL(@CnHF3gNYMKt8O4WhtX7)Dffx!o_i;sd4+bmi5F7)sq&XH zgYf7|*YfE7R&;~Y5S|3idpzP zC$mIkl%9-lVlQvAg!!mSOsw+7!gpsqo?macquFUhtLMp=KKL2nq5DZ3YX{zxT^ zFZFvlNSS$%`j7O)Y5atfhCyyoC$?;xgkNm)7rl}rg~FEl`ZeDYE%Ofa@bF~q^lxTU z@d-}WZ~=^z=9Ih96M3mzJ|gzsMbbrzj$nl`WTd9=6%~1J!&~zTYsf4+H$U>$#{a;O zi)=G%j3P{oF&_1OMCST?4{E}KluGaxs(N)s+_0`ATTu=cW%02gc`(Y)pVnS@D*vWU zOa3q^Mr4y(;fsZ!aMsmQUq62AMw8GckRCcVb z*#2US&T%ZEyd+7L2TSK~r@M1`v$-%xt%1eRdw{~r5d=^4r)1WrVWqlqM%mlug!%2DP zfHFe4=+4raNUQrfs5-273rPYun*T_0tj{VuTahov>_AdTRGhwPU7?wC(G~gw^0JXn z6=!i6x3n?N#rIM_I4Q&p00DH(nWvpcs$((HqC%dmVHM?|# z-=a76W??pGXdYUyFpSjR=nHC2#uH#Kkv?RIKl=qxpY+TH$t}AL6Dbu*D#=ZiOrWj; zh}M^x%(v35M8&r<-ZkA9+J`fXE2&I%XO$3U=a)_!emu*Uuiy@|uxk8-vUBHjTxyfl zbTmHTX`jq(rY0k}li`8ATQ1p7G0Bo69 z@;O2}WB(;fXhC8KxSek<34gEz(ZsQ<3}5vXA! zH7ikP9YW-0Ro#MX2iNf)+cK|qF-qre(F79|d0meOmpmos!*3bGW~fPJRVmm;+qDthO5_i-m-c3u*;l?p^CR;QDL=$n`4Zd&1 z;>@-MZk%L8Okm>W#?BCd$V%qp7sbSuci~=|)^{iu@-n)q%PujO7!EB%zd5%V6QdS- z(gimN(@r!SDA2zunyYwma0*xj^B&@hyG_vv`i?8j1*?xNL!VKTP(|?4(r(vn?)|!) zMN&g@HSnM&oB*|)Y0?^ssmy zU{1BheX+#RO|3bKPHhFWSj=F)`BN!FxYSl`mCMpcFPmu_k@=8u64d5FXJx#!xWp5@ znSY=_E@gZ7Y4iz=b1WNXrg*YRx7sOiv^5_7Ox)0%X)b9W+blN-%0JW*Lc4S0oSH0IU^^qqf)g&h63MN<*q}! zwJk4)K$Nu4ModtFLrob=cz*4&by?S3_(?{+87&M|Ob5QVvmpUVUGQeuR$1xIwRP zWMyjncSZYvtW(S$P0D9ryH@JO#mln|^QR4F>JRn>V%L|IBKft`y45g&zaWoSX{0si z^TDLZ7sAk-?Yj1`iz_y)Wcq3YSw|XEQ`_;bY52z}GEY9^pKRq8ar$gB-ig>&el-LM zkQ_51dvDmeqTeM;8@;=$9X{8)!?ZX>(^A4U`hH4j%anA=25ZmipM;?xoIt+!`m>KlI0+!=NpH z48Uw+t&fcOB8)J#g|L<(e5D-!`FpNaiqjGLc7U%k0D|TuuSB|IVYhlJM=k-LKZD(cJDB9NWcH+r-`za#5NAGodswx)lh43Ve!}Y8WcZhkNGVY z&}05cuuh-9lNBK1{+;^eg79W$>otVr?5trSd)9I4gc`cMo-$u5uv|{(Zy!2Gz%^Iw z3~RfoKr_ZU%bMSMXCM>}Sk~moMveu*KKy;Db39hB`O$n=kx?nC9jqd4v~w-0`Iph; z)N^{;%8YxH!*I(UiNPTiw};s<``xzUeML|L0zXVlce3$I(%s5&->a_a$JK-D$I~=O zc+C%GxWHp5B(D9*0{RxeklRJWmSt&Tl3tfM{sLz;;CFe2l&bF)Bu*$A7gESvi$lkqTF=P=t@9J9+$sy!PrW+tQyLc>{LtRZbnNdh4GN;* zkGAH2DcICF)bZ2wGck^E8!|IWa#Ey`i6?!Ff>3ibs(nEjaHI=7>HDxZ> z_zc1;bHI!(K;nq<7UWZy=ypiQdI!n+s40QeDWwC9%ulIW>slE>Rwa}n)Jm7nh=Gc} z#abQ0X>R(6xR0H*Hh$|zUJ{FAGznS*wx+m3IH3jKE3Hmw?NX-2 zdAU$%Q3k>nMH386o?hjv`5{J+jYehb18mZmxoF~m1;o-;ki+w~Sau#3NIJLpXW92g zmfuHRm0xV^onRU?A-g#)RICBI7C*9xUTC*F!d0$Pm=B%4R%lBsytF4zZf%w@yRMnYaoW4rdN*@H| z??|L@GVZUUbzfOP6fwm;a7dryiT#=%?y+iQvBjxWPGF^BvY6*zF}>|$Y5Q!#0TAs7 zUYAwfal!SeVzTLEk{w{`kR{Q^T}NP}V8~!3P?DxYhfmDg%VSuqQBW05L=s2ot~8B9 zfU^&mPW?BZuNhlMr75}(1$oXPW&Dey4X?XyGpb{H{u*%TWXYDYpje>LkbRwcc5Z)SJs`2tzxx}<~!;|i#D zq~lpwIgBY*%t}>e_M!S_}4#}Zo8lM?%KmS?y1tIPy_RAapI2HF4~#P z2n>#hY4tV9J~6*Fp5dsSwcoU9pfG2 z)>2{MXlME4Y1x~Sy!11bB`sJ`L#b}w?28{;h0I|fb3t_{ORCAq(&^AOGg*Dfj-f}S z-h*xy%6U>DpKO{U6fc+fW5mEJ`t5n@bIkj&Ped{}A9Em*xF^Z;Ic$R^fcX+N9uEnT zqVy%#Jmuzjn_gR3k{2=uLZG$3hM6Y>Tgpfygn8PY6U^XQvp-n+;|mB!9Y3=~8%>#Q zvMD|~Va~Y5e@#IqGubN6YK|3RnR3$CKe`&&Wj@z^4`Qu?4Mq%Nfw4nhz~s^QRfs8V zAI_=aSXyLrjhbWFQu|F5E%l1V<&oqOfz=}1n}Q;Zw35u!eK38dd2=zB?(+JVZF2x9 z@VaaG7MfB3Ohn=Z68k_LZ@@u#sc^FOK@1YtSreh)67V)Bya<_7tDr~kiFXho=$N@> z%I9*c>9A7`RiD!cb3|y_t+Q_G6}Dy@Inp7?J&F(j0bk?RpzrN8Xw@^U#yRx&J=-VX z^?Odss{i=W*%AIEs~%)LW5gKlSf3vDr)M*xj6J??I=hP0e=v9T>3vxs`^Lf{iyFK3 z+-LM{aa$1TAsDuoDbTLR`h#Uodu|Ybq;}1bQs#<8u|FxMQGtRPk`^wuPAf*o^s7ib z$s*$`1QTL5hPAF8s>NTSQV4FW^WzYhAe@&28wSy4+BU;3(~5GOpf!V96Q3tNlZ<56 z{3&?<@ViWT#Ps@E1fe#`0*sPIz65ua6(b2p!Sv~UzG~P6;g*$7Rvmco1@sQb4)K*r zmu^3D!4Q9NSs;K!=9loTm2v4Ed`XcKyJBn%t%VYtoX!fR=o(XzF8Mpg@>{(@KX^=l zt`VvE8wiCQB8UT^2LeU5>w__RS{H&cQH>>h5xIyhRPGsERS)m*7XF|M`28zmOoGIi zB)SXxxi#?U@aT#ulQFzQkB^iuf)95P#K3exN0EYn3n*z`)K}<^vo12S$SJY=Nknjc z%+LHq6;C(N5x-IhcS1#l9B2bYO+Pqb54x8h;#e2Q72KtZAAeC+a1xd%FjCZPVRxF* zQB>tyTlDK35y`Vxg&~%;_r-!+JUpBhj5cv#-PR{>`-@N3JuL%kNHv+&9kE%AVBzZKDPG}Yv)@9 zF?~Nn1OkN#r?n_AyNO+-yOuv7#5~|)x_k5eRgBf(%^%_KNFf(ANpCh}vud&dZYT6# ztLE~5jEiH$)nU>WxnVr@_)_JbrMOKB7YjoY=e=+{z>l93FaauEkWyo)g%`?Z@B4za z)N0z(89N;_G9+`mP$+629ye`q`{3m?5&8K&0SqpnC4qe7FcxSDeL8u0R}49&W0N|h zlZL(wOhdv`?sENmiDFY>!`-V*3iZb^Hzniz()Na4H;~evvl0EOzssM>N7E) zy+{m;1jKaKpo(c9Xs4yukn9aJQ60?mC+%n#x>1op4~8P?S5Vp<&0yu@a={&6R~eCi+kV(^Se1p zPA6Js10ra9B%k>DYtg(TjWJW-0HT0Bug7E>XvZ$D;fz}6ih-T+;eb%YwhceUt7sW# zUmP6dM@KS0UyF@^)F`L=Q@t>u#_7CJ*0f{ zP_#}#` zMzb7^_9`$FFjeEmeWN0eBacg;;3w$8=pmQBy$*LicN;x&-}a0 z+5D>B@AS%fKXyy@KVY70cWRa2kq#oAGdVz3nJ_b106#wgzpuiZG|?!;Poc^aEk3As zzOkYyqIsM%^Aqc32CCDTE<7pHueB%iK0 z)`yI~@Q=yzB~&Gyv}VhnuvcoGV%z>b@&+d1AT^c9m3E1lIUqKiKv7!@vRJIy`V#yZ zc3K-$`~F;a`%h+r9h8S0&~^ESGF(lBNg^fMH z2IX-d+QMs=P4{K>FzRe&#>@6Y%foV{N)b_3x)p9)Ei3nvL;VxmmZX4xIK!v=h-9lR z4^VXx1d01i5HfSOAnbClfB4aPZO49xxem%F7|rERV;~eQ9QY@TTe==Hy<7z!^th+j zu>Bz}Q^ACL7`E0O<_2Gn4XkeLb7bx696E>n-1V*dqj`+sxPJhJ@EV|-5i`XQ9vH7J7i70J?dc^zdjhO_To9w&jW&0wb>TFk zpLq{p*q64AJ$;lvDB`PgYO7|9whG0yY9}0}g|5zX0t~}Ed}kzIlU72CCS{^y-3&}D zusNn>mDuOMk$w9n#2pb)m5qXjRCHl2T} zC<@{7Pl>ix)-06FgXWRP->F;&QRN+c{7iuz&F!v3T@7t+&P=WZ8S&Rpoiso;+R?fR zvmO>Hn(cfq(e9+Qb4ow_(EnkIq|S0{4ytXSe)W$;Gwztr=)CR80-{*(T*%;I<2YZn zorGn8oTFfY0O6bfZ~o`Kt9Ia3Xg2K3ijPa)-&a?~FI98pkyE`E!o@!eziX$kNV3oX zNHoFy&)LdXBp)3DYAO9auj%7GA7A==-ZgED#_U7YjADPfvW~Q+dAngQruyz$sR+#U z=~2r2!I6qpJ{}c&*T}?-0p_xr58cjEa)gXYOWia?eJ4-YdGrt0%$4HtgfQgpxdb%+ zbgY9*23KMir>$^I`ESYIPssij3N^9!+4V#kT!ZZ&SHRYPp#iyP5Nwx|sh106Qx?I};l#6DyB88yi0d zCqE|#11l>(E9|ze^^z>x5cCd9d bHFh#*c671)c_E1Z&j}zasU%S&W)$*25>|>e literal 0 HcmV?d00001 diff --git a/doc/_static/favicon/icon-16x16.png b/doc/_static/favicon/icon-16x16.png new file mode 100644 index 0000000000000000000000000000000000000000..6a35da34b1a60bd8631b8e63e549bf898ee5ed2d GIT binary patch literal 1037 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 literal 0 HcmV?d00001 diff --git a/doc/_static/favicon/icon-32x32.png b/doc/_static/favicon/icon-32x32.png new file mode 100644 index 0000000000000000000000000000000000000000..4690f3f0c3f69a2f6557c5a7e22e3d5292352f6a GIT binary patch literal 1901 zcmZ{lX;{*U6UToNh(e^9m{;b3cPf~QqNpS)wu+YLsukv);*FXeh)1n?M4IL1Sy{1V zUYU0aVYsVhX{BkXglFYJ+u@PN(#!vg|2#AEd7hd1KJ#jx$?+gNBVfuf000m!B;rvq z)qjPQq&O$0rF|0v98Pc}06@cK_(l-qS2LJ&)C~aAbO0bd3jo%|rSy3KprHZa69oWp zB>=EDuB`otjkqCs@;7Iqm||OV=%9-s9Y^{-5dd~^e}zPEEGtA@geJMTIYMV)P`IWc z|GVa0u@LJ*BzXC8i?8=z4s=HH=-w+o`d=T!_e|yO>;2`S_1R@1SMvOb4Vnz%RR|oV zuA+%TB}=WsDTJO&+Vuhr32UZ35mich=X3H;zxb0Slqj19XSFkB4QGy@i=D*H)wA9& z#=PVfX?eGJ(U#Y`zLtA>&*6ZWTJ|Z)2`uI~yG==e;ssGa;cjuXTizHsRc=_iLwbzW zm7o}e-&2fCAaCJ7+^#`$e_Cq7&fW#N5?o+{+$ttcvZP0~b^fu;t49|(P?zX59y>7h zi-kx*B&m7*%Y^Eq%8XHc!HA&Z{qUMkM2u|eh)KpMM0JweI?cIZdHx$I?(^o=U~`7b zTT7p!59VjU5_1(h&PxruGL!_*wK&H<(#E-g$lAAp^Kx@MNWpAeHC#)%(!3>8PjdD+ zXyvTG!uZxc2L2UI3B_`^PB zNUGC=QM>$8F`;Hxj4#D1d(EKiZ;Gk?QG*ub?r>etveGf1rD7E5!~;S&keZvm?$gTZ zDVHxu4#H&)TSQ{8#h^WOW-B>@evHtc?Np>A7Y;zVi$C-58I=A)<3m`-Vb96V=_AWqSRoa-fc#?F-oOo1fr;em6TB`(8g*jfG^VSu_z! zr5oM4%H2ovi`zDT76%nh>PvT8!RhBuSPgGv`6N_gaIQXMEO1sjYiL-(H`kCd%|%^dBtQ2jo>%XYwee>_!rziY0<};xMlD=O5@NtjI5not(Ryz zfRBneT=N17PA-?967o&yIJ@4yMOs&o7`nELQL2)+B4uCDnYT^+hty+AsLNuTq`sXzQxnsP^sO~#Ow zASIE&Ho`C3zmPyxQVc+9(&oMLpy%sMy}wLEGWxZ zeuimGQbRI#7UijowJ$JaELKj%p(kkaZ4x2jtuatov2=yi!M*{NAfDa!hEUwjRNww+)UCpK>v*3 z@5@e`ig00cHeYxREZNn$o>zmVWWwl0k)}tOkI8?uj`Xibz zO!VFQP_Z`mvM{#mHJSI)Aq8PxGSR;+acVN{-o2>$KJi!4PIB~13JFRI#RVsXiUF9S zO-+q3Xd^V%3xmNOG{c!4G(@9uX!IoILhb(usIeiDVWrWL?s3X#f2KjCWMzR*(i&L02fCxvGx!p<3EVHS#|&b literal 0 HcmV?d00001 diff --git a/doc/_static/favicon/icon-96x96.png b/doc/_static/favicon/icon-96x96.png new file mode 100644 index 0000000000000000000000000000000000000000..abc5d235fbaeb0154148c78fa80c19be81c2cd68 GIT binary patch literal 7747 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 + + + + + + + + + + + + + + + + + + + + diff --git a/doc/conf.py b/doc/conf.py index 9708c38f..7bcef61d 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -30,7 +30,8 @@ # Add any Sphinx extension module names here, as strings. They can be extensions # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. extensions = ['sphinx.ext.autodoc', 'sphinx.ext.intersphinx', 'sphinx.ext.todo', - 'sphinx.ext.coverage', 'sphinx.ext.ifconfig', 'sphinx_paramlinks',] + 'sphinx.ext.coverage', 'sphinx.ext.ifconfig', 'sphinx_paramlinks', + 'sphinx-favicon',] autodoc_default_options = { 'members': True, @@ -129,10 +130,97 @@ # pixels large. #html_favicon = None +# Set up favicons with sphinx-favicon. +favicons = [ + { + "rel": "icon", + "static-file": "favicon/icon.svg", + "type": "image/svg", + }, + { + "rel": "icon", + "sizes": "16x16", + "static-file": "favicon/icon-16x16.png", + "type": "image/png", + }, + { + "rel": "icon", + "sizes": "32x32", + "static-file": "favicon/icon-32x32.png", + "type": "image/png", + }, + { + "rel": "icon", + "sizes": "96x96", + "static-file": "favicon/icon-96x96.png", + "type": "image/png", + }, + { + "rel": "icon", + "sizes": "128x128", + "static-file": "favicon/icon-128x128.png", + "type": "image/png", + }, + { + "rel": "icon", + "sizes": "196x196", + "static-file": "favicon/icon-196x196.png", + "type": "image/png", + }, + { + "rel": "apple-touch-icon", + "sizes": "57x57", + "static-file": "favicon/apple-touch-icon-57x57.png", + "type": "image/png", + }, + { + "rel": "apple-touch-icon", + "sizes": "60x60", + "static-file": "favicon/apple-touch-icon-60x60.png", + "type": "image/png", + }, + { + "rel": "apple-touch-icon", + "sizes": "72x72", + "static-file": "favicon/apple-touch-icon-72x72.png", + "type": "image/png", + }, + { + "rel": "apple-touch-icon", + "sizes": "76x76", + "static-file": "favicon/apple-touch-icon-76x76.png", + "type": "image/png", + }, + { + "rel": "apple-touch-icon", + "sizes": "114x114", + "static-file": "favicon/apple-touch-icon-114x114.png", + "type": "image/png", + }, + { + "rel": "apple-touch-icon", + "sizes": "120x120", + "static-file": "favicon/apple-touch-icon-120x120.png", + "type": "image/png", + }, + { + "rel": "apple-touch-icon", + "sizes": "144x144", + "static-file": "favicon/apple-touch-icon-144x144.png", + "type": "image/png", + }, + { + "rel": "apple-touch-icon", + "sizes": "152x152", + "static-file": "favicon/apple-touch-icon-152x152.png", + "type": "image/png", + }, +] + # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". -#html_static_path = ['_static'] +html_static_path = ['_static'] # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, # using the given strftime format. diff --git a/requirements-doc.txt b/requirements-doc.txt index 6f0bb607..964c9f18 100644 --- a/requirements-doc.txt +++ b/requirements-doc.txt @@ -1,2 +1,3 @@ sphinx==5.2.1 sphinx-paramlinks==0.5.4 +sphinx-favicon==0.2 From a2892a3ed04f4e463f9f4a91e254754a7158afbe Mon Sep 17 00:00:00 2001 From: Georgy Moiseev Date: Fri, 7 Oct 2022 14:39:04 +0300 Subject: [PATCH 12/14] readme: consistency with doc Make README consistent with documentation index page. Remove Tarantool 1.6 guides since the version was deprecated several years ago. Part of #67 --- README.rst | 31 +++++++++++++------------------ 1 file changed, 13 insertions(+), 18 deletions(-) diff --git a/README.rst b/README.rst index ae22a6d9..05cc7fa3 100644 --- a/README.rst +++ b/README.rst @@ -22,18 +22,16 @@ With pip (recommended) The recommended way to install the ``tarantool`` package is using ``pip``. -For Tarantool version < 1.6.0, get the ``0.3.*`` connector version:: - - $ pip install tarantool\<0.4 - -For a later Tarantool version, get the ``0.5.*`` connector version:: +.. code-block:: bash - $ pip install tarantool\>0.4 + $ pip install tarantool ZIP archive ^^^^^^^^^^^ -You can also download zip archive, unpack it and run:: +You can also download zip archive, unpack it and run: + +.. code-block:: bash $ python setup.py install @@ -42,11 +40,7 @@ Development version You can also install the development version of the package using ``pip``. -For Tarantool version < 1.6.0, get the ``stable`` branch:: - - $ pip install git+https://github.com/tarantool/tarantool-python.git@stable - -For a later Tarantool version, use the ``master`` branch:: +.. code-block:: bash $ pip install git+https://github.com/tarantool/tarantool-python.git@master @@ -55,17 +49,15 @@ For a later Tarantool version, use the ``master`` branch:: What is Tarantool? ------------------ -`Tarantool`_ is an in-memory NoSQL database with a Lua application server on board. -It combines the network programming power of Node.JS -with data persistency capabilities of Redis. -It's open-source, licensed under `BSD-2-Clause`_. +`Tarantool`_ is an in-memory computing platform originally designed by +`VK`_ and released under the terms of `BSD license`_. Features -------- * ANSI SQL, including views, joins, referential and check constraints * Lua packages for non-blocking I/O, fibers, and HTTP -* MsgPack data format and MsgPack-based client-server protocol +* MessagePack data format and MessagePack-based client-server protocol * Two data engines: * memtx – in-memory storage engine with optional persistence @@ -97,7 +89,7 @@ Run tests On Linux: -.. code-block:: console +.. code-block:: bash $ python setup.py test @@ -141,7 +133,10 @@ Open ``localhost:8000`` in your browser to read the docs. .. _`Tarantool homepage`: https://tarantool.io .. _`Tarantool on GitHub`: https://github.com/tarantool/tarantool .. _`Tarantool documentation`: https://www.tarantool.io/en/doc/latest/ +.. _`VK`: https://vk.company .. _`Client-server protocol specification`: https://www.tarantool.io/en/doc/latest/dev_guide/internals/box_protocol/ +.. _`BSD`: +.. _`BSD license`: .. _`BSD-2-Clause`: https://opensource.org/licenses/BSD-2-Clause .. _`asynctnt`: https://github.com/igorcoding/asynctnt .. _`feature comparison table`: https://www.tarantool.io/en/doc/latest/book/connectors/#python-feature-comparison From 1b98e4936c3915605b7a7bd8f6fae2287ffbfe51 Mon Sep 17 00:00:00 2001 From: Georgy Moiseev Date: Mon, 10 Oct 2022 15:26:34 +0300 Subject: [PATCH 13/14] readme: use make commands for consistency Part of #67 --- INSTALL | 2 +- Makefile | 4 +++- README.rst | 6 +++--- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/INSTALL b/INSTALL index 5d7089ac..9c9975fc 100644 --- a/INSTALL +++ b/INSTALL @@ -14,4 +14,4 @@ Using `easy_install`:: You can also download the source tarball and install the package using distutils script:: - # python setup.py install + # make install diff --git a/Makefile b/Makefile index bfa3516b..1da07273 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,6 @@ -.PHONY: test +.PHONY: install test +install: + python setup.py install test: python setup.py test testdata: diff --git a/README.rst b/README.rst index 05cc7fa3..e62407cc 100644 --- a/README.rst +++ b/README.rst @@ -33,7 +33,7 @@ You can also download zip archive, unpack it and run: .. code-block:: bash - $ python setup.py install + $ make install Development version ^^^^^^^^^^^^^^^^^^^ @@ -91,7 +91,7 @@ On Linux: .. code-block:: bash - $ python setup.py test + $ make test On Windows: @@ -103,7 +103,7 @@ On Windows: * Set the following environment variables: * ``REMOTE_TARANTOOL_HOST=...``, * ``REMOTE_TARANTOOL_CONSOLE_PORT=3302``. -* Run ``python setup.py test``. +* Run ``make test``. Build docs ^^^^^^^^^^ From 589d3ff3645760152fde65892e848d08ddc03d88 Mon Sep 17 00:00:00 2001 From: Georgy Moiseev Date: Fri, 7 Oct 2022 14:41:23 +0300 Subject: [PATCH 14/14] changelog: consistency with doc Closes #67 --- CHANGELOG.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 18014b32..0f518c55 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,8 +15,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 type. `tarantool.Datetime` may be encoded to Tarantool datetime objects. - You can create `tarantool.Datetime` objects either from msgpack - data or by using the same API as in Tarantool: + You can create `tarantool.Datetime` objects either from + MessagePack data or by using the same API as in Tarantool: ```python dt1 = tarantool.Datetime(year=2022, month=8, day=31, @@ -73,8 +73,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 type. `tarantool.Interval` may be encoded to Tarantool interval objects. - You can create `tarantool.Interval` objects either from msgpack - data or by using the same API as in Tarantool: + You can create `tarantool.Interval` objects either from + MessagePack data or by using the same API as in Tarantool: ```python di = tarantool.Interval(year=-1, month=2, day=3,