├── setup.cfg
├── .gitmodules
├── pytest.ini
├── .clang-format
├── .gitignore
├── tools
├── get_memgraph_version.sh
├── wait_for_memgraph.sh
└── install_linux_deps.sh
├── docs
├── source
│ ├── cursor.rst
│ ├── index.rst
│ ├── module.rst
│ ├── connection.rst
│ ├── conf.py
│ ├── usage.rst
│ └── introduction.rst
└── Makefile
├── MANIFEST.in
├── src
├── exceptions.h
├── column.h
├── glue.h
├── cursor.h
├── types.h
├── connection.h
├── column.c
├── connection-int.c
├── mgclientmodule.c
├── connection.c
├── types.c
└── cursor.c
├── test
├── test_types.py
├── memory_leak.py
├── common.py
├── test_connection.py
├── test_glue.py
└── test_cursor.py
├── .github
└── workflows
│ ├── ci.yml
│ ├── release.yml
│ ├── build_docs.yml
│ └── reusable_buildtest.yml
├── README.md
├── CHANGELOG.rst
├── LICENSE
└── setup.py
/setup.cfg:
--------------------------------------------------------------------------------
1 | [build_ext]
2 | static_openssl = 1
3 |
--------------------------------------------------------------------------------
/.gitmodules:
--------------------------------------------------------------------------------
1 | [submodule "mgclient"]
2 | path = mgclient
3 | url = https://github.com/memgraph/mgclient.git
4 |
--------------------------------------------------------------------------------
/pytest.ini:
--------------------------------------------------------------------------------
1 | [pytest]
2 | markers =
3 | temporal: mark a test that tests temporal types.
4 | norecursedirs = memgraph
5 |
--------------------------------------------------------------------------------
/.clang-format:
--------------------------------------------------------------------------------
1 | ---
2 | Language: Cpp
3 | BasedOnStyle: Google
4 | UseTab: Never
5 | DerivePointerAlignment: false
6 | PointerAlignment: Right
7 | ColumnLimit : 80
8 | IncludeBlocks: Preserve
9 | ...
10 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # build directory
2 | build/
3 | dist/
4 | pymgclient.egg-info/
5 |
6 | # vim temporary files
7 | *.swp
8 | *.swo
9 |
10 | # python temporary files
11 | .venv
12 | *.pyc
13 | *.pyo
14 | __pycache__/
--------------------------------------------------------------------------------
/tools/get_memgraph_version.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 | mgversion=$(
3 | curl -s https://api.github.com/repos/memgraph/memgraph/releases/latest \
4 | | grep -m1 '"tag_name":' \
5 | | sed -E 's/.*"([^"]+)".*/\1/' \
6 | | sed 's/^v//'
7 | )
8 | echo "$mgversion"
--------------------------------------------------------------------------------
/docs/source/cursor.rst:
--------------------------------------------------------------------------------
1 | ===============================
2 | :class:`Cursor` class
3 | ===============================
4 |
5 | .. py:module:: mgclient
6 |
7 | .. autoclass:: mgclient.Cursor
8 | :members:
9 |
10 | #####################
11 | :class:`Column` class
12 | #####################
13 |
14 | :attr:`Cursor.description` list consists of instances of :class:`Column` class.
15 |
16 | .. autoclass:: mgclient.Column
17 | :members:
18 |
--------------------------------------------------------------------------------
/MANIFEST.in:
--------------------------------------------------------------------------------
1 | recursive-include src *.c *.h
2 | recursive-include docs *
3 | prune docs/build
4 | recursive-include tests *.py
5 | include LICENSE MANIFEST.in README.md setup.cfg setup.py CHANGELOG.rst
6 |
7 |
8 | recursive-include mgclient/src *.c *.h CMakeLists.txt
9 | recursive-include mgclient/include *.h *.hpp
10 | recursive-include mgclient/cmake *.cmake
11 | recursive-include mgclient/tests *.cpp *.hpp CMakeLists.txt
12 | include mgclient/README.md mgclient/LICENSE mgclient/CMakeLists.txt
13 |
--------------------------------------------------------------------------------
/docs/Makefile:
--------------------------------------------------------------------------------
1 | # Minimal makefile for Sphinx documentation
2 | #
3 |
4 | # You can set these variables from the command line.
5 | SPHINXOPTS =
6 | SPHINXBUILD = sphinx-build
7 | SOURCEDIR = source
8 | BUILDDIR = build
9 |
10 | # Put it first so that "make" without argument is like "make help".
11 | help:
12 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
13 |
14 | .PHONY: help Makefile
15 |
16 | # Catch-all target: route all unknown targets to Sphinx using the new
17 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
18 | %: Makefile
19 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
20 |
--------------------------------------------------------------------------------
/docs/source/index.rst:
--------------------------------------------------------------------------------
1 | .. pymgclient documentation master file, created by
2 | sphinx-quickstart on Thu May 16 09:51:30 2019.
3 | You can adapt this file completely to your liking, but it should at least
4 | contain the root `toctree` directive.
5 |
6 | ========================================================
7 | pymgclient -- DB-API 2.0 interface for Memgraph database
8 | ========================================================
9 |
10 | pymgclient is a `Memgraph `_ database adapter for Python
11 | language compliant with the DB-API 2.0 specification described by :pep:`249`.
12 |
13 |
14 | .. toctree::
15 | :maxdepth: 2
16 | :caption: Contents:
17 |
18 | introduction
19 | usage
20 | module
21 | connection
22 | cursor
23 |
24 | Indices and tables
25 | ==================
26 |
27 | * :ref:`genindex`
28 | * :ref:`search`
29 |
--------------------------------------------------------------------------------
/src/exceptions.h:
--------------------------------------------------------------------------------
1 | // Copyright (c) 2016-2020 Memgraph Ltd. [https://memgraph.com]
2 | //
3 | // Licensed under the Apache License, Version 2.0 (the "License");
4 | // you may not use this file except in compliance with the License.
5 | // You may obtain a copy of the License at
6 | //
7 | // http://www.apache.org/licenses/LICENSE-2.0
8 | //
9 | // Unless required by applicable law or agreed to in writing, software
10 | // distributed under the License is distributed on an "AS IS" BASIS,
11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | // See the License for the specific language governing permissions and
13 | // limitations under the License.
14 |
15 | #ifndef PYMGCLIENT_EXCEPTIONS_H
16 | #define PYMGCLIENT_EXCEPTIONS_H
17 |
18 | extern PyObject *Warning;
19 | extern PyObject *Error;
20 | extern PyObject *InterfaceError;
21 | extern PyObject *DatabaseError;
22 | extern PyObject *DataError;
23 | extern PyObject *OperationalError;
24 | extern PyObject *IntegrityError;
25 | extern PyObject *InternalError;
26 | extern PyObject *ProgrammingError;
27 | extern PyObject *NotSupportedError;
28 |
29 | #endif
30 |
--------------------------------------------------------------------------------
/src/column.h:
--------------------------------------------------------------------------------
1 | // Copyright (c) 2016-2020 Memgraph Ltd. [https://memgraph.com]
2 | //
3 | // Licensed under the Apache License, Version 2.0 (the "License");
4 | // you may not use this file except in compliance with the License.
5 | // You may obtain a copy of the License at
6 | //
7 | // http://www.apache.org/licenses/LICENSE-2.0
8 | //
9 | // Unless required by applicable law or agreed to in writing, software
10 | // distributed under the License is distributed on an "AS IS" BASIS,
11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | // See the License for the specific language governing permissions and
13 | // limitations under the License.
14 |
15 | #ifndef PYMGCLIENT_COLUMN_H
16 | #define PYMGCLIENT_COLUMN_H
17 |
18 | #include
19 |
20 | // clang-format off
21 | typedef struct {
22 | PyObject_HEAD
23 |
24 | PyObject *name;
25 | PyObject *type_code;
26 | PyObject *display_size;
27 | PyObject *internal_size;
28 | PyObject *precision;
29 | PyObject *scale;
30 | PyObject *null_ok;
31 | } ColumnObject;
32 | // clang-format on
33 |
34 | extern PyTypeObject ColumnType;
35 |
36 | #endif
37 |
--------------------------------------------------------------------------------
/src/glue.h:
--------------------------------------------------------------------------------
1 | // Copyright (c) 2016-2020 Memgraph Ltd. [https://memgraph.com]
2 | //
3 | // Licensed under the Apache License, Version 2.0 (the "License");
4 | // you may not use this file except in compliance with the License.
5 | // You may obtain a copy of the License at
6 | //
7 | // http://www.apache.org/licenses/LICENSE-2.0
8 | //
9 | // Unless required by applicable law or agreed to in writing, software
10 | // distributed under the License is distributed on an "AS IS" BASIS,
11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | // See the License for the specific language governing permissions and
13 | // limitations under the License.
14 |
15 | #ifndef PYMGCLIENT_GLUE_H
16 | #define PYMGCLIENT_GLUE_H
17 |
18 | #include
19 |
20 | #include
21 |
22 | PyObject *mg_list_to_py_tuple(const mg_list *list);
23 |
24 | PyObject *mg_list_to_py_list(const mg_list *list);
25 |
26 | PyObject *mg_value_to_py_object(const mg_value *value);
27 |
28 | mg_map *py_dict_to_mg_map(PyObject *dict);
29 |
30 | mg_value *py_object_to_mg_value(PyObject *object);
31 |
32 | mg_date_time_zone_id *py_date_time_to_mg_date_time_zone_id(PyObject *obj);
33 |
34 | void py_datetime_import_init();
35 | #endif
36 |
--------------------------------------------------------------------------------
/src/cursor.h:
--------------------------------------------------------------------------------
1 | // Copyright (c) 2016-2020 Memgraph Ltd. [https://memgraph.com]
2 | //
3 | // Licensed under the Apache License, Version 2.0 (the "License");
4 | // you may not use this file except in compliance with the License.
5 | // You may obtain a copy of the License at
6 | //
7 | // http://www.apache.org/licenses/LICENSE-2.0
8 | //
9 | // Unless required by applicable law or agreed to in writing, software
10 | // distributed under the License is distributed on an "AS IS" BASIS,
11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | // See the License for the specific language governing permissions and
13 | // limitations under the License.
14 |
15 | #ifndef PYMGCLIENT_CURSOR_H
16 | #define PYMGCLIENT_CURSOR_H
17 |
18 | #include
19 |
20 | struct ConnectionObject;
21 |
22 | #define CURSOR_STATUS_READY 0
23 | #define CURSOR_STATUS_EXECUTING 1
24 | #define CURSOR_STATUS_CLOSED 3
25 |
26 | // clang-format off
27 | typedef struct {
28 | PyObject_HEAD
29 |
30 | struct ConnectionObject *conn;
31 | int status;
32 | int hasresults;
33 | long arraysize;
34 |
35 | Py_ssize_t rowindex;
36 | Py_ssize_t rowcount;
37 | PyObject *rows;
38 | PyObject *description;
39 | } CursorObject;
40 | // clang-format on
41 |
42 | extern PyTypeObject CursorType;
43 |
44 | #endif
45 |
--------------------------------------------------------------------------------
/src/types.h:
--------------------------------------------------------------------------------
1 | // Copyright (c) 2016-2020 Memgraph Ltd. [https://memgraph.com]
2 | //
3 | // Licensed under the Apache License, Version 2.0 (the "License");
4 | // you may not use this file except in compliance with the License.
5 | // You may obtain a copy of the License at
6 | //
7 | // http://www.apache.org/licenses/LICENSE-2.0
8 | //
9 | // Unless required by applicable law or agreed to in writing, software
10 | // distributed under the License is distributed on an "AS IS" BASIS,
11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | // See the License for the specific language governing permissions and
13 | // limitations under the License.
14 |
15 | #ifndef PYMGCLIENT_TYPES_H
16 | #define PYMGCLIENT_TYPES_H
17 |
18 | #include
19 |
20 | // clang-format off
21 | typedef struct {
22 | PyObject_HEAD
23 |
24 | int64_t id;
25 | PyObject *labels;
26 | PyObject *properties;
27 | } NodeObject;
28 |
29 | typedef struct {
30 | PyObject_HEAD
31 |
32 | int64_t id;
33 | int64_t start_id;
34 | int64_t end_id;
35 | PyObject *type;
36 | PyObject *properties;
37 | } RelationshipObject;
38 |
39 | typedef struct {
40 | PyObject_HEAD
41 |
42 | PyObject *nodes;
43 | PyObject *relationships;
44 | } PathObject;
45 | // clang-format on
46 |
47 | extern PyTypeObject NodeType;
48 | extern PyTypeObject RelationshipType;
49 | extern PyTypeObject PathType;
50 |
51 | #endif
52 |
--------------------------------------------------------------------------------
/test/test_types.py:
--------------------------------------------------------------------------------
1 | # Copyright (c) 2016-2020 Memgraph Ltd. [https://memgraph.com]
2 | #
3 | # Licensed under the Apache License, Version 2.0 (the "License");
4 | # you may not use this file except in compliance with the License.
5 | # You may obtain a copy of the License at
6 | #
7 | # http://www.apache.org/licenses/LICENSE-2.0
8 | #
9 | # Unless required by applicable law or agreed to in writing, software
10 | # distributed under the License is distributed on an "AS IS" BASIS,
11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | # See the License for the specific language governing permissions and
13 | # limitations under the License.
14 |
15 | import mgclient
16 |
17 |
18 | def test_node():
19 | node1 = mgclient.Node(1, set(), {})
20 | assert str(node1) == "()"
21 |
22 | node2 = mgclient.Node(1, set(["Label1"]), {})
23 | assert str(node2) == "(:Label1)"
24 |
25 | node3 = mgclient.Node(1, set(["Label2"]), {"prop": 1})
26 | assert str(node3) == "(:Label2 {'prop': 1})"
27 |
28 | node4 = mgclient.Node(1, set(), {"prop": 1})
29 | assert str(node4) == "({'prop': 1})"
30 |
31 |
32 | def test_relationship():
33 | rel1 = mgclient.Relationship(0, 1, 2, "Type", {})
34 | assert str(rel1) == "[:Type]"
35 |
36 | rel2 = mgclient.Relationship(0, 1, 2, "Type", {"prop": 1})
37 | assert str(rel2) == "[:Type {'prop': 1}]"
38 |
39 |
40 | def test_path():
41 | n1 = mgclient.Node(1, set(["Label1"]), {})
42 | n2 = mgclient.Node(2, set(["Label2"]), {})
43 | n3 = mgclient.Node(3, set(["Label3"]), {})
44 |
45 | e1 = mgclient.Relationship(1, 1, 2, "Edge1", {})
46 | e2 = mgclient.Relationship(2, 3, 2, "Edge2", {})
47 |
48 | path = mgclient.Path([n1, n2, n3], [e1, e2])
49 | assert str(path) == "(:Label1)-[:Edge1]->(:Label2)<-[:Edge2]-(:Label3)"
50 |
--------------------------------------------------------------------------------
/src/connection.h:
--------------------------------------------------------------------------------
1 | // Copyright (c) 2016-2020 Memgraph Ltd. [https://memgraph.com]
2 | //
3 | // Licensed under the Apache License, Version 2.0 (the "License");
4 | // you may not use this file except in compliance with the License.
5 | // You may obtain a copy of the License at
6 | //
7 | // http://www.apache.org/licenses/LICENSE-2.0
8 | //
9 | // Unless required by applicable law or agreed to in writing, software
10 | // distributed under the License is distributed on an "AS IS" BASIS,
11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | // See the License for the specific language governing permissions and
13 | // limitations under the License.
14 |
15 | #ifndef PYMGCLIENT_CONNECTION_H
16 | #define PYMGCLIENT_CONNECTION_H
17 |
18 | #include
19 |
20 | #include
21 |
22 | // Connection status constants.
23 | #define CONN_STATUS_READY 0
24 | #define CONN_STATUS_IN_TRANSACTION 1
25 | #define CONN_STATUS_EXECUTING 2
26 | #define CONN_STATUS_FETCHING 3
27 | #define CONN_STATUS_CLOSED 4
28 | #define CONN_STATUS_BAD (-1)
29 |
30 | // clang-format off
31 | typedef struct ConnectionObject {
32 | PyObject_HEAD
33 |
34 | mg_session *session;
35 | int status;
36 | int autocommit;
37 | int lazy;
38 | } ConnectionObject;
39 | // clang-format on
40 |
41 | extern PyTypeObject ConnectionType;
42 |
43 | int connection_raise_if_bad_status(const ConnectionObject *conn);
44 |
45 | int connection_run_without_results(ConnectionObject *conn, const char *query);
46 |
47 | int connection_run(ConnectionObject *conn, const char *query, PyObject *params,
48 | PyObject **columns);
49 |
50 | int connection_pull(ConnectionObject *conn, long n);
51 |
52 | int connection_fetch(ConnectionObject *conn, PyObject **row, int *has_more);
53 |
54 | int connection_begin(ConnectionObject *conn);
55 |
56 | void connection_discard_all(ConnectionObject *conn);
57 |
58 | #endif
59 |
--------------------------------------------------------------------------------
/test/memory_leak.py:
--------------------------------------------------------------------------------
1 | import linecache
2 | import os
3 | import tracemalloc
4 | import mgclient
5 |
6 |
7 | def display_top(snapshot, key_type="lineno", limit=3):
8 | snapshot = snapshot.filter_traces(
9 | (
10 | tracemalloc.Filter(False, ""),
11 | tracemalloc.Filter(False, ""),
12 | )
13 | )
14 | top_stats = snapshot.statistics(key_type)
15 |
16 | print("Top %s lines" % limit)
17 | for index, stat in enumerate(top_stats[:limit], 1):
18 | frame = stat.traceback[0]
19 | filename = os.sep.join(frame.filename.split(os.sep)[-2:])
20 | print(
21 | "#%s: %s:%s: %.1f KiB" % (index, filename, frame.lineno, stat.size / 1024)
22 | )
23 | line = linecache.getline(frame.filename, frame.lineno).strip()
24 | if line:
25 | print(" %s" % line)
26 | other = top_stats[limit:]
27 | if other:
28 | size = sum(stat.size for stat in other)
29 | print("%s other: %.1f KiB" % (len(other), size / 1024))
30 | total = sum(stat.size for stat in top_stats)
31 | print("Total allocated size: %.1f KiB" % (total / 1024))
32 |
33 |
34 | def main():
35 | conn = mgclient.connect(host="127.0.0.1", port=7687)
36 | cursor = conn.cursor()
37 | for _ in range(1, 10000):
38 | cursor.execute(
39 | """
40 | CREATE (n:Person {name: 'John'})-[e:KNOWS]->
41 | (m:Person {name: 'Steve'});
42 | """
43 | )
44 |
45 | while True:
46 | tracemalloc.start()
47 |
48 | cursor.execute(
49 | """
50 | MATCH (n:Person {name: 'John'})-[e:KNOWS]->
51 | (m:Person {name: 'Steve'})
52 | RETURN n, m, e
53 | """
54 | )
55 | cursor.fetchone()
56 |
57 | snapshot = tracemalloc.take_snapshot()
58 | display_top(snapshot)
59 |
60 |
61 | if __name__ == "__main__":
62 | main()
63 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 | concurrency:
3 | group: ${{ github.head_ref || github.sha }}
4 | cancel-in-progress: true
5 |
6 | on:
7 | pull_request:
8 | workflow_dispatch:
9 | inputs:
10 | test_linux:
11 | type: boolean
12 | default: true
13 | description: "Run Linux Build and Test"
14 | test_windows:
15 | type: boolean
16 | default: true
17 | description: "Run Windows Build and Test"
18 | test_macintosh:
19 | type: boolean
20 | default: true
21 | description: "Run Mac OS Build"
22 | build_source_dist:
23 | type: boolean
24 | default: true
25 | description: "Build Source Distribution"
26 | upload_artifacts:
27 | type: boolean
28 | default: true
29 | description: "Upload Artifacts"
30 |
31 | schedule:
32 | - cron: "0 0 * * 0"
33 |
34 | jobs:
35 | weekly_build:
36 | if: ${{ github.event_name == 'schedule' }}
37 | name: Weekly Build
38 | uses: "./.github/workflows/reusable_buildtest.yml"
39 | with:
40 | test_linux: true
41 | test_windows: true
42 | test_macintosh: true
43 | build_source_dist: false
44 | upload_artifacts: false
45 | secrets: inherit
46 |
47 | pr_test:
48 | if: ${{ github.event_name == 'pull_request' }}
49 | name: Pull Request Tests
50 | uses: "./.github/workflows/reusable_buildtest.yml"
51 | with:
52 | test_linux: true
53 | test_windows: true
54 | test_macintosh: true
55 | build_source_dist: true
56 | upload_artifacts: false
57 | secrets: inherit
58 |
59 | manual_test:
60 | if: ${{ github.event_name == 'workflow_dispatch' }}
61 | name: Manual Test
62 | uses: "./.github/workflows/reusable_buildtest.yml"
63 | with:
64 | test_linux: ${{ inputs.test_linux }}
65 | test_windows: ${{ inputs.test_windows }}
66 | test_macintosh: ${{ inputs.test_macintosh }}
67 | build_source_dist: ${{ inputs.build_source_dist }}
68 | upload_artifacts: ${{ inputs.upload_artifacts }}
69 | secrets: inherit
70 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | [](https://github.com/memgraph/pymgclient/actions)
2 |
3 | # pymgclient - Memgraph database adapter for Python language
4 |
5 | pymgclient is a [Memgraph](https://memgraph.com) database adapter for Python
6 | programming language compliant with the DB-API 2.0 specification described by
7 | PEP 249.
8 |
9 | mgclient module is the current implementation of the adapter. It is implemented
10 | in C as a wrapper around [mgclient](https://github.com/memgraph/mgclient), the
11 | official Memgraph client library. As a C extension, it is only compatible with
12 | the CPython implementation of the Python programming language.
13 |
14 | pymgclient only works with Python 3.
15 |
16 | Check out the documentation if you need help with
17 | [installation](https://memgraph.github.io/pymgclient/introduction.html#installation)
18 | or if you want to
19 | [build](https://memgraph.github.io/pymgclient/introduction.html#install-from-source)
20 | pymgclient for yourself!
21 | ## Documentation
22 |
23 | Online documentation can be found on [GitHub
24 | pages](https://memgraph.github.io/pymgclient/).
25 |
26 | You can also build a local version of the documentation by running `make` from
27 | the `docs` directory. You will need [Sphinx](http://www.sphinx-doc.org/)
28 | installed in order to do that.
29 |
30 | ## Code sample
31 |
32 | Here is an example of an interactive session showing some of the basic
33 | commands:
34 |
35 | ```python
36 | >>> import mgclient
37 |
38 | # Make a connection to the database
39 | >>> conn = mgclient.connect(host='127.0.0.1', port=7687)
40 |
41 | # Create a cursor for query execution
42 | >>> cursor = conn.cursor()
43 |
44 | # Execute a query
45 | >>> cursor.execute("""
46 | CREATE (n:Person {name: 'John'})-[e:KNOWS]->
47 | (m:Person {name: 'Steve'})
48 | RETURN n, e, m
49 | """)
50 |
51 | # Fetch one row of query results
52 | >>> row = cursor.fetchone()
53 |
54 | >>> print(row[0])
55 | (:Person {'name': 'John'})
56 |
57 | >>> print(row[1])
58 | [:KNOWS]
59 |
60 | >>> print(row[2])
61 | (:Person {'name': 'Steve'})
62 |
63 | # Make database changes persistent
64 | >>> conn.commit()
65 | ```
66 |
--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | name: Release
2 |
3 |
4 | on:
5 | workflow_dispatch:
6 | inputs:
7 | version:
8 | required: true
9 | type: string
10 | publish_doc:
11 | description: 'If set to true, then the documentation will be published.'
12 | default: false
13 | required: false
14 | type: boolean
15 | publish_pypi:
16 | default: false
17 | type: boolean
18 | description: "Attempt to publish packages to PyPI"
19 | test:
20 | default: true
21 | description: "Run test release (no docs, publish to test.pypi.org)"
22 | type: boolean
23 |
24 |
25 | env:
26 | PYMGCLIENT_OVERRIDE_VERSION: "${{ github.event.inputs.version }}"
27 |
28 | jobs:
29 | release_tests:
30 | name: Release Package and Test
31 | uses: "./.github/workflows/reusable_buildtest.yml"
32 | with:
33 | test_linux: true
34 | test_windows: true
35 | test_macintosh: true
36 | build_source_dist: true
37 | upload_artifacts: true
38 | version: ${{ inputs.version }}
39 | secrets: inherit
40 |
41 | build_docs:
42 | needs: [release_tests]
43 | name: Build Docs
44 | uses: "./.github/workflows/build_docs.yml"
45 | with:
46 | publish_doc: false
47 | secrets: inherit
48 |
49 | publish_artifacts:
50 | name: Collect Artifacts
51 | runs-on: ubuntu-24.04
52 | needs: [release_tests, build_docs]
53 |
54 | steps:
55 | - name: Download all artifacts
56 | uses: actions/download-artifact@v4
57 | with:
58 | # omit ‘name’ to fetch *every* artifact uploaded earlier
59 | path: ./downloaded-artifacts
60 |
61 | - name: Move Artifacts
62 | run: |
63 | mkdir -p dist
64 | mv -v downloaded-artifacts/*/* dist/
65 |
66 | - name: Show contents
67 | run: |
68 | ls dist/
69 |
70 | - name: Publish Package to PyPI
71 | if: ${{ inputs.publish_pypi == true && inputs.test == false }}
72 | uses: pypa/gh-action-pypi-publish@v1.4.2
73 | with:
74 | user: __token__
75 | password: ${{ secrets.PYPI_API_TOKEN }}
76 |
77 | - name: Publish Package to PyPI (TEST)
78 | if: ${{ inputs.publish_pypi == true && inputs.test }}
79 | uses: pypa/gh-action-pypi-publish@v1.4.2
80 | with:
81 | user: __token__
82 | password: ${{ secrets.TEST_PYPI_API_TOKEN }}
83 | repository_url: https://test.pypi.org/legacy/
84 | verbose: true
--------------------------------------------------------------------------------
/docs/source/module.rst:
--------------------------------------------------------------------------------
1 | ===============================
2 | :mod:`mgclient` module content
3 | ===============================
4 |
5 | The module interface respects the DB-API 2.0 standard defined in :pep:`249`.
6 |
7 | .. py:module:: mgclient
8 |
9 | .. autofunction:: mgclient.connect
10 |
11 | See :ref:`lazy-connections` section to learn about
12 | advantages and limitations of using the ``lazy`` parameter.
13 |
14 | ################
15 | Module constants
16 | ################
17 |
18 | DB-API 2.0 requires the following constants to be defined:
19 |
20 | .. data:: mgclient.apilevel
21 |
22 | String constant stating the supported DB API level. For :mod:`mgclient` it
23 | is ``2.0``.
24 |
25 | .. data:: mgclient.threadsafety
26 |
27 | Integer constant stating the level of thread safety the interface supports.
28 | For :mod:`mgclient` it is 1, meaning that threads may share the module, but
29 | not connections.
30 |
31 | .. data:: mgclient.paramstyle
32 |
33 | String constant stating the type of parameter marker formatting expected by
34 | the interface. For :mod:`mgclient` it is ``cypher``, which is not a valid
35 | value by DB-API 2.0 specification. See Passing parameters section for more
36 | details.
37 |
38 | ##########
39 | Exceptions
40 | ##########
41 |
42 | By DB-API 2.0 specification, the module makes all error information available
43 | through these exceptions or subclasses thereof:
44 |
45 | .. autoexception:: mgclient.Warning
46 |
47 | .. autoexception:: mgclient.Error
48 |
49 | .. autoexception:: mgclient.InterfaceError
50 |
51 | .. autoexception:: mgclient.DatabaseError
52 |
53 | .. autoexception:: mgclient.DataError
54 |
55 | .. autoexception:: mgclient.OperationalError
56 |
57 | .. autoexception:: mgclient.IntegrityError
58 |
59 | .. autoexception:: mgclient.InternalError
60 |
61 | .. autoexception:: mgclient.ProgrammingError
62 |
63 | .. autoexception:: mgclient.NotSupportedError
64 |
65 | .. NOTE::
66 |
67 | In the current state, :exc:`OperationalError` is raised for all errors
68 | obtained from the database. This will probably be improved in the future.
69 |
70 | ##################
71 | Graph type objects
72 | ##################
73 |
74 | .. autoclass:: mgclient.Node
75 |
76 | .. autoattribute:: mgclient.Node.id
77 | .. autoattribute:: mgclient.Node.labels
78 | .. autoattribute:: mgclient.Node.properties
79 |
80 | .. autoclass:: mgclient.Relationship
81 |
82 | .. autoattribute:: mgclient.Relationship.id
83 | .. autoattribute:: mgclient.Relationship.start_id
84 | .. autoattribute:: mgclient.Relationship.end_id
85 | .. autoattribute:: mgclient.Relationship.type
86 | .. autoattribute:: mgclient.Relationship.properties
87 |
88 |
89 | .. autoclass:: mgclient.Path
90 | :members:
91 |
--------------------------------------------------------------------------------
/docs/source/connection.rst:
--------------------------------------------------------------------------------
1 | ===============================
2 | :class:`Connection` class
3 | ===============================
4 |
5 | .. py:module:: mgclient
6 |
7 | .. autoclass:: mgclient.Connection
8 | :members:
9 |
10 | .. _lazy-connections:
11 |
12 | ################
13 | Lazy connections
14 | ################
15 |
16 | When a query is executed using :meth:`.execute` on a cursor, the default
17 | :mod:`mgclient` behaviour is to wait for all of the results to be available
18 | and store them into cursor's internal result list. On one hand, that means that
19 | :meth:`.execute` will block until all of the results are ready and all results
20 | will be stored in memory at the same time. On the other hand, that also means
21 | that result fetching methods will never block.
22 |
23 | Sometimes, that behaviour is unwanted. Maybe we don't need all results in
24 | memory at the same time, because we only want to do row-by-row processing on
25 | huge result sets. In that case, we may use a lazy connection.
26 |
27 | A lazy connection is created by passing ``True`` for ``lazy`` parameter when
28 | calling :func:`.connect`. Cursors created using lazy connections will only try
29 | to read results from the network socket when :meth:`.fetchone`,
30 | :meth:`.fetchmany` or :meth:`.fetchall` is called. Also, they can allocate less
31 | memory because they don't have to store the entire result set in memory at
32 | once.
33 |
34 | However, lazy connections have two limitations:
35 |
36 | 1. They are always in autocommit mode. If necessary, transactions can be
37 | explicitly managed by executing ``BEGIN``, ``COMMIT`` or ``ROLLBACK``
38 | queries.
39 |
40 | 2. At most one query can execute at a given time. Trying to execute multiple
41 | queries at once will raise an :exc:`InterfaceError`.
42 |
43 | Before trying to execute a new query, all results of the previous query
44 | must be fetched from its corresponding cursor (for example by calling
45 | :meth:`.fetchone` until it returns :obj:`None`, or by calling
46 | :meth:`.fetchmany`).
47 |
48 | Here's an example usage of a lazy connection::
49 |
50 | >>> import mgclient
51 |
52 | >>> conn = mgclient.connect(host="127.0.0.1", port=7687, lazy=True)
53 |
54 | >>> cursor = conn.cursor()
55 |
56 | >>> cursor.execute("UNWIND range(1, 3) AS n RETURN n * n")
57 |
58 | >>> cursor.fetchone()
59 | (1, )
60 |
61 | >>> cursor.fetchone()
62 | (4, )
63 |
64 | >>> cursor.fetchone()
65 | (9, )
66 |
67 | # We still didn't get None from fetchone()
68 | >>> cursor.execute("RETURN 100")
69 | Traceback (most recent call last):
70 | File "", line 1, in
71 | mgclient.InterfaceError: cannot call execute during execution of a query
72 |
73 | >>> cursor.fetchone()
74 | None
75 |
76 | # Now we can execute a new query
77 | >>> cursor.execute("RETURN 100")
78 |
79 |
--------------------------------------------------------------------------------
/docs/source/conf.py:
--------------------------------------------------------------------------------
1 | # Configuration file for the Sphinx documentation builder.
2 | #
3 | # This file only contains a selection of the most common options. For a full
4 | # list see the documentation:
5 | # http://www.sphinx-doc.org/en/master/config
6 |
7 | # -- Path setup --------------------------------------------------------------
8 |
9 | # If extensions (or modules to document with autodoc) are in another directory,
10 | # add these directories to sys.path here. If the directory is relative to the
11 | # documentation root, use os.path.abspath to make it absolute, like shown here.
12 | #
13 | # import os
14 | # import sys
15 | # sys.path.insert(0, os.path.abspath('.'))
16 |
17 | import inspect
18 |
19 | # -- Project information -----------------------------------------------------
20 |
21 | project = "pymgclient"
22 | copyright = "2022, Memgraph Ltd."
23 | author = "Memgraph Ltd."
24 |
25 | # The full version, including alpha/beta/rc tags
26 | release = "1.3.1"
27 |
28 |
29 | # -- General configuration ---------------------------------------------------
30 |
31 | # Add any Sphinx extension module names here, as strings. They can be
32 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
33 | # ones.
34 | extensions = ["sphinx.ext.autodoc"]
35 | autodoc_docstring_signature = True
36 |
37 | # Add any paths that contain templates here, relative to this directory.
38 | templates_path = ["_templates"]
39 |
40 | # List of patterns, relative to source directory, that match files and
41 | # directories to ignore when looking for source files.
42 | # This pattern also affects html_static_path and html_extra_path.
43 | exclude_patterns = []
44 |
45 |
46 | # -- Options for HTML output -------------------------------------------------
47 |
48 | # The theme to use for HTML and HTML Help pages. See the documentation for
49 | # a list of builtin themes.
50 | #
51 | html_theme = "alabaster"
52 |
53 | # Add any paths that contain custom static files (such as style sheets) here,
54 | # relative to this directory. They are copied after the builtin static files,
55 | # so a file named "default.css" will overwrite the builtin "default.css".
56 | html_static_path = ["_static"]
57 |
58 |
59 | def setup(app):
60 | # Type signature from C docstring is stripped and stored in
61 | # __text_signature__ attribute, so __doc__ attribute doesn't contain it.
62 | # Because of this, `autodoc_docstring_signature` doesn't work so we have to
63 | # do it manually.
64 | def process_signature(app, what, name, obj, options, signature, return_annotation):
65 | if signature:
66 | return (signature, return_annotation)
67 |
68 | try:
69 | sig = inspect.signature(obj)
70 | return (str(sig), return_annotation)
71 | except BaseException:
72 | return (None, None)
73 |
74 | app.connect("autodoc-process-signature", process_signature)
75 |
--------------------------------------------------------------------------------
/.github/workflows/build_docs.yml:
--------------------------------------------------------------------------------
1 | name: Build Docs
2 |
3 | on:
4 | workflow_dispatch:
5 | inputs:
6 | publish_doc:
7 | type: boolean
8 | description: 'If set to true, then the documentation will be published.'
9 | default: false
10 | required: false
11 | workflow_call:
12 | inputs:
13 | publish_doc:
14 | type: boolean
15 | description: 'If set to true, then the documentation will be published.'
16 | default: false
17 | required: false
18 |
19 | jobs:
20 | build_docs:
21 | name: Build Documentation
22 | runs-on: [self-hosted, Linux, Ubuntu24.04]
23 | steps:
24 | - name: Checkout repository and submodules
25 | uses: actions/checkout@v4
26 | with:
27 | fetch-depth: 0
28 | submodules: recursive
29 |
30 |
31 | - name: Install Python Dependencies
32 | run: |
33 | python3 -m venv env
34 | source env/bin/activate
35 | pip install sphinx
36 |
37 | - name: Get Current Python Version
38 | run: |
39 | version="$(( python3 --version 2>&1 || echo ) | grep -Po '(?<=Python )\d+\.\d+' || true)"
40 | echo "Found version $version"
41 | echo "PYTHON_VERSION=$version" >> $GITHUB_ENV
42 |
43 | - name: Download Python Artifact
44 | id: download
45 | uses: actions/download-artifact@v4
46 | continue-on-error: true
47 | with:
48 | name: pymgclient-linux-${{ env.PYTHON_VERSION }}
49 | path: ./dist
50 |
51 | - name: Record download status
52 | if: always()
53 | id: record
54 | run: |
55 | echo "Download step outcome: ${{ steps.download.outcome }}"
56 | if [[ "${{ steps.download.outcome }}" == "success" ]]; then
57 | echo "DOWNLOAD_STATUS=success" >> $GITHUB_OUTPUT
58 | else
59 | echo "DOWNLOAD_STATUS=failure" >> $GITHUB_OUTPUT
60 | fi
61 |
62 | - name: Install Pymgclient (Artifact)
63 | if: steps.record.outputs.DOWNLOAD_STATUS == 'success'
64 | run: |
65 | source env/bin/activate
66 | pip install dist/*.whl
67 |
68 | - name: Install Pymgclient (PyPI)
69 | if: steps.record.outputs.DOWNLOAD_STATUS == 'failure'
70 | run: |
71 | source env/bin/activate
72 | pip install pymgclient
73 |
74 | - name: Build docs
75 | run: |
76 | source env/bin/activate
77 | cd docs
78 | make html
79 | rm build/html/.buildinfo
80 | touch build/html/.nojekyll
81 |
82 | - name: Deploy docs
83 | if: ${{ github.event.inputs.publish_doc == 'true' }}
84 | uses: peaceiris/actions-gh-pages@v3
85 | with:
86 | github_token: ${{ secrets.GITHUB_TOKEN }}
87 | publish_dir: ./docs/build/html
88 |
89 | - name: Cleanup
90 | if: always()
91 | run: |
92 | rm -rf env || true
--------------------------------------------------------------------------------
/tools/wait_for_memgraph.sh:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 | set -euo pipefail
3 |
4 | TIMEOUT=10
5 | MEMGRAPH_CONSOLE_BINARY=${MEMGRAPH_CONSOLE_BINARY:-}
6 |
7 | usage() {
8 | cat < [port]
10 | --timeout SECONDS Max seconds to wait (default: $TIMEOUT)
11 | Memgraph host to check
12 | [port] Memgraph port (default: 7687)
13 | EOF
14 | exit 1
15 | }
16 |
17 | # --- parse flags ---
18 | while [[ $# -gt 0 ]]; do
19 | case $1 in
20 | --timeout)
21 | [[ -n "${2-}" ]] || { echo "Error: --timeout needs an argument" >&2; usage; }
22 | TIMEOUT=$2
23 | shift 2
24 | ;;
25 | -h|--help)
26 | usage
27 | ;;
28 | *)
29 | break
30 | ;;
31 | esac
32 | done
33 |
34 | # --- positional args ---
35 | (( $# >= 1 && $# <= 2 )) || usage
36 | HOST=$1
37 | PORT=${2-7687}
38 |
39 | # --- locate mgconsole ---
40 | if [[ -z "$MEMGRAPH_CONSOLE_BINARY" ]]; then
41 | if command -v mgconsole &>/dev/null; then
42 | MEMGRAPH_CONSOLE_BINARY=$(command -v mgconsole)
43 | HAVE_MGCONSOLE=1
44 | else
45 | HAVE_MGCONSOLE=0
46 | fi
47 | else
48 | if [[ ! -x "$MEMGRAPH_CONSOLE_BINARY" ]]; then
49 | echo "Error: \$MEMGRAPH_CONSOLE_BINARY set to '$MEMGRAPH_CONSOLE_BINARY', but not executable." >&2
50 | exit 1
51 | fi
52 | HAVE_MGCONSOLE=1
53 | fi
54 |
55 | # --- wait for a port on memgraph host with timeout ---
56 | wait_port() {
57 | local host=$1 port=$2 timeout=$3 start now
58 | start=$(date +%s)
59 | while true; do
60 | if timeout 1 bash -c "(exec 3<>'/dev/tcp/${host}/${port}')" 2>/dev/null; then
61 | return 0
62 | fi
63 | now=$(date +%s)
64 | (( now - start >= timeout )) && {
65 | echo "Timeout ($timeout s) waiting for $host:$port" >&2
66 | return 1
67 | }
68 | done
69 | }
70 |
71 | # --- wait for memgraph console to respond with timeout ---
72 | wait_for_memgraph() {
73 | local host=$1 port=$2 timeout=$3 start now
74 | start=$(date +%s)
75 | while true; do
76 | if timeout 1 bash -c "echo 'RETURN 1;' | \"$MEMGRAPH_CONSOLE_BINARY\" --host \"$host\" --port \"$port\" >/dev/null 2>&1"; then
77 | return 0
78 | fi
79 | now=$(date +%s)
80 | (( now - start >= timeout )) && {
81 | echo "Timeout ($timeout s) waiting for memgraph at $host:$port" >&2
82 | return 1
83 | }
84 | sleep 0.1
85 | done
86 | }
87 |
88 | # --- run checks ---
89 | echo "Waiting for TCP port $HOST:$PORT (timeout ${TIMEOUT}s)..."
90 | if wait_port "$HOST" "$PORT" "$TIMEOUT"; then
91 | timed_out=0
92 | else
93 | timed_out=1
94 | fi
95 |
96 | if (( HAVE_MGCONSOLE )); then
97 | echo "Waiting for memgraph console on $HOST:$PORT (timeout ${TIMEOUT}s)..."
98 | wait_for_memgraph "$HOST" "$PORT" "$TIMEOUT"
99 | else
100 | if [[ $timed_out == 1 ]]; then
101 | echo "mgconsole not found"
102 | exit 1
103 | fi
104 | echo "Note: mgconsole not found; skipping memgraph-console check."
105 | fi
106 |
107 | echo "Memgraph Started."
108 |
--------------------------------------------------------------------------------
/test/common.py:
--------------------------------------------------------------------------------
1 | # Copyright (c) 2016-2020 Memgraph Ltd. [https://memgraph.com]
2 | #
3 | # Licensed under the Apache License, Version 2.0 (the "License");
4 | # you may not use this file except in compliance with the License.
5 | # You may obtain a copy of the License at
6 | #
7 | # http://www.apache.org/licenses/LICENSE-2.0
8 | #
9 | # Unless required by applicable law or agreed to in writing, software
10 | # distributed under the License is distributed on an "AS IS" BASIS,
11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | # See the License for the specific language governing permissions and
13 | # limitations under the License.
14 |
15 | import os
16 | import subprocess
17 | import sys
18 | import time
19 | import tempfile
20 | import pytest
21 | import mgclient
22 |
23 | MEMGRAPH_PATH = os.getenv("MEMGRAPH_PATH", "/usr/lib/memgraph/memgraph")
24 | MEMGRAPH_PORT = int(os.getenv("MEMGRAPH_PORT", 7687))
25 | MEMGRAPH_HOST = os.getenv("MEMGRAPH_HOST", None)
26 | MEMGRAPH_STARTED_WITH_SSL = os.getenv("MEMGRAPH_STARTED_WITH_SSL", None)
27 | DURABILITY_DIR = tempfile.TemporaryDirectory()
28 |
29 |
30 | def wait_for_server(port):
31 | cmd = ["nc", "-z", "-w", "1", "127.0.0.1", str(port)]
32 | count = 0
33 | while subprocess.call(cmd) != 0:
34 | time.sleep(0.1)
35 | if count > 100:
36 | raise RuntimeError("Could not wait for server on port", port, "to startup!")
37 | sys.exit(1)
38 | count += 1
39 |
40 |
41 | requires_ssl_enabled = pytest.mark.skipif(
42 | MEMGRAPH_HOST is not None and MEMGRAPH_STARTED_WITH_SSL is None,
43 | reason="requires secure connection",
44 | )
45 |
46 | requires_ssl_disabled = pytest.mark.skipif(
47 | MEMGRAPH_HOST is not None and MEMGRAPH_STARTED_WITH_SSL is not None,
48 | reason="requires insecure connection",
49 | )
50 |
51 |
52 | class Memgraph:
53 | def __init__(self, host, port, use_ssl, process):
54 | self.host = host
55 | self.port = port
56 | self.use_ssl = use_ssl
57 | self.process = process
58 |
59 | def is_long_running(self):
60 | return self.process is None
61 |
62 | def sslmode(self):
63 | return (
64 | mgclient.MG_SSLMODE_REQUIRE if self.use_ssl else mgclient.MG_SSLMODE_DISABLE
65 | )
66 |
67 | def terminate(self):
68 | if self.process:
69 | self.process.terminate()
70 | self.process.wait()
71 |
72 |
73 | def start_memgraph(cert_file="", key_file=""):
74 | if MEMGRAPH_HOST:
75 | use_ssl = MEMGRAPH_STARTED_WITH_SSL is not None
76 | return Memgraph(MEMGRAPH_HOST, MEMGRAPH_PORT, use_ssl, None)
77 |
78 | cmd = [
79 | MEMGRAPH_PATH,
80 | "--bolt-port",
81 | str(MEMGRAPH_PORT),
82 | "--bolt-cert-file",
83 | cert_file,
84 | "--bolt-key-file",
85 | key_file,
86 | "--data-directory",
87 | DURABILITY_DIR.name,
88 | "--storage-properties-on-edges=true",
89 | "--storage-snapshot-interval-sec=0",
90 | "--storage-wal-enabled=false",
91 | "--storage-snapshot-on-exit=false",
92 | "--telemetry-enabled=false",
93 | "--log-file",
94 | "--timezone",
95 | "UTC",
96 | ]
97 | memgraph_process = subprocess.Popen(cmd)
98 | wait_for_server(MEMGRAPH_PORT)
99 | use_ssl = True if key_file.strip() else False
100 | return Memgraph("localhost", MEMGRAPH_PORT, use_ssl, memgraph_process)
101 |
--------------------------------------------------------------------------------
/CHANGELOG.rst:
--------------------------------------------------------------------------------
1 | =========
2 | Changelog
3 | =========
4 |
5 | ######
6 | 1.5.1
7 | ######
8 |
9 |
10 | *********
11 | Bug Fixes
12 | *********
13 |
14 | * Replace `PyDateTime_DATE_GET_TZINFO` with Python 3.9 compatible function
15 |
16 |
17 | ######
18 | 1.5.0
19 | ######
20 |
21 |
22 | ******************************
23 | Major Feature and Improvements
24 | ******************************
25 |
26 | * mgclient has been updated to 1.5.0
27 | * Update minimum Python requirement to 3.9
28 | * Support for zoned datetime objects with named IANA timezones or offsets
29 | * Remove deprecated `datetime.utcfromtimestamp()` usage in C extension
30 |
31 |
32 | ######
33 | 1.4.0
34 | ######
35 |
36 |
37 | ******************************
38 | Major Feature and Improvements
39 | ******************************
40 |
41 | * Update CI to use newer Python versions
42 | * Update release workflow and CI infrastructure
43 |
44 | ######
45 | 1.3.1
46 | ######
47 |
48 |
49 | ******************************
50 | Major Feature and Improvements
51 | ******************************
52 |
53 | * Use OpenSSL 1.1.1q and 3.0.5 versions for binary packages
54 |
55 | *********
56 | Bug Fixes
57 | *********
58 |
59 | * Fixed import path of errors from `distutils`
60 |
61 | ######
62 | 1.3.0
63 | ######
64 |
65 |
66 | ******************************
67 | Major Feature and Improvements
68 | ******************************
69 |
70 | * mgclient has been updated to 1.4.0
71 | * Support for OpenSSL 3
72 | * Use OpenSSL 1.1.1o and 3.0.3 versions for binary packages
73 |
74 | ######
75 | 1.2.1
76 | ######
77 |
78 |
79 | ******************************
80 | Major Feature and Improvements
81 | ******************************
82 |
83 | * Use OpenSSL 1.1.1n for binary packages
84 |
85 | ######
86 | 1.2.0
87 | ######
88 |
89 |
90 | ******************************
91 | Major Feature and Improvements
92 | ******************************
93 |
94 | * Add suport for arm64 macOS machines.
95 | * Link OpenSSL statically by default.
96 | * Add support for Python 3.10.
97 |
98 | ######
99 | 1.1.0
100 | ######
101 |
102 |
103 | ****************
104 | Breaking Changes
105 | ****************
106 |
107 | * `pymgclient` is supported only for >3.7 python versions on Windows.
108 |
109 | ******************************
110 | Major Feature and Improvements
111 | ******************************
112 |
113 | * Add support for temporal types.
114 |
115 | *********
116 | Bug Fixes
117 | *********
118 |
119 | ######
120 | 1.0.0
121 | ######
122 |
123 |
124 | ****************
125 | Breaking Changes
126 | ****************
127 |
128 | ******************************
129 | Major Feature and Improvements
130 | ******************************
131 |
132 | * Include `mgclient` to decouple pymgclient from the installed version of
133 | `mgclient`, thus make the building and usage easier.
134 | * Add support for macOS and Windows.
135 |
136 | *********
137 | Bug Fixes
138 | *********
139 |
140 | * Fix various memory leaks.
141 | * Fix transaction handling when an error happens in explicit transactional
142 | mode. The running transaction is reset and a new one is started with the
143 | next command.
144 |
145 | ######
146 | 0.1.0
147 | ######
148 |
149 |
150 | ****************
151 | Breaking Changes
152 | ****************
153 |
154 | ******************************
155 | Major Feature and Improvements
156 | ******************************
157 |
158 | * Initial implementation of DB-API 2.0 specification described by :pep:`249`.
159 |
--------------------------------------------------------------------------------
/src/column.c:
--------------------------------------------------------------------------------
1 | // Copyright (c) 2016-2020 Memgraph Ltd. [https://memgraph.com]
2 | //
3 | // Licensed under the Apache License, Version 2.0 (the "License");
4 | // you may not use this file except in compliance with the License.
5 | // You may obtain a copy of the License at
6 | //
7 | // http://www.apache.org/licenses/LICENSE-2.0
8 | //
9 | // Unless required by applicable law or agreed to in writing, software
10 | // distributed under the License is distributed on an "AS IS" BASIS,
11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | // See the License for the specific language governing permissions and
13 | // limitations under the License.
14 |
15 | #include "column.h"
16 |
17 | #include
18 |
19 | static void column_dealloc(ColumnObject *column) {
20 | Py_XDECREF(column->name);
21 | Py_XDECREF(column->type_code);
22 | Py_XDECREF(column->display_size);
23 | Py_XDECREF(column->internal_size);
24 | Py_XDECREF(column->precision);
25 | Py_XDECREF(column->scale);
26 | Py_XDECREF(column->null_ok);
27 | Py_TYPE(column)->tp_free(column);
28 | }
29 |
30 | static PyObject *column_repr(ColumnObject *column) {
31 | return PyUnicode_FromFormat(
32 | "<%s(name=%R, type_code=%R, display_size=%R, internal_size=%R, "
33 | "precision=%R, scale=%R, null_ok=%R) at %p>",
34 | Py_TYPE(column)->tp_name, column->name, column->type_code,
35 | column->display_size, column->internal_size, column->precision,
36 | column->scale, column->null_ok, column);
37 | }
38 |
39 | int column_init(ColumnObject *column, PyObject *args, PyObject *kwargs) {
40 | PyObject *name;
41 | static char *kwlist[] = {"", NULL};
42 | if (!PyArg_ParseTupleAndKeywords(args, kwargs, "O", kwlist, &name)) {
43 | return -1;
44 | }
45 |
46 | if (!PyUnicode_Check(name)) {
47 | PyErr_SetString(PyExc_TypeError, "__init__ argument 1 must be a string");
48 | return -1;
49 | }
50 |
51 | PyObject *tmp_name = column->name;
52 | Py_INCREF(name);
53 | column->name = name;
54 | Py_XDECREF(tmp_name);
55 |
56 | PyObject *tmp_type_code = column->type_code;
57 | Py_INCREF(Py_None);
58 | column->type_code = Py_None;
59 | Py_XDECREF(tmp_type_code);
60 |
61 | PyObject *tmp_display_size = column->display_size;
62 | Py_INCREF(Py_None);
63 | column->display_size = Py_None;
64 | Py_XDECREF(tmp_display_size);
65 |
66 | PyObject *tmp_internal_size = column->internal_size;
67 | Py_INCREF(Py_None);
68 | column->internal_size = Py_None;
69 | Py_XDECREF(tmp_internal_size);
70 |
71 | PyObject *tmp_precision = column->precision;
72 | Py_INCREF(Py_None);
73 | column->precision = Py_None;
74 | Py_XDECREF(tmp_precision);
75 |
76 | PyObject *tmp_scale = column->scale;
77 | Py_INCREF(Py_None);
78 | column->scale = Py_None;
79 | Py_XDECREF(tmp_scale);
80 |
81 | PyObject *tmp_null_ok = column->null_ok;
82 | Py_INCREF(Py_None);
83 | column->null_ok = Py_None;
84 | Py_XDECREF(tmp_null_ok);
85 |
86 | return 0;
87 | }
88 |
89 | PyDoc_STRVAR(ColumnType_name_doc, "name of the returned column");
90 | PyDoc_STRVAR(
91 | ColumnType_unsupported_doc,
92 | "always set to ``None`` (required by DB-API 2.0 spec, but not supported)");
93 |
94 | static PyMemberDef column_members[] = {
95 | {"name", T_OBJECT, offsetof(ColumnObject, name), READONLY,
96 | ColumnType_name_doc},
97 | {"type_code", T_OBJECT, offsetof(ColumnObject, type_code), READONLY,
98 | ColumnType_unsupported_doc},
99 | {"display_size", T_OBJECT, offsetof(ColumnObject, display_size), READONLY,
100 | ColumnType_unsupported_doc},
101 | {"internal_size", T_OBJECT, offsetof(ColumnObject, internal_size), READONLY,
102 | ColumnType_unsupported_doc},
103 | {"precision", T_OBJECT, offsetof(ColumnObject, precision), READONLY,
104 | ColumnType_unsupported_doc},
105 | {"scale", T_OBJECT, offsetof(ColumnObject, scale), READONLY,
106 | ColumnType_unsupported_doc},
107 | {"null_ok", T_OBJECT, offsetof(ColumnObject, null_ok), READONLY,
108 | ColumnType_unsupported_doc},
109 | {NULL}};
110 |
111 | PyDoc_STRVAR(ColumnType_doc, "Description of a column returned by the query.");
112 |
113 | // clang-format off
114 | PyTypeObject ColumnType = {
115 | PyVarObject_HEAD_INIT(NULL, 0)
116 | .tp_name = "mgclient.Column",
117 | .tp_basicsize = sizeof(ColumnObject),
118 | .tp_itemsize = 0,
119 | .tp_dealloc = (destructor)column_dealloc,
120 | .tp_repr = (reprfunc)column_repr,
121 | .tp_flags = Py_TPFLAGS_DEFAULT,
122 | .tp_doc = ColumnType_doc,
123 | .tp_members = column_members,
124 | .tp_init = (initproc)column_init,
125 | .tp_new = PyType_GenericNew
126 | };
127 | // clang-format on
128 |
--------------------------------------------------------------------------------
/docs/source/usage.rst:
--------------------------------------------------------------------------------
1 | ==================
2 | Basic module usage
3 | ==================
4 |
5 | .. py:module:: mgclient
6 |
7 | Basic :mod:`mgclient` module usage is similar to that of all the database
8 | adapters compliant the DB-API 2.0. To use the module, you must:
9 |
10 | 1. Create a :class:`Connection` object, using the :func:`.connect`
11 | function.
12 |
13 | 2. Create a :class:`Cursor` object by calling :meth:`.cursor` on
14 | the :class:`Connection` object.
15 |
16 | 3. Call cursor's :meth:`.execute` method to perform openCypher queries.
17 |
18 | 4. Make database changes persistent by calling :meth:`.commit`, or drop them
19 | by calling :meth:`.rollback`.
20 |
21 | Here is an example of an interactive session
22 | showing some of the basic commands::
23 |
24 | >>> import mgclient
25 |
26 | # Make a connection to the database
27 | >>> conn = mgclient.connect(host='127.0.0.1', port=7687)
28 |
29 | # Create a cursor for query execution
30 | >>> cursor = conn.cursor()
31 |
32 | # Execute a query
33 | >>> cursor.execute("""
34 | CREATE (n:Person {name: 'John'})-[e:KNOWS]->
35 | (m:Person {name: 'Steve'})
36 | RETURN n, e, m
37 | """)
38 |
39 | # Fetch one row of query results
40 | >>> row = cursor.fetchone()
41 |
42 | >>> print(row[0])
43 | (:Person {'name': 'John'})
44 |
45 | >>> print(row[1])
46 | [:KNOWS]
47 |
48 | >>> print(row[2])
49 | (:Person {'name': 'Steve'})
50 |
51 | # Make database changes persistent
52 | >>> conn.commit()
53 |
54 | ########################################
55 | Passing parameters to openCypher queries
56 | ########################################
57 |
58 | Usually, your openCypher queries will need to use the values from Python
59 | variables. You shouldn't assemble your query using Python's string operators
60 | because doing so is insecure.
61 |
62 | Instead, you should use the parameter substitution mechanism built into
63 | Memgraph. Put ``$name`` as a placeholder wherever you want to use a value, and
64 | the provide a dictionary mapping names to values as the second argument to the
65 | cursor's :meth:`.execute`. For example::
66 |
67 | # Don't do this!
68 | server_id = 'srvr-38219012-sw'
69 | c.execute("MATCH (s:Server {id: '%s'}) SET s.hits = s.hits + 1"
70 | % server_id)
71 |
72 | # Instead, do this
73 | c.execute("MATCH (s:Server {id: $sid}) SET s.hits = s.htis + 1",
74 | {'sid': server_id})
75 |
76 |
77 | #############################################
78 | Adaptation of Memgraph values to Python types
79 | #############################################
80 |
81 | The following table shows the mapping between Python and Memgraph types:
82 |
83 | ============= ===============================
84 | Memgraph Python
85 | ============= ===============================
86 | Null :const:`None`
87 | Boolean :class:`bool`
88 | Integer :class:`int`
89 | Float :class:`float`
90 | String :const:`str`
91 | Date :class:`datetime.date`
92 | LocalTime :class:`datetime.time`
93 | LocalDateTime :class:`datetime.datetime`
94 | Duration :class:`datetime.timedelta`
95 | List :class:`list`
96 | Map :class:`dict`
97 | Node :class:`mgclient.Node`
98 | Relationship :class:`mgclient.Relationship`
99 | Path :class:`mgclient.Path`
100 | ============= ===============================
101 |
102 | Note that in Bolt protocol, all string data is represented as UTF-8 encoded
103 | binary data.
104 |
105 | ####################
106 | Transactions control
107 | ####################
108 |
109 | In :mod:`mgclient` transactions are handled by the :class:`Connection` class.
110 | By default, the first time a command is sent to the database using one of the
111 | :class:`Cursor` objects created by it, a new transaction is started (by sending
112 | ``BEGIN`` command to Memgraph). All following commands (issued by any of the
113 | cursors) will be executed in the context of the same transaction. If any of the
114 | commands fails, the transaction will not be able to commit and no further
115 | command will successfuly execute until :meth:`.rollback` is called.
116 |
117 | The connection is responsible for terming its transaction, either by calling
118 | :meth:`.commit` or :meth:`.rollback`. Closing the connection using
119 | :meth:`Connection.close` or destroying the connection object results
120 | in an implicit rollback.
121 |
122 | You can set the connection in *autocommit* mode: that way all commands executed
123 | will be immediately committed and no rollback is possible. A few commands
124 | (``CREATE INDEX``, ``CREATE USER`` and similar) require to be run outside any
125 | transaction. To set the connection in *autocommit* mode, set
126 | :attr:`.autocommit` property of the connection to ``True``.
127 |
--------------------------------------------------------------------------------
/tools/install_linux_deps.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | # This script is for installing the dependencies required for building,
4 | # installing and testing pymgclient
5 |
6 | #!/usr/bin/env bash
7 | set -euo pipefail
8 |
9 | # defaults
10 | python_version=""
11 | distro=""
12 | force_update=false
13 |
14 | usage() {
15 | cat <] [--python-version X.Y]
17 | Optional distro tag, e.g. ubuntu-24.04, fedora-42
18 | --python-version X.Y Optional Python MAJOR.MINOR (e.g. 3.12).
19 | Defaults to system python3's MAJOR.MINOR.
20 | --force-update Force python packages to be updated
21 | EOF
22 | exit 1
23 | }
24 |
25 |
26 | # parse flags + optional positional distro
27 | while [[ $# -gt 0 ]]; do
28 | case "$1" in
29 | --python-version)
30 | if [[ -z "${2-}" ]]; then
31 | echo "Error: --python-version needs an argument" >&2
32 | usage
33 | fi
34 | python_version="$2"
35 | shift 2
36 | ;;
37 | --force-update)
38 | force_update=true
39 | shift 1
40 | ;;
41 | -h|--help)
42 | usage
43 | ;;
44 | --*) # any other flag
45 | echo "Unknown option: $1" >&2
46 | usage
47 | ;;
48 | *) # first non-flag is our distro
49 | if [[ -z "$distro" ]]; then
50 | distro="$1"
51 | shift
52 | else
53 | echo "Unexpected argument: $1" >&2
54 | usage
55 | fi
56 | ;;
57 | esac
58 | done
59 |
60 |
61 | # detect python_version if not given
62 | if [[ -z "$python_version" ]]; then
63 | python_version="$(python3 --version | grep -Eo '[0-9]+\.[0-9]+' )"
64 | fi
65 | python_binary="python${python_version}"
66 |
67 | # Detect distro from /etc/os-release
68 | detect_distro() {
69 | if [[ -r /etc/os-release ]]; then
70 | . /etc/os-release
71 | # Normalize common IDs
72 | case "$ID" in
73 | ubuntu|debian|linuxmint|fedora|centos|rhel|rocky)
74 | # version might be "24.04" or "9" or "42"
75 | # some versions include quotes
76 | ver="${VERSION_ID//\"/}"
77 | echo "${ID} ${ver}"
78 | ;;
79 | *)
80 | echo "unknown-$(uname -s | tr '[:upper:]' '[:lower:]') $(uname -r)" ;;
81 | esac
82 | else
83 | echo "unknown-$(uname -s | tr '[:upper:]' '[:lower:]') $(uname -r)"
84 | fi
85 | }
86 |
87 | # Ensure at least one arg
88 | if [[ $# -gt 1 ]]; then
89 | usage
90 | fi
91 |
92 | # If distro not provided, detect it
93 | if [[ -z "$distro" ]]; then
94 | read distro version < <(detect_distro)
95 | if [[ "$distro" == unknown* ]]; then
96 | echo "Unknown distro detected"
97 | exit 1
98 | fi
99 | else
100 | # split version from distro, e.g. `ubuntu-24.04` -> `ubuntu` `24.04`
101 | version="${distro#*-}"
102 | distro="${distro%%-*}"
103 | fi
104 | echo "Linux Distro: $distro $version"
105 |
106 | # detect if we need sudo or not
107 | SUDO=()
108 | if (( EUID != 0 )); then
109 | if ! command -v sudo &>/dev/null; then
110 | echo "Error: root privileges or sudo required." >&2
111 | exit 1
112 | fi
113 | SUDO=(sudo)
114 | fi
115 |
116 | DEB_DEPS=(
117 | python${python_version}
118 | python${python_version}-dev
119 | python3-pip
120 | python3-setuptools
121 | python3-wheel
122 | libpython${python_version}
123 | cmake
124 | g++
125 | libssl-dev
126 | netcat-traditional
127 | patchelf
128 | libatomic1
129 | )
130 |
131 | RPM_DEPS=(
132 | python${python_version}
133 | python${python_version}-devel
134 | python3-pip
135 | python3-setuptools
136 | python3-wheel
137 | cmake
138 | g++
139 | openssl-devel
140 | nmap-ncat
141 | libatomic
142 | )
143 |
144 | install_deb() {
145 | echo "Installing DEB dependencies..."
146 | installed_python_version="$(( python3 --version 2>&1 || echo ) | grep -Po '(?<=Python )\d+\.\d+' || true)"
147 | if [[ "$python_version" != "$installed_python_version" ]]; then
148 | echo "Installed Python version ${installed_python_version} does not match requested version ${python_version}"
149 | if [[ "$distro" == "debian" ]]; then
150 | exit 1
151 | else
152 | echo "Adding deadsnakes PPA"
153 | "${SUDO[@]}" apt-get update
154 | "${SUDO[@]}" apt-get install -y software-properties-common
155 | "${SUDO[@]}" add-apt-repository -y ppa:deadsnakes/ppa
156 | fi
157 | fi
158 | if [[ ("$distro" == "ubuntu" && ${version#*.} -ge 24) \
159 | || ("$distro" == "linuxmint" && ${version#*.} -ge 22) ]]; then
160 | DEB_DEPS+=( libcurl4t64 )
161 | else
162 | DEB_DEPS+=( libcurl4 )
163 | fi
164 | "${SUDO[@]}" apt-get update
165 | "${SUDO[@]}" apt-get install -y ${DEB_DEPS[*]}
166 | }
167 |
168 | install_rpm() {
169 | echo "Installing RPM dependencies..."
170 | "${SUDO[@]}" dnf install -y ${RPM_DEPS[*]}
171 | }
172 |
173 | case "$distro" in
174 | debian|ubuntu|linuxmint)
175 | install_deb
176 | ;;
177 | centos|fedora|rocky|rhel)
178 | install_rpm
179 | ;;
180 | *)
181 | echo "Unsupported Distro: $distro" >&2
182 | exit 1
183 | ;;
184 | esac
185 |
186 | # install python dependencies
187 | export PIP_BREAK_SYSTEM_PACKAGES=1
188 | pkgs=(networkx pytest pyopenssl sphinx setuptools wheel auditwheel tzdata)
189 | if [[ $force_update == true ]]; then
190 | "$python_binary" -m pip install --upgrade --ignore-installed ${pkgs[@]}
191 | else
192 | for pkg in "${pkgs[@]}"; do
193 | echo "Installing/upgrading $pkg..."
194 | if ! "$python_binary" -m pip install --upgrade "$pkg"; then
195 | echo "Warning: pip failed on $pkg, continuing…" >&2
196 | fi
197 | done
198 | fi
--------------------------------------------------------------------------------
/src/connection-int.c:
--------------------------------------------------------------------------------
1 | // Copyright (c) 2016-2020 Memgraph Ltd. [https://memgraph.com]
2 | //
3 | // Licensed under the Apache License, Version 2.0 (the "License");
4 | // you may not use this file except in compliance with the License.
5 | // You may obtain a copy of the License at
6 | //
7 | // http://www.apache.org/licenses/LICENSE-2.0
8 | //
9 | // Unless required by applicable law or agreed to in writing, software
10 | // distributed under the License is distributed on an "AS IS" BASIS,
11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | // See the License for the specific language governing permissions and
13 | // limitations under the License.
14 |
15 | #include "connection.h"
16 | #include "exceptions.h"
17 | #include "glue.h"
18 |
19 | int connection_raise_if_bad_status(const ConnectionObject *conn) {
20 | if (conn->status == CONN_STATUS_BAD) {
21 | PyErr_SetString(InterfaceError, "bad session");
22 | return -1;
23 | }
24 | if (conn->status == CONN_STATUS_CLOSED) {
25 | PyErr_SetString(InterfaceError, "session closed");
26 | return -1;
27 | }
28 | return 0;
29 | }
30 |
31 | void connection_handle_error(ConnectionObject *conn, int error) {
32 | if (mg_session_status(conn->session) == MG_SESSION_BAD) {
33 | conn->status = CONN_STATUS_BAD;
34 | } else if (error == MG_ERROR_TRANSIENT_ERROR ||
35 | error == MG_ERROR_DATABASE_ERROR ||
36 | error == MG_ERROR_CLIENT_ERROR) {
37 | conn->status = CONN_STATUS_READY;
38 | }
39 | PyErr_SetString(DatabaseError, mg_session_error(conn->session));
40 | }
41 |
42 | int connection_run_without_results(ConnectionObject *conn, const char *query) {
43 | int status = mg_session_run(conn->session, query, NULL, NULL, NULL, NULL);
44 | if (status != 0) {
45 | connection_handle_error(conn, status);
46 | return -1;
47 | }
48 |
49 | status = mg_session_pull(conn->session, NULL);
50 | if (status != 0) {
51 | connection_handle_error(conn, status);
52 | return -1;
53 | }
54 |
55 | while (1) {
56 | mg_result *result;
57 | int status = mg_session_fetch(conn->session, &result);
58 | if (status == 0) {
59 | break;
60 | }
61 | if (status == 1) {
62 | if (PyErr_WarnFormat(Warning, 2,
63 | "unexpected rows received after executing '%s'",
64 | query) < 0) {
65 | return -1;
66 | }
67 | }
68 | if (status < 0) {
69 | connection_handle_error(conn, status);
70 | return -1;
71 | }
72 | }
73 |
74 | return 0;
75 | }
76 |
77 | int connection_run(ConnectionObject *conn, const char *query, PyObject *params,
78 | PyObject **columns) {
79 | // This should be used to start the execution of a query, so we validate
80 | // we're in a valid state for query execution.
81 | assert((conn->autocommit && conn->status == CONN_STATUS_READY) ||
82 | (!conn->autocommit && conn->status == CONN_STATUS_IN_TRANSACTION));
83 |
84 | mg_map *mg_params = NULL;
85 | if (params) {
86 | mg_params = py_dict_to_mg_map(params);
87 | if (!mg_params) {
88 | return -1;
89 | }
90 | }
91 |
92 | const mg_list *mg_columns;
93 | int status =
94 | mg_session_run(conn->session, query, mg_params, NULL, &mg_columns, NULL);
95 | mg_map_destroy(mg_params);
96 |
97 | if (status != 0) {
98 | connection_handle_error(conn, status);
99 | return -1;
100 | }
101 |
102 | if (columns) {
103 | *columns = mg_list_to_py_list(mg_columns);
104 | }
105 |
106 | conn->status = CONN_STATUS_EXECUTING;
107 | return 0;
108 | }
109 |
110 | int connection_pull(ConnectionObject *conn, long n) {
111 | assert(conn->status == CONN_STATUS_EXECUTING);
112 |
113 | int status;
114 | if (n == 0) { // PULL_ALL
115 | status = mg_session_pull(conn->session, NULL);
116 | } else { // PULL_N
117 | mg_map *pull_information = mg_map_make_empty(1);
118 | mg_value *pull_info_n = mg_value_make_integer(n);
119 | mg_map_insert(pull_information, "n", pull_info_n);
120 | status = mg_session_pull(conn->session, pull_information);
121 | }
122 | if (status == 0) {
123 | conn->status = CONN_STATUS_FETCHING;
124 | return 0;
125 | } else {
126 | connection_handle_error(conn, status);
127 | return -1;
128 | }
129 | }
130 |
131 | int connection_fetch(ConnectionObject *conn, PyObject **row,
132 | int *has_more_out) {
133 | assert(conn->status == CONN_STATUS_FETCHING);
134 |
135 | mg_result *result;
136 | int status = mg_session_fetch(conn->session, &result);
137 | if (status == 0) {
138 | const mg_map *mg_summary = mg_result_summary(result);
139 | const mg_value *mg_has_more = mg_map_at(mg_summary, "has_more");
140 | const int has_more = mg_value_bool(mg_has_more);
141 | if (!has_more) {
142 | conn->status =
143 | conn->autocommit ? CONN_STATUS_READY : CONN_STATUS_IN_TRANSACTION;
144 | } else {
145 | conn->status = CONN_STATUS_EXECUTING;
146 | }
147 | if (has_more_out) {
148 | *has_more_out = has_more;
149 | }
150 | }
151 |
152 | if (status < 0) {
153 | // TODO (gitbuda): Define new CUSOR_STATUS to handle query error.
154 | // Since database has to pull data ahead of time because of has_more info,
155 | // by saving the status here, pymgclient would be able to "simulate" the
156 | // right behaviour and raise error at the right time. Cursor::fetchone has
157 | // the most questionable behaviour because it returns error one step
158 | // earlier.
159 | connection_handle_error(conn, status);
160 | return -1;
161 | }
162 | if (status == 1 && row) {
163 | PyObject *pyresult = mg_list_to_py_tuple(mg_result_row(result));
164 | if (!pyresult) {
165 | connection_discard_all(conn);
166 | // the connection_handle_error mustn't be called here, as the error
167 | // doesn't affect the status of the connection
168 | return -1;
169 | }
170 | *row = pyresult;
171 | }
172 | assert(status == 0 || status == 1);
173 | return status;
174 | }
175 |
176 | int connection_begin(ConnectionObject *conn) {
177 | assert(!conn->lazy && conn->status == CONN_STATUS_READY);
178 |
179 | // send BEGIN command and expect no results
180 | if (connection_run_without_results(conn, "BEGIN") < 0) {
181 | return -1;
182 | }
183 |
184 | conn->status = CONN_STATUS_IN_TRANSACTION;
185 | return 0;
186 | }
187 |
188 | void connection_discard_all(ConnectionObject *conn) {
189 | assert(conn->status == CONN_STATUS_EXECUTING);
190 | assert(PyErr_Occurred());
191 |
192 | PyObject *prev_exc;
193 | {
194 | PyObject *type, *traceback;
195 | PyErr_Fetch(&type, &prev_exc, &traceback);
196 | PyErr_NormalizeException(&type, &prev_exc, &traceback);
197 | Py_XDECREF(type);
198 | Py_XDECREF(traceback);
199 | }
200 |
201 | int status = mg_session_pull(conn->session, NULL);
202 | if (status == 0) {
203 | mg_result *result;
204 | while ((status = mg_session_fetch(conn->session, &result)) == 1)
205 | ;
206 | }
207 |
208 | if (status == 0) {
209 | // We successfuly discarded all of the results.
210 | PyErr_SetString(InterfaceError,
211 | "There was an error fetching query results. The query has "
212 | "executed successfully but the results were discarded.");
213 | PyObject *type, *curr_exc, *traceback;
214 | PyErr_Fetch(&type, &curr_exc, &traceback);
215 | PyErr_NormalizeException(&type, &curr_exc, &traceback);
216 | PyException_SetCause(curr_exc, prev_exc);
217 | PyErr_Restore(type, curr_exc, traceback);
218 | } else {
219 | // There was a database error while pulling the rest of the results.
220 | connection_handle_error(conn, status);
221 | PyObject *pulling_exc;
222 | {
223 | PyObject *type, *traceback;
224 | PyErr_Fetch(&type, &pulling_exc, &traceback);
225 | PyErr_NormalizeException(&type, &pulling_exc, &traceback);
226 | Py_XDECREF(type);
227 | Py_XDECREF(traceback);
228 | }
229 |
230 | PyErr_SetString(
231 | InterfaceError,
232 | "There was an error fetching query results. While pulling the rest of "
233 | "the results from server to discard them, another exception occurred. "
234 | "It is not certain whether the query executed successfuly.");
235 | PyObject *type, *curr_exc, *traceback;
236 | PyErr_Fetch(&type, &curr_exc, &traceback);
237 | PyErr_NormalizeException(&type, &curr_exc, &traceback);
238 | PyException_SetCause(pulling_exc, prev_exc);
239 | PyException_SetCause(curr_exc, pulling_exc);
240 | PyErr_Restore(type, curr_exc, traceback);
241 | }
242 |
243 | if (conn->status != CONN_STATUS_BAD) {
244 | conn->status =
245 | conn->autocommit ? CONN_STATUS_READY : CONN_STATUS_IN_TRANSACTION;
246 | }
247 | }
248 |
--------------------------------------------------------------------------------
/docs/source/introduction.rst:
--------------------------------------------------------------------------------
1 | ============
2 | Introduction
3 | ============
4 |
5 | pymgclient is a `Memgraph `_ database adapter for Python
6 | language compliant with the DB-API 2.0 specification described by :pep:`249`.
7 |
8 | :py:mod:`mgclient` module is the current implementation of the adapter. It is
9 | implemented in C as a wrapper around `mgclient`_, the official Memgraph client
10 | library. As a C extension, it is only compatible with CPython implementation of
11 | the Python programming language.
12 |
13 | :py:mod:`mgclient` only works with Python 3.
14 |
15 |
16 | #############
17 | Installation
18 | #############
19 |
20 | pymgclient has prebuilt binary packages for `Python
21 | `_ 3.10 - 3.13 on
22 |
23 | * Linux amd64
24 |
25 | * macOS 14 (Sonoma) and 15 (Sequoia) on arm64
26 |
27 | * Windows x86_64
28 |
29 | To install pymgclient binaries on these platforms see `Install binaries`_ section.
30 | A source distribution is also provided for other distributions and can be installed
31 | using ``pip`` after installing `Build prerequisites`_; alternatively -
32 | see `Install from source`_.
33 |
34 | Install binaries
35 | ################
36 |
37 | .. warning::
38 | All of the binary packages are statically linked against OpenSSL, that means the
39 | version of OpenSSL they are using is fixed. If security is important for you,
40 | you should check how to build pymgclient with dynamically linked OpenSSL, so
41 | pymgclient can use the latest version of OpenSSL that is installed on your
42 | machine. The source distribution may be built with the flags to dynamically
43 | link to the host machine's OpenSSL library.
44 |
45 | ``--no-build-isolation --global-option=build_ext --global-option "--static-openssl=False"``
46 |
47 | On Linux and macOS run::
48 |
49 | $ pip3 install --user pymgclient
50 |
51 | On Windows run::
52 |
53 | $ py -3 -m pip install --user pymgclient
54 |
55 | Alternatively, on Windows, if the launcher is not installed, just run::
56 |
57 | $ pip install --user pymgclient
58 |
59 | .. note::
60 | Some platforms may require using the ``--break-system-packages`` flag.
61 |
62 |
63 | Install from source
64 | ###################
65 |
66 | pymgclient can be installed from source on:
67 |
68 | * all platforms that have prebuilt binaries
69 | * on various Linux distributions, including:
70 |
71 | * Ubuntu 22.04+
72 | * Debian 11+
73 | * Centos 9+
74 | * Fedora 41+
75 |
76 | *******************
77 | Build prerequisites
78 | *******************
79 |
80 | pymgclient is a C wrapper around the `mgclient`_ Memgraph client library. To
81 | build it from you will need:
82 |
83 | * Python 3.7 (3.9 for Mac OS) or newer
84 | * Python 3.7 (3.9 for Mac OS) or newer header files
85 | * A C compiler supporting C11 standard
86 | * A C++ compiler (it is not used directly, but necessary for CMake to work)
87 | * Preqrequisites of `mgclient`_:
88 |
89 | * CMake 3.8 or newer
90 | * OpenSSL 1.0.2 or newer (including OpenSSL 3.0.0+) and its header files
91 |
92 | Building on Linux
93 | *****************
94 |
95 | First install the prerequisites:
96 |
97 | * On Debian/Ubuntu::
98 |
99 | $ sudo apt install python3-dev cmake make gcc g++ libssl-dev
100 | * On CentOS/Fedora::
101 |
102 | $ sudo dnf install -y python3-devel cmake3 make gcc gcc-c++ openssl-devel
103 |
104 | After the prerequisites are installed pymgclient can be installed via pip::
105 |
106 | $ pip3 install --user pymgclient
107 |
108 | If you want to dynamically link OpenSSL for better security, you can use the
109 | following command::
110 |
111 | $ pip3 install --user \
112 | --global-option=build_ext \
113 | --global-option="--static-openssl=false" \
114 | pymgclient
115 |
116 | This will download the source package of pymgclient and build the binary package
117 | before installing it. Alternatively, pymgclient can be installed by using
118 | :file:`setup.py`::
119 |
120 | $ python3 setup.py install
121 |
122 | Building on macOS
123 | *****************
124 |
125 | To install the C/C++ compiler, run::
126 |
127 | $ xcode-select --install
128 |
129 | The rest of the build prerequisites can be installed easily via `brew`_::
130 |
131 | $ brew install python3 openssl cmake
132 |
133 | It is important to mention that on M1/ARM machines pymgclient cannot be built
134 | with the default installed Python version, thus Python needs to be installed via
135 | brew. If you are interested in the technical details, you can find more details
136 | in the technical notes below.
137 |
138 | After the prerequisites are installed pymgclient can be installed via pip::
139 |
140 | $ pip3 install --user pymgclient --no-binary :all:
141 |
142 | This will download the source package of pymgclient and build the binary package
143 | before installing it. If you want to dynamically link OpenSSL for better
144 | security, you can use the following command::
145 |
146 | $ pip3 install --user \
147 | --global-option=build_ext \
148 | --global-option="--static-openssl=false" \
149 | pymgclient \
150 | --no-binary :all:
151 |
152 | Alternatively, pymgclient can be installed by using :file:`setup.py`::
153 |
154 | $ python3 setup.py install
155 |
156 | Technical note for arm64 machines
157 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
158 |
159 | The default installed Python is in the so called `Universal Binary 2
160 | `_ format. That
161 | means all of the packages that are built with this Python version have to be
162 | built also as a universal binary. Though pymgclient builds on both x86_64 and
163 | arm64 architectures, the brew installed OpenSSL version only contains the arm64
164 | binaries. As a consequence, during building the x86_64 part of the universal
165 | binary of pymgclient, the linker fails, because it cannot find the OpenSSL
166 | binaries in x86_64 binary format.
167 |
168 | Building on Windows
169 | *******************
170 |
171 | Building pymgclient on Windows is only advised for advanced users, therefore the
172 | following description assumes technical knowledge about Windows, compiling C/C++
173 | applications and Python package.
174 |
175 | To build pymgclient on Windows, the `MSYS2 `_
176 | environment is needed. Once it is installed, run "MSYS2 MSYS" from Start menu
177 | and install the necessary packages::
178 |
179 | $ pacman -Su
180 | $ pacman -S --needed base-devel mingw-w64-x86_64-toolchain \
181 | mingw-w64-x86_64-cmake mingw-w64-x86_64-openssl
182 |
183 | After installation, add the :file:`/mingw64/bin` (by default this
184 | is :file:`C:/msys64/mingw64/bin`) to the :envvar:`PATH` environment variable to
185 | make the installed applications accessible from the default Windows command
186 | prompt. Once it is done, start the Windows command prompt and make sure the
187 | applications are available, e.g. checking the version of gcc::
188 |
189 | $ gcc --version
190 |
191 | When the environment is done, start the Windows command prompt and install
192 | pymgclient can be installed via pip::
193 |
194 | $ pip install --user pymgclient --no-binary :all:
195 |
196 | If you want to dynamically link OpenSSL for better security, you can use the
197 | following command::
198 |
199 | $ pip install --user \
200 | --global-option=build_ext \
201 | --global-option="--static-openssl=false" \
202 | pymgclient \
203 | --no-binary :all:
204 |
205 | Alternatively, pymgclient can be installed by using :file:`setup.py`::
206 |
207 | $ python setup.py install
208 |
209 | ######################
210 | Running the test suite
211 | ######################
212 |
213 | If pymgclient is installed from downloaded source, you can run the test suite to
214 | verify it is working correctly. From the source directory, you can run::
215 |
216 | $ python3 -m pytest
217 |
218 | To run the tests, you will need to have Memgraph, pytest and pyopenssl installed
219 | on your machine. The tests will try to start the Memgraph binary from the
220 | standard installation path (usually :file:`/usr/lib/memgraph/memgraph`)
221 | listening on port 7687. You can configure a different path or port by setting
222 | the following environment variables:
223 |
224 | * :envvar:`MEMGRAPH_PATH`
225 | * :envvar:`MEMGRAPH_PORT`
226 |
227 | Alternatively you can also run the tests with an already running Memgraph by
228 | configuring the host and port by setting the following environment variables:
229 |
230 | * :envvar:`MEMGRAPH_HOST`
231 | * :envvar:`MEMGRAPH_PORT`
232 |
233 | When an already running Memgraph is used, then some of the tests might get
234 | skipped if Memgraph hasn't been started with a suitable configuration. The
235 | :envvar:`MEMGRAPH_STARTED_WITH_SSL` environment variable can be used to indicate
236 | whether Memgraph is started using SSL or not. If the environment variable is
237 | defined (regardless of its value), then the tests connect via secure Bolt
238 | connection, otherwise they connect with regular Bolt connection.
239 |
240 | The **tests insert data into Memgraph**, so they shouldn't be used with a
241 | Memgraph running in "production" environment.
242 |
243 | .. _mgclient: https://github.com/memgraph/mgclient
244 | .. _brew: https://brew.sh
245 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 |
2 | Apache License
3 | Version 2.0, January 2004
4 | http://www.apache.org/licenses/
5 |
6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
7 |
8 | 1. Definitions.
9 |
10 | "License" shall mean the terms and conditions for use, reproduction,
11 | and distribution as defined by Sections 1 through 9 of this document.
12 |
13 | "Licensor" shall mean the copyright owner or entity authorized by
14 | the copyright owner that is granting the License.
15 |
16 | "Legal Entity" shall mean the union of the acting entity and all
17 | other entities that control, are controlled by, or are under common
18 | control with that entity. For the purposes of this definition,
19 | "control" means (i) the power, direct or indirect, to cause the
20 | direction or management of such entity, whether by contract or
21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the
22 | outstanding shares, or (iii) beneficial ownership of such entity.
23 |
24 | "You" (or "Your") shall mean an individual or Legal Entity
25 | exercising permissions granted by this License.
26 |
27 | "Source" form shall mean the preferred form for making modifications,
28 | including but not limited to software source code, documentation
29 | source, and configuration files.
30 |
31 | "Object" form shall mean any form resulting from mechanical
32 | transformation or translation of a Source form, including but
33 | not limited to compiled object code, generated documentation,
34 | and conversions to other media types.
35 |
36 | "Work" shall mean the work of authorship, whether in Source or
37 | Object form, made available under the License, as indicated by a
38 | copyright notice that is included in or attached to the work
39 | (an example is provided in the Appendix below).
40 |
41 | "Derivative Works" shall mean any work, whether in Source or Object
42 | form, that is based on (or derived from) the Work and for which the
43 | editorial revisions, annotations, elaborations, or other modifications
44 | represent, as a whole, an original work of authorship. For the purposes
45 | of this License, Derivative Works shall not include works that remain
46 | separable from, or merely link (or bind by name) to the interfaces of,
47 | the Work and Derivative Works thereof.
48 |
49 | "Contribution" shall mean any work of authorship, including
50 | the original version of the Work and any modifications or additions
51 | to that Work or Derivative Works thereof, that is intentionally
52 | submitted to Licensor for inclusion in the Work by the copyright owner
53 | or by an individual or Legal Entity authorized to submit on behalf of
54 | the copyright owner. For the purposes of this definition, "submitted"
55 | means any form of electronic, verbal, or written communication sent
56 | to the Licensor or its representatives, including but not limited to
57 | communication on electronic mailing lists, source code control systems,
58 | and issue tracking systems that are managed by, or on behalf of, the
59 | Licensor for the purpose of discussing and improving the Work, but
60 | excluding communication that is conspicuously marked or otherwise
61 | designated in writing by the copyright owner as "Not a Contribution."
62 |
63 | "Contributor" shall mean Licensor and any individual or Legal Entity
64 | on behalf of whom a Contribution has been received by Licensor and
65 | subsequently incorporated within the Work.
66 |
67 | 2. Grant of Copyright License. Subject to the terms and conditions of
68 | this License, each Contributor hereby grants to You a perpetual,
69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
70 | copyright license to reproduce, prepare Derivative Works of,
71 | publicly display, publicly perform, sublicense, and distribute the
72 | Work and such Derivative Works in Source or Object form.
73 |
74 | 3. Grant of Patent License. Subject to the terms and conditions of
75 | this License, each Contributor hereby grants to You a perpetual,
76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
77 | (except as stated in this section) patent license to make, have made,
78 | use, offer to sell, sell, import, and otherwise transfer the Work,
79 | where such license applies only to those patent claims licensable
80 | by such Contributor that are necessarily infringed by their
81 | Contribution(s) alone or by combination of their Contribution(s)
82 | with the Work to which such Contribution(s) was submitted. If You
83 | institute patent litigation against any entity (including a
84 | cross-claim or counterclaim in a lawsuit) alleging that the Work
85 | or a Contribution incorporated within the Work constitutes direct
86 | or contributory patent infringement, then any patent licenses
87 | granted to You under this License for that Work shall terminate
88 | as of the date such litigation is filed.
89 |
90 | 4. Redistribution. You may reproduce and distribute copies of the
91 | Work or Derivative Works thereof in any medium, with or without
92 | modifications, and in Source or Object form, provided that You
93 | meet the following conditions:
94 |
95 | (a) You must give any other recipients of the Work or
96 | Derivative Works a copy of this License; and
97 |
98 | (b) You must cause any modified files to carry prominent notices
99 | stating that You changed the files; and
100 |
101 | (c) You must retain, in the Source form of any Derivative Works
102 | that You distribute, all copyright, patent, trademark, and
103 | attribution notices from the Source form of the Work,
104 | excluding those notices that do not pertain to any part of
105 | the Derivative Works; and
106 |
107 | (d) If the Work includes a "NOTICE" text file as part of its
108 | distribution, then any Derivative Works that You distribute must
109 | include a readable copy of the attribution notices contained
110 | within such NOTICE file, excluding those notices that do not
111 | pertain to any part of the Derivative Works, in at least one
112 | of the following places: within a NOTICE text file distributed
113 | as part of the Derivative Works; within the Source form or
114 | documentation, if provided along with the Derivative Works; or,
115 | within a display generated by the Derivative Works, if and
116 | wherever such third-party notices normally appear. The contents
117 | of the NOTICE file are for informational purposes only and
118 | do not modify the License. You may add Your own attribution
119 | notices within Derivative Works that You distribute, alongside
120 | or as an addendum to the NOTICE text from the Work, provided
121 | that such additional attribution notices cannot be construed
122 | as modifying the License.
123 |
124 | You may add Your own copyright statement to Your modifications and
125 | may provide additional or different license terms and conditions
126 | for use, reproduction, or distribution of Your modifications, or
127 | for any such Derivative Works as a whole, provided Your use,
128 | reproduction, and distribution of the Work otherwise complies with
129 | the conditions stated in this License.
130 |
131 | 5. Submission of Contributions. Unless You explicitly state otherwise,
132 | any Contribution intentionally submitted for inclusion in the Work
133 | by You to the Licensor shall be under the terms and conditions of
134 | this License, without any additional terms or conditions.
135 | Notwithstanding the above, nothing herein shall supersede or modify
136 | the terms of any separate license agreement you may have executed
137 | with Licensor regarding such Contributions.
138 |
139 | 6. Trademarks. This License does not grant permission to use the trade
140 | names, trademarks, service marks, or product names of the Licensor,
141 | except as required for reasonable and customary use in describing the
142 | origin of the Work and reproducing the content of the NOTICE file.
143 |
144 | 7. Disclaimer of Warranty. Unless required by applicable law or
145 | agreed to in writing, Licensor provides the Work (and each
146 | Contributor provides its Contributions) on an "AS IS" BASIS,
147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
148 | implied, including, without limitation, any warranties or conditions
149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
150 | PARTICULAR PURPOSE. You are solely responsible for determining the
151 | appropriateness of using or redistributing the Work and assume any
152 | risks associated with Your exercise of permissions under this License.
153 |
154 | 8. Limitation of Liability. In no event and under no legal theory,
155 | whether in tort (including negligence), contract, or otherwise,
156 | unless required by applicable law (such as deliberate and grossly
157 | negligent acts) or agreed to in writing, shall any Contributor be
158 | liable to You for damages, including any direct, indirect, special,
159 | incidental, or consequential damages of any character arising as a
160 | result of this License or out of the use or inability to use the
161 | Work (including but not limited to damages for loss of goodwill,
162 | work stoppage, computer failure or malfunction, or any and all
163 | other commercial damages or losses), even if such Contributor
164 | has been advised of the possibility of such damages.
165 |
166 | 9. Accepting Warranty or Additional Liability. While redistributing
167 | the Work or Derivative Works thereof, You may choose to offer,
168 | and charge a fee for, acceptance of support, warranty, indemnity,
169 | or other liability obligations and/or rights consistent with this
170 | License. However, in accepting such obligations, You may act only
171 | on Your own behalf and on Your sole responsibility, not on behalf
172 | of any other Contributor, and only if You agree to indemnify,
173 | defend, and hold each Contributor harmless for any liability
174 | incurred by, or claims asserted against, such Contributor by reason
175 | of your accepting any such warranty or additional liability.
176 |
177 | END OF TERMS AND CONDITIONS
178 |
--------------------------------------------------------------------------------
/test/test_connection.py:
--------------------------------------------------------------------------------
1 | # Copyright (c) 2016-2020 Memgraph Ltd. [https://memgraph.com]
2 | #
3 | # Licensed under the Apache License, Version 2.0 (the "License");
4 | # you may not use this file except in compliance with the License.
5 | # You may obtain a copy of the License at
6 | #
7 | # http://www.apache.org/licenses/LICENSE-2.0
8 | #
9 | # Unless required by applicable law or agreed to in writing, software
10 | # distributed under the License is distributed on an "AS IS" BASIS,
11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | # See the License for the specific language governing permissions and
13 | # limitations under the License.
14 |
15 | import mgclient
16 | import pytest
17 | import tempfile
18 |
19 | from common import start_memgraph, Memgraph, requires_ssl_enabled, requires_ssl_disabled
20 | from OpenSSL import crypto
21 |
22 |
23 | @pytest.fixture(scope="function")
24 | def memgraph_server():
25 | memgraph = start_memgraph()
26 | yield memgraph.host, memgraph.port, memgraph.sslmode(), memgraph.is_long_running()
27 |
28 | memgraph.terminate()
29 |
30 |
31 | def generate_key_and_cert(key_file, cert_file):
32 | k = crypto.PKey()
33 | k.generate_key(crypto.TYPE_RSA, 4096)
34 |
35 | cert = crypto.X509()
36 | cert.get_subject().C = "CA"
37 | cert.get_subject().O = "server"
38 | cert.get_subject().CN = "localhost"
39 | cert.set_serial_number(1)
40 | cert.gmtime_adj_notBefore(0)
41 | cert.gmtime_adj_notAfter(86400)
42 | cert.set_issuer(cert.get_subject())
43 | cert.set_pubkey(k)
44 | cert.sign(k, "sha512")
45 |
46 | cert_file.write(crypto.dump_certificate(crypto.FILETYPE_PEM, cert))
47 | cert_file.flush()
48 | key_file.write(crypto.dump_privatekey(crypto.FILETYPE_PEM, k))
49 | key_file.flush()
50 |
51 |
52 | @pytest.fixture(scope="function")
53 | def secure_memgraph_server():
54 | # we need public/private key pair to run Memgraph with SSL
55 | with tempfile.NamedTemporaryFile() as key_file, tempfile.NamedTemporaryFile() as cert_file:
56 | generate_key_and_cert(key_file.file, cert_file.file)
57 |
58 | memgraph = start_memgraph(key_file=key_file.name, cert_file=cert_file.name)
59 | assert memgraph.use_ssl
60 | assert memgraph.sslmode() == mgclient.MG_SSLMODE_REQUIRE
61 | yield memgraph.host, memgraph.port, memgraph.is_long_running()
62 |
63 | memgraph.terminate()
64 |
65 |
66 | def test_connect_args_validation():
67 | # bad port
68 | with pytest.raises(ValueError):
69 | mgclient.connect(host="127.0.0.1", port=12344567)
70 |
71 | # bad SSL mode
72 | with pytest.raises(ValueError):
73 | mgclient.connect(host="127.0.0.1", port=7687, sslmode=55)
74 |
75 | # trust_callback not callable
76 | with pytest.raises(TypeError):
77 | mgclient.connect(
78 | host="127.0.0.1",
79 | port=7687,
80 | sslmode=mgclient.MG_SSLMODE_REQUIRE,
81 | trust_callback="not callable",
82 | )
83 |
84 |
85 | @requires_ssl_disabled
86 | def test_connect_insecure_success(memgraph_server):
87 | host, port, sslmode, _ = memgraph_server
88 | assert sslmode == mgclient.MG_SSLMODE_DISABLE
89 | conn = mgclient.connect(host=host, port=port, sslmode=sslmode)
90 |
91 | assert conn.status == mgclient.CONN_STATUS_READY
92 |
93 |
94 | @requires_ssl_disabled
95 | def test_connection_secure_fail(memgraph_server):
96 | # server doesn't use SSL
97 | host, port, sslmode, _ = memgraph_server
98 | with pytest.raises(mgclient.OperationalError):
99 | mgclient.connect(host=host, port=port, sslmode=mgclient.MG_SSLMODE_REQUIRE)
100 |
101 |
102 | @requires_ssl_enabled
103 | def test_connection_secure_success(secure_memgraph_server):
104 | host, port, is_long_running = secure_memgraph_server
105 |
106 | with pytest.raises(mgclient.OperationalError):
107 | conn = mgclient.connect(host=host, port=port)
108 |
109 | def good_trust_callback(hostname, ip_address, key_type, fingerprint):
110 | if not is_long_running:
111 | assert hostname == "localhost"
112 | assert ip_address == "127.0.0.1"
113 | return True
114 |
115 | def bad_trust_callback(hostname, ip_address, key_type, fingerprint):
116 | if not is_long_running:
117 | assert hostname == "localhost"
118 | assert ip_address == "127.0.0.1"
119 | return False
120 |
121 | with pytest.raises(mgclient.OperationalError):
122 | conn = mgclient.connect(
123 | host=host,
124 | port=port,
125 | sslmode=mgclient.MG_SSLMODE_REQUIRE,
126 | trust_callback=bad_trust_callback,
127 | )
128 |
129 | conn = mgclient.connect(
130 | host=host,
131 | port=port,
132 | sslmode=mgclient.MG_SSLMODE_REQUIRE,
133 | trust_callback=good_trust_callback,
134 | )
135 |
136 | assert conn.status == mgclient.CONN_STATUS_READY
137 |
138 |
139 | def test_connection_close(memgraph_server):
140 | host, port, sslmode, _ = memgraph_server
141 | conn = mgclient.connect(host=host, port=port, sslmode=sslmode)
142 |
143 | assert conn.status == mgclient.CONN_STATUS_READY
144 |
145 | conn.close()
146 | assert conn.status == mgclient.CONN_STATUS_CLOSED
147 |
148 | # closing twice doesn't do anything
149 | conn.close()
150 | assert conn.status == mgclient.CONN_STATUS_CLOSED
151 |
152 | with pytest.raises(mgclient.InterfaceError):
153 | conn.commit()
154 |
155 | with pytest.raises(mgclient.InterfaceError):
156 | conn.rollback()
157 |
158 | with pytest.raises(mgclient.InterfaceError):
159 | conn.cursor()
160 |
161 |
162 | def test_connection_close_lazy(memgraph_server):
163 | host, port, sslmode, _ = memgraph_server
164 | conn = mgclient.connect(host=host, port=port, lazy=True, sslmode=sslmode)
165 | cursor = conn.cursor()
166 |
167 | assert conn.status == mgclient.CONN_STATUS_READY
168 |
169 | cursor.execute("RETURN 100")
170 | assert conn.status == mgclient.CONN_STATUS_EXECUTING
171 |
172 | with pytest.raises(mgclient.InterfaceError):
173 | conn.close()
174 |
175 | cursor.fetchall()
176 |
177 | conn.close()
178 | assert conn.status == mgclient.CONN_STATUS_CLOSED
179 |
180 |
181 | def test_autocommit_regular(memgraph_server):
182 | host, port, sslmode, _ = memgraph_server
183 |
184 | conn = mgclient.connect(host=host, port=port, sslmode=sslmode)
185 |
186 | # autocommit should be turned off by default
187 | assert not conn.autocommit
188 |
189 | cursor = conn.cursor()
190 | cursor.execute("RETURN 5")
191 | assert conn.status == mgclient.CONN_STATUS_IN_TRANSACTION
192 |
193 | # can't update autocommit while in transaction
194 | with pytest.raises(mgclient.InterfaceError):
195 | conn.autocommit = True
196 |
197 | conn.rollback()
198 | conn.autocommit = True
199 |
200 | assert conn.autocommit
201 |
202 | assert conn.status == mgclient.CONN_STATUS_READY
203 | cursor.execute("RETURN 5")
204 | assert conn.status == mgclient.CONN_STATUS_READY
205 |
206 | with pytest.raises(mgclient.InterfaceError):
207 | del conn.autocommit
208 |
209 |
210 | def test_autocommit_lazy(memgraph_server):
211 | host, port, sslmode, _ = memgraph_server
212 |
213 | conn = mgclient.connect(host=host, port=port, lazy=True, sslmode=sslmode)
214 |
215 | # autocommit is always true for lazy connections
216 | assert conn.autocommit
217 |
218 | with pytest.raises(mgclient.InterfaceError):
219 | conn.autocommit = False
220 |
221 |
222 | def test_commit(memgraph_server):
223 | host, port, sslmode, is_long_running = memgraph_server
224 |
225 | conn1 = mgclient.connect(host=host, port=port, sslmode=sslmode)
226 | conn2 = mgclient.connect(host=host, port=port, sslmode=sslmode)
227 | conn2.autocommit = True
228 |
229 | cursor1 = conn1.cursor()
230 | cursor1.execute("MATCH (n) RETURN count(n)")
231 | original_count = cursor1.fetchall()[0][0]
232 | assert is_long_running or original_count == 0
233 |
234 | cursor1.execute("CREATE (:Node)")
235 |
236 | cursor2 = conn2.cursor()
237 | cursor2.execute("MATCH (n) RETURN count(n)")
238 | assert cursor2.fetchall() == [(original_count,)]
239 |
240 | conn1.commit()
241 |
242 | cursor2.execute("MATCH (n) RETURN count(n)")
243 | assert cursor2.fetchall() == [(original_count + 1,)]
244 |
245 |
246 | def test_rollback(memgraph_server):
247 | host, port, sslmode, is_long_running = memgraph_server
248 |
249 | conn = mgclient.connect(host=host, port=port, sslmode=sslmode)
250 |
251 | cursor = conn.cursor()
252 |
253 | cursor.execute("MATCH (n) RETURN count(n)")
254 | original_count = cursor.fetchall()[0][0]
255 | assert is_long_running or original_count == 0
256 |
257 | cursor.execute("CREATE (:Node)")
258 | cursor.fetchall()
259 |
260 | cursor.execute("MATCH (n) RETURN count(n)")
261 | assert cursor.fetchall() == [(original_count + 1,)]
262 |
263 | conn.rollback()
264 | cursor.execute("MATCH (n) RETURN count(n)")
265 | assert cursor.fetchall() == [(original_count,)]
266 |
267 |
268 | def test_close_doesnt_commit(memgraph_server):
269 | host, port, sslmode, is_long_running = memgraph_server
270 |
271 | conn = mgclient.connect(host=host, port=port, sslmode=sslmode)
272 |
273 | cursor = conn.cursor()
274 | cursor.execute("MATCH (n) RETURN count(n)")
275 | original_count = cursor.fetchall()[0][0]
276 | assert is_long_running or original_count == 0
277 |
278 | cursor.execute("CREATE (:Node)")
279 |
280 | conn.close()
281 |
282 | conn = mgclient.connect(host=host, port=port, sslmode=sslmode)
283 | cursor = conn.cursor()
284 | cursor.execute("MATCH (n) RETURN count(n)")
285 |
286 | assert cursor.fetchall() == [(original_count,)]
287 |
288 |
289 | def test_commit_rollback_lazy(memgraph_server):
290 | host, port, sslmode, _ = memgraph_server
291 | conn = mgclient.connect(host=host, port=port, lazy=True, sslmode=sslmode)
292 | cursor = conn.cursor()
293 | cursor.execute("CREATE (:Node) RETURN 1")
294 |
295 | conn.rollback()
296 | assert conn.status == mgclient.CONN_STATUS_EXECUTING
297 |
298 | conn.commit()
299 | assert conn.status == mgclient.CONN_STATUS_EXECUTING
300 |
301 | assert cursor.fetchall() == [(1,)]
302 | assert conn.status == mgclient.CONN_STATUS_READY
303 |
304 |
305 | def test_autocommit_failure(memgraph_server):
306 | host, port, sslmode, _ = memgraph_server
307 | conn = mgclient.connect(host=host, port=port, sslmode=sslmode)
308 | conn.autocommit = False
309 |
310 | assert conn.status == mgclient.CONN_STATUS_READY
311 | cursor = conn.cursor()
312 | cursor.execute("RETURN 5")
313 | assert conn.status == mgclient.CONN_STATUS_IN_TRANSACTION
314 |
315 | with pytest.raises(mgclient.DatabaseError):
316 | cursor.execute("SHOW INDEX INFO")
317 |
318 | assert conn.status == mgclient.CONN_STATUS_READY
319 | cursor.execute("RETURN 5")
320 | assert conn.status == mgclient.CONN_STATUS_IN_TRANSACTION
321 |
--------------------------------------------------------------------------------
/src/mgclientmodule.c:
--------------------------------------------------------------------------------
1 | // Copyright (c) 2016-2020 Memgraph Ltd. [https://memgraph.com]
2 | //
3 | // Licensed under the Apache License, Version 2.0 (the "License");
4 | // you may not use this file except in compliance with the License.
5 | // You may obtain a copy of the License at
6 | //
7 | // http://www.apache.org/licenses/LICENSE-2.0
8 | //
9 | // Unless required by applicable law or agreed to in writing, software
10 | // distributed under the License is distributed on an "AS IS" BASIS,
11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | // See the License for the specific language governing permissions and
13 | // limitations under the License.
14 |
15 | #define PY_SSIZE_T_CLEAN
16 | #include
17 |
18 | #define APILEVEL "2.0"
19 | #define THREADSAFETY 1
20 | // For simplicity, here we deviate from the DB-API spec.
21 | #define PARAMSTYLE "cypher"
22 |
23 | #include
24 |
25 | #include "column.h"
26 | #include "connection.h"
27 | #include "cursor.h"
28 | #include "glue.h"
29 | #include "types.h"
30 |
31 | PyObject *Warning;
32 | PyObject *Error;
33 | PyObject *InterfaceError;
34 | PyObject *DatabaseError;
35 | PyObject *DataError;
36 | PyObject *OperationalError;
37 | PyObject *IntegrityError;
38 | PyObject *InternalError;
39 | PyObject *ProgrammingError;
40 | PyObject *NotSupportedError;
41 |
42 | PyDoc_STRVAR(Warning_doc, "Exception raised for important warnings.");
43 | PyDoc_STRVAR(Error_doc, "Base class of all other error exceptions.");
44 | PyDoc_STRVAR(
45 | InterfaceError_doc,
46 | "Exception raised for errors related to the database interface rather than "
47 | "the database itself.");
48 | PyDoc_STRVAR(DatabaseError_doc,
49 | "Exception raised for errors related to the database.");
50 | PyDoc_STRVAR(
51 | DataError_doc,
52 | "Exception raised for errors that are due to problems with the processed "
53 | "data.");
54 | PyDoc_STRVAR(
55 | OperationalError_doc,
56 | "Exception raised for errors related to the database's operation, not "
57 | "necessarily under the control of the programmer (e.g. unexpected "
58 | "disconnect, failed allocation).");
59 | PyDoc_STRVAR(
60 | IntegrityError_doc,
61 | "Exception raised when the relational integrity of the database is "
62 | "affected.");
63 | PyDoc_STRVAR(
64 | InternalError_doc,
65 | "Exception raised when the database encounters an internal error.");
66 | PyDoc_STRVAR(
67 | ProgrammingError_doc,
68 | "Exception raised for programming errors (e.g. syntax error, invalid "
69 | "parameters)");
70 | PyDoc_STRVAR(
71 | NotSupportedError_doc,
72 | "Exception raised in a case a method or database API was used which is not "
73 | "supported by the database.");
74 |
75 | int add_module_exceptions(PyObject *module) {
76 | struct {
77 | const char *name;
78 | PyObject **exc;
79 | PyObject **base;
80 | const char *docstring;
81 | } module_exceptions[] = {
82 | {"mgclient.Warning", &Warning, &PyExc_Exception, Warning_doc},
83 | {"mgclient.Error", &Error, &PyExc_Exception, Error_doc},
84 | {"mgclient.InterfaceError", &InterfaceError, &Error, InterfaceError_doc},
85 | {"mgclient.DatabaseError", &DatabaseError, &Error, DatabaseError_doc},
86 | {"mgclient.DataError", &DataError, &DatabaseError, DataError_doc},
87 | {"mgclient.OperationalError", &OperationalError, &DatabaseError,
88 | OperationalError_doc},
89 | {"mgclient.IntegrityError", &IntegrityError, &DatabaseError,
90 | IntegrityError_doc},
91 | {"mgclient.InternalError", &InternalError, &DatabaseError,
92 | InternalError_doc},
93 | {"mgclient.ProgrammingError", &ProgrammingError, &DatabaseError,
94 | ProgrammingError_doc},
95 | {"mgclient.NotSupportedError", &NotSupportedError, &DatabaseError,
96 | NotSupportedError_doc},
97 | {NULL, NULL, NULL, NULL}};
98 |
99 | for (size_t i = 0; module_exceptions[i].name; ++i) {
100 | *module_exceptions[i].exc = NULL;
101 | }
102 | for (size_t i = 0; module_exceptions[i].name; ++i) {
103 | PyObject *exc = PyErr_NewExceptionWithDoc(module_exceptions[i].name,
104 | module_exceptions[i].docstring,
105 | *module_exceptions[i].base, NULL);
106 | if (!exc) {
107 | goto cleanup;
108 | }
109 | *module_exceptions[i].exc = exc;
110 | }
111 | for (size_t i = 0; module_exceptions[i].name; ++i) {
112 | const char *name = strrchr(module_exceptions[i].name, '.');
113 | name = name ? name + 1 : module_exceptions[i].name;
114 | if (PyModule_AddObject(module, name, *module_exceptions[i].exc) < 0) {
115 | goto cleanup;
116 | }
117 | }
118 |
119 | return 0;
120 |
121 | cleanup:
122 | for (size_t i = 0; module_exceptions[i].name; ++i) {
123 | Py_XDECREF(*module_exceptions[i].exc);
124 | }
125 | return -1;
126 | }
127 |
128 | int add_module_constants(PyObject *module) {
129 | if (PyModule_AddStringConstant(module, "apilevel", APILEVEL) < 0) {
130 | return -1;
131 | }
132 | if (PyModule_AddIntConstant(module, "threadsafety", THREADSAFETY) < 0) {
133 | return -1;
134 | }
135 | if (PyModule_AddStringConstant(module, "paramstyle", PARAMSTYLE) < 0) {
136 | return -1;
137 | }
138 | if (PyModule_AddIntMacro(module, MG_SSLMODE_REQUIRE) < 0) {
139 | return -1;
140 | }
141 | if (PyModule_AddIntMacro(module, MG_SSLMODE_DISABLE) < 0) {
142 | return -1;
143 | }
144 |
145 | // Connection status constants.
146 | if (PyModule_AddIntMacro(module, CONN_STATUS_READY) < 0) {
147 | return -1;
148 | }
149 | if (PyModule_AddIntMacro(module, CONN_STATUS_BAD) < 0) {
150 | return -1;
151 | }
152 | if (PyModule_AddIntMacro(module, CONN_STATUS_CLOSED) < 0) {
153 | return -1;
154 | }
155 | if (PyModule_AddIntMacro(module, CONN_STATUS_IN_TRANSACTION) < 0) {
156 | return -1;
157 | }
158 | if (PyModule_AddIntMacro(module, CONN_STATUS_EXECUTING) < 0) {
159 | return -1;
160 | }
161 |
162 | return 0;
163 | }
164 |
165 | static struct {
166 | char *name;
167 | PyTypeObject *type;
168 | } type_table[] = {{"Connection", &ConnectionType},
169 | {"Cursor", &CursorType},
170 | {"Column", &ColumnType},
171 | {"Node", &NodeType},
172 | {"Relationship", &RelationshipType},
173 | {"Path", &PathType},
174 | {NULL, NULL}};
175 |
176 | static int add_module_types(PyObject *module) {
177 | for (size_t i = 0; type_table[i].name; ++i) {
178 | if (PyType_Ready(type_table[i].type) < 0) {
179 | return -1;
180 | }
181 | if (PyModule_AddObject(module, type_table[i].name,
182 | (PyObject *)type_table[i].type) < 0) {
183 | return -1;
184 | }
185 | }
186 | return 0;
187 | }
188 |
189 | static PyObject *mgclient_connect(PyObject *self, PyObject *args,
190 | PyObject *kwargs) {
191 | // Unused parameter.
192 | (void)self;
193 |
194 | return PyObject_Call((PyObject *)&ConnectionType, args, kwargs);
195 | }
196 |
197 | // clang-format off
198 | PyDoc_STRVAR(mgclient_connect_doc,
199 | "connect(host=None, address=None, port=None, username=None, password=None,\n\
200 | client_name=None, sslmode=mgclient.MG_SSLMODE_DISABLE,\n\
201 | sslcert=None, sslkey=None, trust_callback=None, lazy=False)\n\
202 | --\n\
203 | \n\
204 | Makes a new connection to the database server and returns a\n\
205 | :class:`Connection` object.\n\
206 | \n\
207 | Currently recognized parameters are:\n\
208 | \n\
209 | * :obj:`host`\n\
210 | \n\
211 | DNS resolvable name of host to connect to. Exactly one of host and\n\
212 | address parameters must be specified.\n\
213 | \n\
214 | * :obj:`address`\n\
215 | \n\
216 | Numeric IP address of host to connect to. This should be in the\n\
217 | standard IPv4 address format. You can also use IPv6 if your machine\n\
218 | supports it. Exactly one of host and address parameters must be\n\
219 | specified.\n\
220 | \n\
221 | * :obj:`port`\n\
222 | \n\
223 | Port number to connect to at the server host.\n\
224 | \n\
225 | * :obj:`username`\n\
226 | \n\
227 | Username to connect as.\n\
228 | \n\
229 | * :obj:`password`\n\
230 | \n\
231 | Password to be used if the server demands password authentication.\n\
232 | \n\
233 | * :obj:`client_name`\n\
234 | \n\
235 | Alternate name and version of the client to send to server. Default is\n\
236 | set by the underlying mgclient library.\n\
237 | \n\
238 | * :obj:`sslmode`\n\
239 | \n\
240 | This option determines whether a secure connection will be negotiated\n\
241 | with the server. There are 2 possible values:\n\
242 | \n\
243 | * :const:`mgclient.MG_SSLMODE_DISABLE`\n\
244 | \n\
245 | Only try a non-SSL connection (default).\n\
246 | \n\
247 | * :const:`mgclient.MG_SSLMODE_REQUIRE`\n\
248 | \n\
249 | Only try an SSL connection.\n\
250 | \n\
251 | * :obj:`sslcert`\n\
252 | \n\
253 | This parameter specifies the file name of the client SSL certificate.\n\
254 | It is ignored in case an SSL connection is not made.\n\
255 | \n\
256 | * :obj:`sslkey`\n\
257 | \n\
258 | This parameter specifies the location of the secret key used for the\n\
259 | client certificate. This parameter is ignored in case an SSL connection\n\
260 | is not made.\n\
261 | \n\
262 | * :obj:`trust_callback`\n\
263 | \n\
264 | A callable taking four arguments.\n\
265 | \n\
266 | After performing the SSL handshake, :meth:`connect` will call this\n\
267 | callable providing the hostname, IP address, public key type and\n\
268 | fingerprint. If the function returns ``False`` SSL connection will\n\
269 | immediately be terminated.\n\
270 | \n\
271 | This can be used to implement TOFU (trust on first use) mechanism.\n\
272 | \n\
273 | * :obj:`lazy`\n\
274 | \n\
275 | If this is set to ``True``, a lazy connection is made. Default is ``False``.");
276 | // clang-format on
277 |
278 | static PyMethodDef mgclient_methods[] = {
279 | {"connect", (PyCFunction)mgclient_connect, METH_VARARGS | METH_KEYWORDS,
280 | mgclient_connect_doc},
281 | {NULL, NULL, 0, NULL}};
282 |
283 | static struct PyModuleDef mgclient_module = {.m_base = PyModuleDef_HEAD_INIT,
284 | .m_name = "mgclient",
285 | .m_doc = NULL,
286 | .m_size = -1,
287 | .m_methods = mgclient_methods,
288 | .m_slots = NULL};
289 |
290 | PyMODINIT_FUNC PyInit_mgclient(void) {
291 | PyObject *m;
292 | if (!(m = PyModule_Create(&mgclient_module))) {
293 | return NULL;
294 | }
295 | if (add_module_exceptions(m) < 0) {
296 | return NULL;
297 | }
298 | if (add_module_constants(m) < 0) {
299 | return NULL;
300 | }
301 | if (add_module_types(m) < 0) {
302 | return NULL;
303 | }
304 | if (mg_init() != MG_SUCCESS) {
305 | return NULL;
306 | }
307 |
308 | py_datetime_import_init();
309 | return m;
310 | }
311 |
--------------------------------------------------------------------------------
/test/test_glue.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 |
3 | # Copyright (c) 2016-2020 Memgraph Ltd. [https://memgraph.com]
4 | #
5 | # Licensed under the Apache License, Version 2.0 (the "License");
6 | # you may not use this file except in compliance with the License.
7 | # You may obtain a copy of the License at
8 | #
9 | # http://www.apache.org/licenses/LICENSE-2.0
10 | #
11 | # Unless required by applicable law or agreed to in writing, software
12 | # distributed under the License is distributed on an "AS IS" BASIS,
13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14 | # See the License for the specific language governing permissions and
15 | # limitations under the License.
16 |
17 |
18 | import datetime
19 | import platform
20 | import sys
21 |
22 | import mgclient
23 | import pytest
24 | from zoneinfo import ZoneInfo
25 |
26 | from common import Memgraph, start_memgraph
27 |
28 | # TODO(colinbarry) The Fedora docker image seems to have flaky timezone support.
29 | # For the time being, we will force Fedora-based tests to use UTC only, and
30 | # for other OSs to test more diverse timezones.
31 | def is_fedora():
32 | """Check if running on Fedora platform"""
33 | try:
34 | with open('/etc/os-release', 'r') as f:
35 | content = f.read()
36 | return 'ID=fedora' in content
37 | except (FileNotFoundError, IOError):
38 | return False
39 |
40 |
41 | @pytest.fixture(scope="function")
42 | def memgraph_connection():
43 | memgraph = start_memgraph()
44 | conn = mgclient.connect(host=memgraph.host, port=memgraph.port, sslmode=memgraph.sslmode())
45 | conn.autocommit = True
46 | yield conn
47 |
48 | memgraph.terminate()
49 | conn.close()
50 |
51 |
52 | def test_none(memgraph_connection):
53 | conn = memgraph_connection
54 | cursor = conn.cursor()
55 | cursor.execute("RETURN $value", {"value": None})
56 | assert cursor.fetchall() == [(None,)]
57 |
58 |
59 | def test_bool(memgraph_connection):
60 | conn = memgraph_connection
61 | cursor = conn.cursor()
62 |
63 | cursor.execute("RETURN $value", {"value": True})
64 | assert cursor.fetchall() == [(True,)]
65 |
66 | cursor.execute("RETURN $value", {"value": False})
67 | assert cursor.fetchall() == [(False,)]
68 |
69 |
70 | def test_integer(memgraph_connection):
71 | conn = memgraph_connection
72 | cursor = conn.cursor()
73 |
74 | cursor.execute("RETURN $value", {"value": 42})
75 | assert cursor.fetchall() == [(42,)]
76 |
77 | cursor.execute("RETURN $value", {"value": -1})
78 | assert cursor.fetchall() == [(-1,)]
79 |
80 | cursor.execute("RETURN $value", {"value": 3289302198})
81 | assert cursor.fetchall() == [(3289302198,)]
82 |
83 | with pytest.raises(OverflowError):
84 | cursor.execute("RETURN $value", {"value": 10 ** 100})
85 |
86 |
87 | def test_float(memgraph_connection):
88 | conn = memgraph_connection
89 | cursor = conn.cursor()
90 | cursor.execute("RETURN $value", {"value": 42.0})
91 | assert cursor.fetchall() == [(42.0,)]
92 |
93 | cursor.execute("RETURN $value", {"value": 3.1415962})
94 | assert cursor.fetchall() == [(3.1415962,)]
95 |
96 |
97 | def test_string(memgraph_connection):
98 | conn = memgraph_connection
99 | cursor = conn.cursor()
100 |
101 | cursor.execute("RETURN $value", {"value": "the best test"})
102 | assert cursor.fetchall() == [("the best test",)]
103 |
104 | cursor.execute("RETURN '\u010C\u017D\u0160'")
105 | assert cursor.fetchall() == [("ČŽŠ",)]
106 |
107 | cursor.execute("RETURN $value", {"value": b"\x01\x0C\x01\x7D\x01\x60".decode("utf-16be")})
108 | assert cursor.fetchall() == [("ČŽŠ",)]
109 |
110 |
111 | def test_list(memgraph_connection):
112 | conn = memgraph_connection
113 | cursor = conn.cursor()
114 |
115 | cursor.execute("RETURN $value", {"value": [1, 2, None, True, False, "abc", []]})
116 | result = cursor.fetchall()
117 | assert result == [([1, 2, None, True, False, "abc", []],)]
118 | value_from_result = result[0][0][6]
119 | # This checks the reference number of the values in a mg_list are correct.
120 | # Ref count should be 3, because:
121 | # * one reference because the list is referenced in result
122 | # * one reference because of value_from_result
123 | # * one temporary reference because sys.getrefcount increases the ref
124 | # count
125 | assert sys.getrefcount(value_from_result) == 3
126 |
127 |
128 | def test_map(memgraph_connection):
129 | conn = memgraph_connection
130 | cursor = conn.cursor()
131 | key_in_a_map = """
132 | A long name because refs to strings are globally counted
133 | """
134 | value_in_a_map = [1, 2, 3]
135 |
136 | cursor.execute(
137 | "RETURN $value",
138 | {
139 | "value": {
140 | "x": 1,
141 | "y": 2,
142 | "map": {key_in_a_map: "value"},
143 | "list": value_in_a_map,
144 | }
145 | },
146 | )
147 |
148 | result = cursor.fetchall()
149 | assert result == [({"x": 1, "y": 2, "map": {key_in_a_map: "value"}, "list": value_in_a_map},)]
150 |
151 | value_in_a_map_from_result = result[0][0]["list"]
152 | # This checks if the reference number of the values in a mg_map are
153 | # correct.
154 | # Ref count should be 3, because:
155 | # * one reference because the list is referenced in result
156 | # * one reference because of value_in_a_map_from_result
157 | # * one temporary reference because sys.getrefcount increases the ref
158 | # count
159 | assert sys.getrefcount(value_in_a_map_from_result) == 3
160 | # This checks if the reference number of the keys in a mg_map are correct.
161 | # Ref count should be 3, because:
162 | # * one reference because the string is referenced in result
163 | # * one reference because of key_in_the_map (refs to the same strings are
164 | # globally counted)
165 | # * one temporary reference because sys.getrefcount increases the ref
166 | # count
167 | assert sys.getrefcount(key_in_a_map) == 3
168 |
169 |
170 | def test_node(memgraph_connection):
171 | conn = memgraph_connection
172 | cursor = conn.cursor()
173 | cursor.execute("CREATE (n:Label1:Label2 {prop1 : 1, prop2 : 'prop2'}) " "RETURN id(n), n")
174 | rows = cursor.fetchall()
175 | node_id, _ = rows[0]
176 | assert rows == [
177 | (
178 | node_id,
179 | mgclient.Node(node_id, set(["Label1", "Label2"]), {"prop1": 1, "prop2": "prop2"}),
180 | )
181 | ]
182 |
183 | with pytest.raises(ValueError):
184 | cursor.execute("RETURN $node", {"node": rows[0][1]})
185 |
186 |
187 | def test_relationship(memgraph_connection):
188 | conn = memgraph_connection
189 | cursor = conn.cursor()
190 | cursor.execute("CREATE (n)-[e:Type {prop1 : 1, prop2 : 'prop2'}]->(m) " "RETURN id(n), id(m), id(e), e")
191 | rows = cursor.fetchall()
192 | start_id, end_id, edge_id, _ = rows[0]
193 | assert rows == [
194 | (
195 | start_id,
196 | end_id,
197 | edge_id,
198 | mgclient.Relationship(edge_id, start_id, end_id, "Type", {"prop1": 1, "prop2": "prop2"}),
199 | )
200 | ]
201 |
202 | with pytest.raises(ValueError):
203 | cursor.execute("RETURN $rel", {"rel": rows[0][3]})
204 |
205 |
206 | def test_path(memgraph_connection):
207 | conn = memgraph_connection
208 | cursor = conn.cursor()
209 | cursor.execute(
210 | "CREATE p = (n1:Node1)<-[e1:Edge1]-(n2:Node2)-[e2:Edge2]->(n3:Node3) "
211 | "RETURN id(n1), id(n2), id(n3), id(e1), id(e2), p"
212 | )
213 | rows = cursor.fetchall()
214 | n1_id, n2_id, n3_id, e1_id, e2_id, _ = rows[0]
215 | n1 = mgclient.Node(n1_id, set(["Node1"]), {})
216 | n2 = mgclient.Node(n2_id, set(["Node2"]), {})
217 | n3 = mgclient.Node(n3_id, set(["Node3"]), {})
218 | e1 = mgclient.Relationship(e1_id, n2_id, n1_id, "Edge1", {})
219 | e2 = mgclient.Relationship(e2_id, n2_id, n3_id, "Edge2", {})
220 |
221 | assert rows == [(n1_id, n2_id, n3_id, e1_id, e2_id, mgclient.Path([n1, n2, n3], [e1, e2]))]
222 |
223 | with pytest.raises(ValueError):
224 | cursor.execute("RETURN $path", {"path": rows[0][5]})
225 |
226 |
227 | def test_tuple(memgraph_connection):
228 | conn = memgraph_connection
229 | cursor = conn.cursor()
230 |
231 | cursor.execute("RETURN $value1, $value2", {"value1": [], "value2": []})
232 | result = cursor.fetchall()
233 | assert result == [([], [])]
234 | for i in [0, 1]:
235 | value_in_tuple = result[0][i]
236 | # This checks the reference number of the values in a tuple created
237 | # from an mg_list are correct.
238 | # Ref count should be 3, because:
239 | # * one reference because the list is referenced in result
240 | # * one reference because of value_in_tuple
241 | # * one temporary reference because sys.getrefcount increases the ref
242 | # count
243 | assert sys.getrefcount(value_in_tuple) == 3
244 |
245 |
246 | @pytest.mark.temporal
247 | def test_time(memgraph_connection):
248 | conn = memgraph_connection
249 | cursor = conn.cursor()
250 | cursor.execute("RETURN $value", {"value": datetime.time(1, 2, 3, 40)})
251 | result = cursor.fetchall()
252 | assert result == [(datetime.time(1, 2, 3, 40),)]
253 |
254 |
255 | @pytest.mark.temporal
256 | def test_date(memgraph_connection):
257 | conn = memgraph_connection
258 | cursor = conn.cursor()
259 | cursor.execute("RETURN $value", {"value": datetime.date(1994, 7, 12)})
260 | result = cursor.fetchall()
261 | assert result == [(datetime.date(1994, 7, 12),)]
262 |
263 |
264 | @pytest.mark.temporal
265 | def test_datetime(memgraph_connection):
266 | conn = memgraph_connection
267 | cursor = conn.cursor()
268 | cursor.execute("RETURN $value", {"value": datetime.datetime(2004, 7, 11, 12, 13, 14, 15)})
269 | result = cursor.fetchall()
270 | assert result == [(datetime.datetime(2004, 7, 11, 12, 13, 14, 15),)]
271 |
272 |
273 | @pytest.mark.temporal
274 | def test_datetime_with_offset_timezone(memgraph_connection):
275 | conn = memgraph_connection
276 | cursor = conn.cursor()
277 | cursor.execute("RETURN $value", {"value": datetime.datetime(2004, 7, 11, 12, 13, 14, 15, tzinfo=datetime.timezone(datetime.timedelta(hours=3)))})
278 | result = cursor.fetchall()
279 | assert result == [(datetime.datetime(2004, 7, 11, 12, 13, 14, 15, tzinfo=datetime.timezone(datetime.timedelta(hours=3))),)]
280 |
281 | @pytest.mark.temporal
282 | def test_datetime_with_named_timezone(memgraph_connection):
283 | conn = memgraph_connection
284 | cursor = conn.cursor()
285 |
286 | # See comment at top of file about Fedora's timezone support.
287 | if is_fedora():
288 | tz_name = "UTC"
289 | expected_tz_names = ["UTC", "Etc/UTC"]
290 | else:
291 | tz_name = "Pacific/Pitcairn"
292 | expected_tz_names = ["Pacific/Pitcairn"]
293 |
294 | test_dt = datetime.datetime(2024, 8, 12, 10, 15, 42, 123, tzinfo=ZoneInfo(tz_name))
295 | cursor.execute("RETURN $value", {"value": test_dt})
296 | result = cursor.fetchall()
297 | assert len(result) == 1
298 | dt = result[0][0]
299 | assert isinstance(dt, datetime.datetime)
300 | assert dt.year == 2024
301 | assert dt.month == 8
302 | assert dt.day == 12
303 | assert dt.hour == 10
304 | assert dt.minute == 15
305 | assert dt.second == 42
306 | assert dt.microsecond == 123
307 | assert str(dt.tzinfo) in expected_tz_names
308 |
309 |
310 | @pytest.mark.temporal
311 | def test_duration(memgraph_connection):
312 | conn = memgraph_connection
313 | cursor = conn.cursor()
314 | cursor.execute("RETURN $value", {"value": datetime.timedelta(64, 7, 11, 1)})
315 | result = cursor.fetchall()
316 | assert result == [(datetime.timedelta(64, 7, 1011),)]
317 |
318 | @pytest.mark.temporal
319 | def test_zoneddatetime(memgraph_connection):
320 | conn = memgraph_connection
321 | cursor = conn.cursor()
322 | cursor.execute("RETURN $value", {"value": datetime.datetime(2025, 8, 12, 10, 15, 42, 123, tzinfo=datetime.timezone(datetime.timedelta(hours=7, minutes=30)))})
323 | result = cursor.fetchall()
324 | assert result == [(datetime.datetime(2025, 8, 12, 10, 15, 42, 123, tzinfo=datetime.timezone(datetime.timedelta(hours=7, minutes=30))),)]
325 |
326 |
327 | @pytest.mark.temporal
328 | def test_zoneddatetime_with_iana_timezone(memgraph_connection):
329 | conn = memgraph_connection
330 | cursor = conn.cursor()
331 |
332 | # See comment at top of file about Fedora's timezone support.
333 | if is_fedora():
334 | tz_name = "UTC"
335 | expected_tz_names = ["UTC", "Etc/UTC"]
336 | else:
337 | tz_name = "America/New_York"
338 | expected_tz_names = ["America/New_York"]
339 |
340 | cursor.execute(f"RETURN datetime({{year: 2025, month: 8, day: 13, hour: 14, minute: 30, second: 45, timezone: '{tz_name}'}})")
341 | result = cursor.fetchall()
342 |
343 | assert len(result) == 1
344 |
345 | dt = result[0][0]
346 | assert isinstance(dt, datetime.datetime)
347 | assert dt.year == 2025
348 | assert dt.month == 8
349 | assert dt.day == 13
350 | assert dt.hour == 14
351 | assert dt.minute == 30
352 | assert dt.second == 45
353 | assert str(dt.tzinfo) in expected_tz_names
354 |
355 |
--------------------------------------------------------------------------------
/setup.py:
--------------------------------------------------------------------------------
1 | # Copyright (c) 2016-2020 Memgraph Ltd. [https://memgraph.com]
2 | #
3 | # Licensed under the Apache License, Version 2.0 (the "License");
4 | # you may not use this file except in compliance with the License.
5 | # You may obtain a copy of the License at
6 | #
7 | # http://www.apache.org/licenses/LICENSE-2.0
8 | #
9 | # Unless required by applicable law or agreed to in writing, software
10 | # distributed under the License is distributed on an "AS IS" BASIS,
11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | # See the License for the specific language governing permissions and
13 | # limitations under the License.
14 |
15 | import os
16 | import platform
17 | import shutil
18 | import sys
19 | import configparser
20 | from distutils import log
21 | from distutils.errors import DistutilsExecError, DistutilsPlatformError
22 | from pathlib import Path
23 | from typing import List
24 |
25 | from setuptools import Extension, setup
26 | from setuptools.command.build_ext import build_ext
27 |
28 | IS_WINDOWS = sys.platform == "win32"
29 | IS_APPLE = sys.platform == "darwin"
30 | IS_X64 = platform.architecture()[0] == "64bit"
31 |
32 | if IS_WINDOWS:
33 | # https://stackoverflow.com/a/57109148/6639989
34 | import distutils.cygwinccompiler
35 |
36 | distutils.cygwinccompiler.get_msvcr = lambda: []
37 |
38 | with open("README.md", "r") as fh:
39 | readme = fh.read()
40 | long_description = "\n".join(readme.split("\n")[2:]).lstrip()
41 |
42 | # Throughout this file "mgclient" can mean two different things:
43 | # 1. The mgclient library which is the official Memgraph client library.
44 | # 2. The mgclient python extension module which is a wrapper around the
45 | # client library.
46 | EXTENSION_NAME = "mgclient"
47 |
48 | sources = [str(path) for path in Path("src").glob("*.c")]
49 |
50 | headers = [str(path) for path in Path("src").glob("*.h")]
51 |
52 | parser = configparser.ConfigParser()
53 | parser.read("setup.cfg")
54 |
55 | static_openssl = parser.getboolean("build_ext", "static_openssl", fallback=False)
56 |
57 | version = os.getenv("PYMGCLIENT_OVERRIDE_VERSION", "1.5.1")
58 |
59 |
60 | def list_all_files_in_dir(path):
61 | result = []
62 | for root, _dirs, files in os.walk(path):
63 | result.extend(os.path.join(root, f) for f in files)
64 |
65 | return result
66 |
67 |
68 | class BuildMgclientExt(build_ext):
69 | """
70 | Builds using cmake instead of the python setuptools implicit build
71 | """
72 |
73 | user_options = build_ext.user_options[:]
74 | user_options.append(("static-openssl=", None, "Compile with statically linked OpenSSL."))
75 |
76 | boolean_options = build_ext.boolean_options[:]
77 | boolean_options.append(("static-openssl"))
78 |
79 | def initialize_options(self):
80 | build_ext.initialize_options(self)
81 | self.static_openssl = static_openssl
82 |
83 | def run(self):
84 | """
85 | Perform build_cmake before doing the 'normal' stuff
86 | """
87 | self.announce(f"Using static OpenSSL: {bool(self.static_openssl)}", level=log.INFO)
88 |
89 | for extension in self.extensions:
90 | if extension.name == EXTENSION_NAME:
91 | self.build_mgclient_for(extension)
92 |
93 | if IS_WINDOWS:
94 | if self.compiler is None:
95 | self.compiler = "mingw32"
96 | elif self.compiler != "mingw32":
97 | raise DistutilsPlatformError(
98 | f"The specified compiler {self.compiler} is not supported on windows, only mingw32 is supported."
99 | )
100 |
101 | super().run()
102 |
103 | def get_cmake_binary(self):
104 | cmake_env_var_name = "PYMGCLIENT_CMAKE"
105 | custom_cmake = os.getenv(cmake_env_var_name)
106 | if custom_cmake is None:
107 | # cmake3 is checked before cmake, because on CentOS cmake refers
108 | # to CMake 2.*
109 | for possible_cmake in ["cmake3", "cmake"]:
110 | self.announce(f"Checking if {possible_cmake} can be used", level=log.INFO)
111 |
112 | which_cmake = shutil.which(possible_cmake)
113 | if which_cmake is not None:
114 | self.announce(f"Using {which_cmake}", level=log.INFO)
115 | return os.path.abspath(which_cmake)
116 |
117 | self.announce(f"{possible_cmake} is not accesible", level=log.INFO)
118 | raise DistutilsExecError("Cannot found suitable cmake")
119 | else:
120 | self.announce(
121 | f"Using the value of {cmake_env_var_name} for CMake, which is" f"{custom_cmake}", level=log.INFO
122 | )
123 | return custom_cmake
124 |
125 | def get_openssl_root_dir(self):
126 | if not IS_APPLE:
127 | return None
128 |
129 | openssl_root_dir_env_var = "OPENSSL_ROOT_DIR"
130 | openssl_root_dir = os.getenv(openssl_root_dir_env_var)
131 |
132 | if openssl_root_dir:
133 | self.announce(
134 | f"Using the value of {openssl_root_dir_env_var} for OpenSSL," f"which is {openssl_root_dir}",
135 | level=log.INFO,
136 | )
137 | return openssl_root_dir
138 |
139 | # The order is the following:
140 | # 1. OpenSSL 3 on Apple Silicon
141 | # 2. OpenSSL 1 on Apple Silicon
142 | # 3. OpenSSL 3 on Intel
143 | # 4. OpenSSL 1 on Intel
144 | # Prefer Apple Silicon over Intel and prefer OpenSSL 3 over 1.
145 | possible_openssl_root_dirs = [
146 | "/opt/homebrew/opt/openssl@3",
147 | "/opt/homebrew/opt/openssl@1.1",
148 | "/usr/local/opt/openssl@3",
149 | "/usr/local/opt/openssl@1.1",
150 | ]
151 |
152 | for dir in possible_openssl_root_dirs:
153 | if os.path.isdir(dir):
154 | return dir
155 |
156 | return None
157 |
158 | def finalize_cmake_config_command_darwin(self, cmake_config_command: List[str]):
159 | openssl_root_dir = self.get_openssl_root_dir()
160 | if openssl_root_dir is not None:
161 | cmake_config_command.append(f"-DOPENSSL_ROOT_DIR={openssl_root_dir}")
162 | # otherwise trust CMake to find OpenSSL
163 |
164 | def finalize_cmake_config_command_win32(self, cmake_config_command: List[str]):
165 | cmake_config_command.append("-GMinGW Makefiles")
166 |
167 | def get_extra_link_args(self, libs: List[str]):
168 | # https://stackoverflow.com/a/45335363/6639989
169 | return [f"-l:lib{name}.a" for name in libs]
170 |
171 | def finalize_darwin(self, extension: Extension):
172 | libs = ["ssl", "crypto"]
173 | openssl_root_dir = self.get_openssl_root_dir()
174 | if self.static_openssl:
175 | extension.extra_link_args.extend([f"{openssl_root_dir}/lib/lib{lib}.a" for lib in libs])
176 | else:
177 | extension.extra_link_args.extend([f"{openssl_root_dir}/lib/lib{lib}.dylib" for lib in libs])
178 |
179 | def finalize_linux_like(self, extension: Extension, libs: List[str]):
180 | if self.static_openssl:
181 | extension.extra_link_args.extend(self.get_extra_link_args(libs))
182 | else:
183 | extension.libraries.extend(libs)
184 |
185 | def finalize_win32(self, extension: Extension):
186 | self.finalize_linux_like(extension, ["ssl", "crypto", "ws2_32"])
187 |
188 | def finalize_linux(self, extension: Extension):
189 | self.finalize_linux_like(extension, ["ssl", "crypto"])
190 |
191 | def get_cflags(self):
192 | return "{0} -Werror=all".format(os.getenv("CFLAGS", "")).strip()
193 |
194 | def build_mgclient_for(self, extension: Extension):
195 | """
196 | Builds mgclient library and configures the extension to be able to use
197 | the mgclient library as a static library.
198 |
199 | In this function all usage of mgclient refers to the client library
200 | and not the python extension module.
201 | """
202 | cmake_binary = self.get_cmake_binary()
203 |
204 | self.announce("Preparing the build environment for mgclient", level=log.INFO)
205 |
206 | extension_build_dir = Path(self.build_temp).absolute()
207 | mgclient_build_path = os.path.join(extension_build_dir, "mgclient_build")
208 | mgclient_install_path = os.path.join(extension_build_dir, "mgclient_install")
209 |
210 | self.announce(f"Using {mgclient_build_path} as build directory for mgclient", level=log.INFO)
211 | self.announce(f"Using {mgclient_install_path} as install directory for mgclient", level=log.INFO)
212 |
213 | os.makedirs(mgclient_build_path, exist_ok=True)
214 | mgclient_source_path = os.path.join(Path(__file__).absolute().parent, "mgclient")
215 |
216 | # CMake <3.13 versions don't support explicit build directory
217 | prev_working_dir = os.getcwd()
218 | os.chdir(mgclient_build_path)
219 |
220 | self.announce("Configuring mgclient", level=log.INFO)
221 |
222 | build_type = "Debug" if self.debug else "Release"
223 | install_libdir = "lib"
224 | install_includedir = "include"
225 | cmake_config_command = [
226 | cmake_binary,
227 | mgclient_source_path,
228 | f"-DCMAKE_INSTALL_LIBDIR={install_libdir}",
229 | f"-DCMAKE_INSTALL_INCLUDEDIR={install_includedir}",
230 | f"-DCMAKE_BUILD_TYPE={build_type}",
231 | f"-DCMAKE_INSTALL_PREFIX={mgclient_install_path}",
232 | "-DBUILD_TESTING=OFF",
233 | "-DCMAKE_POSITION_INDEPENDENT_CODE=ON",
234 | f'-DCMAKE_C_FLAGS="{self.get_cflags()}"',
235 | f"-DOPENSSL_USE_STATIC_LIBS={'ON' if self.static_openssl else 'OFF'}",
236 | "-DPKG_CONFIG_USE_STATIC_LIBS=ON"
237 | ]
238 |
239 | finalize_cmake_config_command = getattr(self, "finalize_cmake_config_command_" + sys.platform, None)
240 | if finalize_cmake_config_command is not None:
241 | finalize_cmake_config_command(cmake_config_command)
242 |
243 | try:
244 | self.spawn(cmake_config_command)
245 | except DistutilsExecError as dee:
246 | self.announce("Error happened during configuring mgclient! Is OpenSSL installed correctly?")
247 | raise dee
248 |
249 | self.announce("Building mgclient binaries", level=log.INFO)
250 |
251 | try:
252 | self.spawn([cmake_binary, "--build", mgclient_build_path, "--config", build_type, "--target", "install"])
253 | except DistutilsExecError as dee:
254 | self.announce("Error happened during building mgclient binaries!", level=log.FATAL)
255 | raise dee
256 |
257 | os.chdir(prev_working_dir)
258 |
259 | mgclient_sources = [os.path.join(mgclient_source_path, "CMakeLists.txt")]
260 |
261 | for subdir in ["src", "include", "cmake"]:
262 | mgclient_sources.extend(list_all_files_in_dir(os.path.join(mgclient_source_path, subdir)))
263 |
264 | extension.include_dirs.append(os.path.join(mgclient_install_path, install_includedir))
265 | extension.extra_objects.append(os.path.join(mgclient_install_path, install_libdir, "libmgclient.a"))
266 | extension.depends.extend(mgclient_sources)
267 | extension.define_macros.append(("MGCLIENT_STATIC_DEFINE", ""))
268 |
269 | finalize = getattr(self, "finalize_" + sys.platform, None)
270 | if finalize is not None:
271 | finalize(extension)
272 |
273 | if sys.platform == "win32":
274 | extra_link_args = [
275 | "-l:libssl.a",
276 | "-l:libcrypto.a",
277 | "-lcrypt32",
278 | "-lws2_32"
279 | ]
280 | else:
281 | extra_link_args = None
282 |
283 | setup(
284 | name="pymgclient",
285 | version=version,
286 | maintainer="Matt James",
287 | maintainer_email="matthew.james@memgraph.io",
288 | author="Colin Barry",
289 | author_email="colin.barry@memgraph.io",
290 | license="Apache2",
291 | python_requires=">=3.9",
292 | description="Memgraph database adapter for Python language",
293 | long_description=long_description,
294 | long_description_content_type="text/markdown",
295 | url="https://github.com/memgraph/pymgclient",
296 | classifiers=[
297 | "Development Status :: 3 - Alpha",
298 | "Intended Audience :: Developers",
299 | "License :: OSI Approved :: Apache Software License",
300 | "Programming Language :: Python :: 3.9",
301 | "Programming Language :: Python :: 3.10",
302 | "Programming Language :: Python :: 3.11",
303 | "Programming Language :: Python :: 3.12",
304 | "Programming Language :: Python :: 3.13",
305 | "Programming Language :: Python :: Implementation :: CPython",
306 | "Topic :: Database",
307 | "Topic :: Database :: Front-Ends",
308 | "Topic :: Software Development",
309 | "Topic :: Software Development :: Libraries :: Python Modules",
310 | "Operating System :: POSIX :: Linux",
311 | "Operating System :: MacOS :: MacOS X",
312 | "Operating System :: Microsoft :: Windows",
313 | ],
314 | ext_modules=[
315 | Extension(EXTENSION_NAME, sources=sources, depends=headers, extra_link_args=extra_link_args)
316 | ],
317 | project_urls={
318 | "Source": "https://github.com/memgraph/pymgclient",
319 | "Documentation": "https://memgraph.github.io/pymgclient",
320 | },
321 | cmdclass={"build_ext": BuildMgclientExt},
322 | install_requires=[
323 | "pyopenssl",
324 | "networkx",
325 | "tzdata"
326 | ]
327 | )
328 |
--------------------------------------------------------------------------------
/src/connection.c:
--------------------------------------------------------------------------------
1 | // Copyright (c) 2016-2020 Memgraph Ltd. [https://memgraph.com]
2 | //
3 | // Licensed under the Apache License, Version 2.0 (the "License");
4 | // you may not use this file except in compliance with the License.
5 | // You may obtain a copy of the License at
6 | //
7 | // http://www.apache.org/licenses/LICENSE-2.0
8 | //
9 | // Unless required by applicable law or agreed to in writing, software
10 | // distributed under the License is distributed on an "AS IS" BASIS,
11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | // See the License for the specific language governing permissions and
13 | // limitations under the License.
14 |
15 | #include "connection.h"
16 |
17 | #include
18 |
19 | #include "cursor.h"
20 | #include "exceptions.h"
21 |
22 | static void connection_dealloc(ConnectionObject *conn) {
23 | mg_session_destroy(conn->session);
24 | Py_TYPE(conn)->tp_free(conn);
25 | }
26 |
27 | static int execute_trust_callback(const char *hostname, const char *ip_address,
28 | const char *key_type, const char *fingerprint,
29 | PyObject *pycallback) {
30 | PyObject *result = PyObject_CallFunction(pycallback, "ssss", hostname,
31 | ip_address, key_type, fingerprint);
32 | if (!result) {
33 | return -1;
34 | }
35 | int status = PyObject_IsTrue(result);
36 | Py_DECREF(result);
37 | return !status;
38 | }
39 |
40 | static int connection_init(ConnectionObject *conn, PyObject *args,
41 | PyObject *kwargs) {
42 | static char *kwlist[] = {"host", "address", "port", "username",
43 | "password", "client_name", "sslmode", "sslcert",
44 | "sslkey", "trust_callback", "lazy", NULL};
45 |
46 | const char *host = NULL;
47 | const char *address = NULL;
48 | int port = -1;
49 | const char *username = NULL;
50 | const char *password = NULL;
51 | const char *client_name = NULL;
52 | int sslmode_int = MG_SSLMODE_DISABLE;
53 | const char *sslcert = NULL;
54 | const char *sslkey = NULL;
55 | PyObject *trust_callback = NULL;
56 | int lazy = 0;
57 |
58 | if (!PyArg_ParseTupleAndKeywords(args, kwargs, "|$ssisssissOp", kwlist, &host,
59 | &address, &port, &username, &password,
60 | &client_name, &sslmode_int, &sslcert,
61 | &sslkey, &trust_callback, &lazy)) {
62 | return -1;
63 | }
64 |
65 | if (port < 0 || port > 65535) {
66 | PyErr_SetString(PyExc_ValueError, "port out of range");
67 | return -1;
68 | }
69 |
70 | enum mg_sslmode sslmode;
71 | switch (sslmode_int) {
72 | case MG_SSLMODE_DISABLE:
73 | case MG_SSLMODE_REQUIRE:
74 | sslmode = sslmode_int;
75 | break;
76 | default:
77 | PyErr_SetString(PyExc_ValueError, "invalid sslmode");
78 | return -1;
79 | }
80 |
81 | if (trust_callback && !PyCallable_Check(trust_callback)) {
82 | PyErr_SetString(PyExc_TypeError,
83 | "trust_callback argument must be callable");
84 | return -1;
85 | }
86 |
87 | mg_session_params *params = mg_session_params_make();
88 | if (!params) {
89 | PyErr_SetString(PyExc_RuntimeError,
90 | "couldn't allocate session parameters object");
91 | return -1;
92 | }
93 | mg_session_params_set_host(params, host);
94 | mg_session_params_set_port(params, (uint16_t)port);
95 | mg_session_params_set_address(params, address);
96 | mg_session_params_set_username(params, username);
97 | mg_session_params_set_password(params, password);
98 | if (client_name) {
99 | mg_session_params_set_user_agent(params, client_name);
100 | }
101 | mg_session_params_set_sslmode(params, sslmode);
102 | mg_session_params_set_sslcert(params, sslcert);
103 | mg_session_params_set_sslkey(params, sslkey);
104 | if (trust_callback) {
105 | mg_session_params_set_trust_callback(
106 | params, (mg_trust_callback_type)execute_trust_callback);
107 | mg_session_params_set_trust_data(params, (void *)trust_callback);
108 | }
109 |
110 | mg_session *session;
111 | {
112 | int status = mg_connect(params, &session);
113 | mg_session_params_destroy(params);
114 | if (status != 0) {
115 | // TODO(mtomic): maybe convert MG_ERROR_* codes to different kinds of
116 | // Python exceptions
117 | PyErr_SetString(OperationalError, mg_session_error(session));
118 | mg_session_destroy(session);
119 | return -1;
120 | }
121 | }
122 |
123 | conn->session = session;
124 | conn->status = CONN_STATUS_READY;
125 | conn->lazy = 0;
126 | conn->autocommit = 0;
127 |
128 | if (lazy) {
129 | conn->lazy = 1;
130 | conn->autocommit = 1;
131 | }
132 |
133 | return 0;
134 | }
135 |
136 | static PyObject *connection_new(PyTypeObject *subtype, PyObject *args,
137 | PyObject *kwargs) {
138 | // Unused args.
139 | (void)args;
140 | (void)kwargs;
141 |
142 | PyObject *conn = subtype->tp_alloc(subtype, 0);
143 | if (!conn) {
144 | return NULL;
145 | }
146 | ((ConnectionObject *)conn)->status = CONN_STATUS_BAD;
147 | return conn;
148 | }
149 |
150 | // clang-format off
151 | PyDoc_STRVAR(connection_close_doc,
152 | "close()\n\
153 | --\n\
154 | \n\
155 | Close the connection now.\n\
156 | \n\
157 | The connection will be unusable from this point forward; an :exc:`InterfaceError`\n\
158 | will be raised if any operation is attempted with the connection. The same applies\n\
159 | to all :class:`.Cursor` objects trying to use the connection.\n\
160 | \n\
161 | Note that closing a connection without committing the changes will cause an implicit\n\
162 | rollback to be performed.");
163 | // clang-format on
164 |
165 | static PyObject *connection_close(ConnectionObject *conn, PyObject *args) {
166 | // Unused args.
167 | (void)args;
168 |
169 | assert(!args);
170 |
171 | if (conn->status == CONN_STATUS_EXECUTING) {
172 | // This can only happen if connection is in lazy execution mode.
173 | assert(conn->lazy);
174 | PyErr_SetString(InterfaceError,
175 | "cannot close connection during execution of a query");
176 | return NULL;
177 | }
178 |
179 | // No need to rollback, closing the connection will automatically
180 | // rollback any open transactions.
181 | mg_session_destroy(conn->session);
182 | conn->session = NULL;
183 | conn->status = CONN_STATUS_CLOSED;
184 |
185 | Py_RETURN_NONE;
186 | }
187 |
188 | // clang-format off
189 | PyDoc_STRVAR(connection_commit_doc,
190 | "commit()\n\
191 | --\n\
192 | \n\
193 | Commit any pending transaction to the database.\n\
194 | \n\
195 | If auto-commit is turned on, this method does nothing.");
196 | // clang-format on
197 |
198 | static PyObject *connection_commit(ConnectionObject *conn, PyObject *args) {
199 | // Unused args.
200 | (void)args;
201 |
202 | assert(!args);
203 |
204 | if (connection_raise_if_bad_status(conn) < 0) {
205 | return NULL;
206 | }
207 |
208 | if (conn->status == CONN_STATUS_EXECUTING) {
209 | // This can only happen if connection is in lazy execution mode. In
210 | // that case, autocommit must be enabled and this method does nothing.
211 | assert(conn->lazy && conn->autocommit);
212 | Py_RETURN_NONE;
213 | }
214 |
215 | if (conn->autocommit || conn->status == CONN_STATUS_READY) {
216 | Py_RETURN_NONE;
217 | }
218 |
219 | assert(conn->status == CONN_STATUS_IN_TRANSACTION);
220 |
221 | // send COMMIT command and expect no results
222 | if (connection_run_without_results(conn, "COMMIT") < 0) {
223 | return NULL;
224 | }
225 |
226 | conn->status = CONN_STATUS_READY;
227 | Py_RETURN_NONE;
228 | }
229 |
230 | // clang-format off
231 | PyDoc_STRVAR(connection_rollback_doc,
232 | "rollback()\n\
233 | --\n\
234 | \n\
235 | Roll back to the start of any pending transaction.\n\
236 | \n\
237 | If auto-commit is turned on, this method does nothing.");
238 | // clang-format on
239 |
240 | static PyObject *connection_rollback(ConnectionObject *conn, PyObject *args) {
241 | // Unused args.
242 | (void)args;
243 |
244 | assert(!args);
245 | if (connection_raise_if_bad_status(conn) < 0) {
246 | return NULL;
247 | }
248 |
249 | if (conn->status == CONN_STATUS_EXECUTING) {
250 | // This can only happen if connection is in lazy execution mode. In
251 | // that case, autocommit must be enabled and this method does nothing.
252 | assert(conn->lazy && conn->autocommit);
253 | Py_RETURN_NONE;
254 | }
255 |
256 | if (conn->autocommit || conn->status == CONN_STATUS_READY) {
257 | Py_RETURN_NONE;
258 | }
259 |
260 | assert(conn->status == CONN_STATUS_IN_TRANSACTION);
261 |
262 | // send ROLLBACK command and expect no results
263 | if (connection_run_without_results(conn, "ROLLBACK") < 0) {
264 | return NULL;
265 | }
266 |
267 | conn->status = CONN_STATUS_READY;
268 | Py_RETURN_NONE;
269 | }
270 |
271 | // clang-format off
272 | PyDoc_STRVAR(connection_cursor_doc,
273 | "cursor()\n\
274 | --\n\
275 | \n\
276 | Return a new :class:`Cursor` object using the connection.");
277 | // clang-format on
278 |
279 | static PyObject *connection_cursor(ConnectionObject *conn, PyObject *args) {
280 | // Unused args.
281 | (void)args;
282 |
283 | assert(!args);
284 |
285 | if (connection_raise_if_bad_status(conn) < 0) {
286 | return NULL;
287 | }
288 |
289 | return PyObject_CallFunctionObjArgs((PyObject *)&CursorType, conn, NULL);
290 | }
291 |
292 | // clang-format off
293 | PyDoc_STRVAR(
294 | ConnectionType_autocommit_doc,
295 | "This read/write attribute specifies whether executed statements\n\
296 | have immediate effect in the database.\n\
297 | \n\
298 | If ``True``, every executed statement has immediate effect.\n\
299 | \n\
300 | If ``False``, a new transaction is started at the execution of the first\n\
301 | command. Transactions must be manually terminated using :meth:`commit` or\n\
302 | :meth:`rollback` methods.");
303 | // clang-format on
304 |
305 | PyObject *connection_autocommit_get(ConnectionObject *conn, void *data) {
306 | (void)data;
307 | if (conn->autocommit) {
308 | Py_RETURN_TRUE;
309 | } else {
310 | Py_RETURN_FALSE;
311 | }
312 | }
313 |
314 | int connection_autocommit_set(ConnectionObject *conn, PyObject *value,
315 | void *data) {
316 | (void)data;
317 | if (!value) {
318 | PyErr_SetString(InterfaceError, "cannot delete autocommit property");
319 | return -1;
320 | }
321 | if (conn->lazy) {
322 | PyErr_SetString(InterfaceError,
323 | "autocommit is always enabled in lazy mode");
324 | return -1;
325 | }
326 | if (conn->status == CONN_STATUS_EXECUTING ||
327 | conn->status == CONN_STATUS_IN_TRANSACTION) {
328 | PyErr_SetString(InterfaceError,
329 | "cannot change autocommit property while in a transaction");
330 | return -1;
331 | }
332 | int tf = PyObject_IsTrue(value);
333 | if (tf < 0) {
334 | return -1;
335 | }
336 | conn->autocommit = tf ? 1 : 0;
337 |
338 | return 0;
339 | }
340 |
341 | static PyMethodDef connection_methods[] = {
342 | {"close", (PyCFunction)connection_close, METH_NOARGS, connection_close_doc},
343 | {"commit", (PyCFunction)connection_commit, METH_NOARGS,
344 | connection_commit_doc},
345 | {"rollback", (PyCFunction)connection_rollback, METH_NOARGS,
346 | connection_rollback_doc},
347 | {"cursor", (PyCFunction)connection_cursor, METH_NOARGS,
348 | connection_cursor_doc},
349 | {NULL, NULL, 0, NULL}};
350 |
351 | // clang-format off
352 | PyDoc_STRVAR(ConnectionType_status_doc,
353 | "Status of the connection.\n\
354 | \n\
355 | It's value can be one of the following macros:\n\
356 | * :data:`mgclient.CONN_STATUS_READY`\n\
357 | The connection is currently not in a transaction and\n\
358 | it is ready to start executing the next command.\n\
359 | \n\
360 | * :data:`mgclient.CONN_STATUS_BAD`\n\
361 | Something went wrong with the connection, it cannot be\n\
362 | used for command execution anymore.\n\
363 | \n\
364 | * :data:`mgclient.CONN_STATUS_CLOSED`\n\
365 | The connection was closed by user, it cannot be\n\
366 | used for command execution anymore.\n\
367 | \n\
368 | * :data:`mgclient.CONN_STATUS_IN_TRANSACTION`\n\
369 | The connection is currently in an implicitly started\n\
370 | transaction.\n\
371 | \n\
372 | * :data:`mgclient.CONN_STATUS_EXECUTING`\n\
373 | The connection is currently executing a query. This status\n\
374 | can only be seen for lazy connections.");
375 | // clang-format on
376 |
377 | static PyMemberDef connection_members[] = {
378 | {"status", T_INT, offsetof(ConnectionObject, status), READONLY,
379 | ConnectionType_status_doc},
380 | {NULL}};
381 |
382 | static PyGetSetDef connection_getset[] = {
383 | {"autocommit", (getter)connection_autocommit_get,
384 | (setter)connection_autocommit_set, ConnectionType_autocommit_doc, NULL},
385 | {NULL}};
386 |
387 | // clang-format off
388 | PyDoc_STRVAR(ConnectionType_doc,
389 | "Encapsulates a database connection.\n\
390 | \n\
391 | New instances are created using the factory function :func:`connect`.\n\
392 | \n\
393 | Connections are not thread-safe.");
394 | // clang-format on
395 |
396 | // clang-format off
397 | PyTypeObject ConnectionType = {
398 | PyVarObject_HEAD_INIT(NULL, 0)
399 | .tp_name = "mgclient.Connection",
400 | .tp_doc = ConnectionType_doc,
401 | .tp_basicsize = sizeof(ConnectionObject),
402 | .tp_itemsize = 0,
403 | .tp_flags = Py_TPFLAGS_DEFAULT,
404 | .tp_dealloc = (destructor)connection_dealloc,
405 | .tp_methods = connection_methods,
406 | .tp_members = connection_members,
407 | .tp_getset = connection_getset,
408 | .tp_init = (initproc)connection_init,
409 | .tp_new = (newfunc)connection_new};
410 | // clang-format on
411 |
--------------------------------------------------------------------------------
/.github/workflows/reusable_buildtest.yml:
--------------------------------------------------------------------------------
1 | name: Reusable Build and Test
2 |
3 |
4 | on:
5 | workflow_call:
6 | inputs:
7 | test_linux:
8 | type: boolean
9 | default: true
10 | description: "Run Linux Build and Test"
11 | test_windows:
12 | type: boolean
13 | default: true
14 | description: "Run Windows Build and Test"
15 | test_macintosh:
16 | type: boolean
17 | default: true
18 | description: "Run Mac OS Build"
19 | build_source_dist:
20 | type: boolean
21 | default: true
22 | description: "Build Source Distribution"
23 | upload_artifacts:
24 | type: boolean
25 | default: true
26 | description: "Upload Artifacts"
27 | version:
28 | required: false
29 | type: string
30 |
31 | jobs:
32 | build_and_test_linux:
33 | if: ${{ inputs.test_linux }}
34 | name: "Build and test on Linux 👍"
35 | strategy:
36 | fail-fast: false
37 | matrix:
38 | include:
39 | - {platform: 'ubuntu-24.04', python_version: '3.10', mgversion: 'latest', arch: 'x86_64'}
40 | - {platform: 'ubuntu-24.04', python_version: '3.11', mgversion: 'latest', arch: 'x86_64'}
41 | - {platform: 'ubuntu-24.04', python_version: '3.12', mgversion: 'latest', arch: 'x86_64'}
42 | - {platform: 'ubuntu-24.04', python_version: '3.13', mgversion: 'latest', arch: 'x86_64'}
43 | - {platform: 'fedora-41', python_version: '3.13', mgversion: 'latest', arch: 'x86_64'}
44 | - {platform: 'ubuntu-24.04', python_version: '3.12', mgversion: 'latest', arch: 'aarch64'}
45 | runs-on: [self-hosted, "${{ matrix.arch == 'x86_64' && 'X64' || 'ARM64' }}"]
46 | steps:
47 | - name: Checkout repository and submodules
48 | uses: actions/checkout@v4
49 | with:
50 | fetch-depth: 0
51 | submodules: recursive
52 |
53 | - name: Set Memgraph Version
54 | run: |
55 | if [[ "${{ matrix.mgversion }}" == "latest" ]]; then
56 | mgversion=$(./tools/get_memgraph_version.sh)
57 | else
58 | mgversion="${{ matrix.mgversion }}"
59 | fi
60 | echo "MGVERSION=$mgversion" >> $GITHUB_ENV
61 |
62 | - name: Download Memgraph
63 | run: |
64 | if [[ "${{ matrix.platform }}" == "fedora-41" ]]; then
65 | MEMGRAPH_PACKAGE_NAME="memgraph-${{ env.MGVERSION }}_1-1.${{ matrix.arch }}.rpm"
66 | LOCAL_PACKAGE_NAME=memgraph.rpm
67 | else
68 | MEMGRAPH_PACKAGE_NAME="memgraph_${{ env.MGVERSION }}-1_${{ matrix.arch == 'x86_64' && 'amd64' || 'arm64' }}.deb"
69 | LOCAL_PACKAGE_NAME=memgraph.deb
70 | fi
71 | curl -L "https://download.memgraph.com/memgraph/v${{ env.MGVERSION }}/${{ matrix.platform }}${{ matrix.arch == 'aarch64' && '-aarch64' || '' }}/${MEMGRAPH_PACKAGE_NAME}" > "${LOCAL_PACKAGE_NAME}"
72 | echo "LOCAL_PACKAGE_NAME=$LOCAL_PACKAGE_NAME" >> $GITHUB_ENV
73 |
74 | - name: Set up Docker Buildx
75 | id: buildx
76 | uses: docker/setup-buildx-action@v3
77 |
78 | - name: Log in to Docker Hub
79 | uses: docker/login-action@v3
80 | with:
81 | username: ${{ secrets.DOCKERHUB_USERNAME }}
82 | password: ${{ secrets.DOCKERHUB_TOKEN }}
83 |
84 | - name: Launch Docker Container
85 | run: |
86 | platform="${{ matrix.platform }}"
87 | tag=${platform//-/:}
88 | if [[ "${{ inputs.version }}" != "" ]]; then
89 | echo "Building version ${{ inputs.version }}"
90 | docker run -d --rm \
91 | -e PYMGCLIENT_OVERRIDE_VERSION=${{ inputs.version }} \
92 | --name testcontainer "$tag" sleep infinity
93 | else
94 | docker run -d --rm --name testcontainer "$tag" sleep infinity
95 | fi
96 |
97 | - name: Set Environment Variables
98 | run: |
99 | break_packages=""
100 | static_ssl=""
101 | if [[ "${{ matrix.platform }}" == "ubuntu-24.04" ]]; then
102 | # this option is specific to ubuntu, not fedora
103 | break_packages="--break-system-packages"
104 | elif [[ "${{ matrix.platform }}" == fedora* ]]; then
105 | # rpm distros do not ship with static lib for openssl
106 | static_ssl="build_ext --static-openssl=False"
107 | fi
108 | echo "BREAK_PACKAGES=$break_packages" >> $GITHUB_ENV
109 | echo "STATIC_SSL=$static_ssl" >> $GITHUB_ENV
110 |
111 | - name: Copy Repo Into Container
112 | run: |
113 | docker cp . testcontainer:/pymgclient
114 |
115 | - name: Install system dependencies
116 | run: |
117 | docker cp $LOCAL_PACKAGE_NAME testcontainer:/$LOCAL_PACKAGE_NAME
118 |
119 | # Prevents Memgraph from starting.
120 | docker exec -i testcontainer \
121 | bash -c "mkdir -p /etc/systemd/system && ln -s /dev/null /etc/systemd/system/memgraph.service"
122 |
123 | # Install dependencies
124 | docker exec -i testcontainer \
125 | bash -c "cd /pymgclient && ./tools/install_linux_deps.sh ${{ matrix.platform }} --python-version ${{ matrix.python_version }} --force-update"
126 |
127 | # Install Memgraph package
128 | if [[ "${{ matrix.platform }}" == "fedora-41" ]]; then
129 | docker exec -i testcontainer \
130 | bash -c "dnf install -y /$LOCAL_PACKAGE_NAME"
131 | else
132 | docker exec -i testcontainer \
133 | bash -c "dpkg -i /$LOCAL_PACKAGE_NAME"
134 | fi
135 | rm -v $LOCAL_PACKAGE_NAME
136 |
137 |
138 | - name: Build Python Wheel
139 | run: |
140 | docker exec -i testcontainer \
141 | bash -c "cd /pymgclient && python${{ matrix.python_version }} setup.py ${{ env.STATIC_SSL }} bdist_wheel"
142 |
143 | - name: Audit Wheel
144 | if: ${{ matrix.platform == 'ubuntu-24.04' }}
145 | run: |
146 | docker exec -i testcontainer \
147 | bash -c "cd /pymgclient && auditwheel repair dist/*.whl --plat manylinux_2_39_${{ matrix.arch == 'x86_64' && 'x86_64' || 'aarch64' }} -w dist/ && rm dist/*linux_${{ matrix.arch == 'x86_64' && 'x86_64' || 'aarch64' }}.whl"
148 |
149 |
150 | - name: Install pymgclient
151 | run: |
152 | docker exec -i testcontainer \
153 | bash -c "python${{ matrix.python_version }} -m pip install ./pymgclient/dist/pymgclient-* ${{ env.BREAK_PACKAGES }}"
154 |
155 | - name: Import mgclient to validate installation
156 | run: |
157 | docker exec -i testcontainer \
158 | bash -c "python${{ matrix.python_version }} -c 'import mgclient'"
159 |
160 | - name: Run tests
161 | run: |
162 | MEMGRAPH_PORT=10000 # what's this for?
163 |
164 | docker exec -i testcontainer \
165 | bash -c "cd /pymgclient && python${{ matrix.python_version }} -m pytest -v"
166 |
167 | - name: Build docs
168 | run: |
169 | docker exec -i testcontainer \
170 | bash -c "cd /pymgclient/docs && make html"
171 |
172 | - name: Copy Package
173 | run: |
174 | docker cp testcontainer:/pymgclient/dist .
175 |
176 | - name: Save source distribution package
177 | if: ${{ inputs.upload_artifacts && matrix.platform == 'ubuntu-24.04' }}
178 | uses: actions/upload-artifact@v4
179 | with:
180 | name: pymgclient-linux-${{ matrix.python_version }}
181 | path: dist/
182 |
183 | - name: Cleanup
184 | if: always()
185 | run: |
186 | docker stop testcontainer || echo "Container does not exist"
187 | docker wait testcontainer || echo "Container does not exist"
188 | docker rmi ${{ env.MGVERSION }} || echo "Image does not exist"
189 |
190 | build_source_dist:
191 | if: ${{ inputs.build_source_dist }}
192 | name: Build Source Distribution
193 | runs-on: [self-hosted, X64, Ubuntu24.04]
194 | steps:
195 | - name: Checkout repository and submodules
196 | uses: actions/checkout@v4
197 | with:
198 | fetch-depth: 0
199 | submodules: recursive
200 |
201 | - name: Set override version if provided
202 | if: ${{ inputs.version != '' }}
203 | run: |
204 | echo "Building version ${{ inputs.version }}"
205 | echo "PYMGCLIENT_OVERRIDE_VERSION=${{ inputs.version }}" >> $GITHUB_ENV
206 |
207 | - name: Create Python Virtual Environment
208 | run: |
209 | python3 -m venv env
210 | source env/bin/activate
211 | pip install setuptools wheel
212 |
213 | - name: Build Python Source Distribution
214 | run: |
215 | source env/bin/activate
216 | python setup.py sdist
217 |
218 | - name: Save source distribution package
219 | if: ${{ inputs.upload_artifacts }}
220 | uses: actions/upload-artifact@v4
221 | with:
222 | name: pymgclient-linux-sdist
223 | path: dist
224 |
225 | - name: Cleanup
226 | if: always()
227 | run: |
228 | rm -fr env || true
229 | rm -fr dist || true
230 |
231 |
232 |
233 | build_and_test_windows:
234 | if: ${{ inputs.test_windows }}
235 | name: Build and Test on Windows
236 | runs-on: ${{ matrix.os }}
237 | strategy:
238 | fail-fast: false
239 | matrix:
240 | os: [windows-2022, windows-2025]
241 | python-version: ["3.10", "3.11", "3.12", "3.13"]
242 |
243 | steps:
244 | - name: Checkout code
245 | uses: actions/checkout@v4
246 | with:
247 | submodules: true
248 |
249 | - name: Set override version if provided
250 | if: ${{ inputs.version != '' }}
251 | shell: bash
252 | run: |
253 | echo "Building version ${{ inputs.version }}"
254 | echo "PYMGCLIENT_OVERRIDE_VERSION=${{ inputs.version }}" >> $GITHUB_ENV
255 |
256 | - name: Set up MSYS2
257 | uses: msys2/setup-msys2@v2
258 | with:
259 | msystem: MINGW64
260 | install: >
261 | git
262 | mingw-w64-x86_64-gcc
263 | mingw-w64-x86_64-cmake
264 | mingw-w64-x86_64-make
265 | mingw-w64-x86_64-openssl
266 |
267 | - name: Add MSYS2 mingw64/bin to PATH
268 | shell: msys2 {0}
269 | run: |
270 | echo "/mingw64/bin" >> $GITHUB_PATH
271 |
272 |
273 | - name: Set up Windows Python
274 | uses: actions/setup-python@v5
275 | with:
276 | python-version: ${{ matrix.python-version }}
277 | architecture: x64
278 |
279 | - name: Add Windows Python to PATH
280 | shell: msys2 {0}
281 | run: |
282 | echo "$pythonLocation" >> $GITHUB_PATH
283 | env:
284 | pythonLocation: ${{ env.pythonLocation }}
285 |
286 |
287 | - name: Install Python build tools
288 | shell: msys2 {0}
289 | run: |
290 | export PATH="$(cygpath -u "$pythonLocation"):$PATH"
291 | python -m pip install --upgrade pip setuptools wheel pyopenssl pytest tzdata
292 | env:
293 | pythonLocation: ${{ env.pythonLocation }}
294 |
295 | - name: Build pymgclient Wheel
296 | shell: msys2 {0}
297 | run: |
298 | export PATH="$(cygpath -u "$pythonLocation"):$PATH"
299 | python setup.py bdist_wheel
300 | env:
301 | pythonLocation: ${{ env.pythonLocation }}
302 |
303 | - name: Install built wheel
304 | shell: msys2 {0}
305 | run: |
306 | export PATH="$(cygpath -u "$pythonLocation"):$PATH"
307 | python -m pip install dist/*.whl
308 | env:
309 | pythonLocation: ${{ env.pythonLocation }}
310 |
311 | - name: Setup WSL Ubuntu
312 | uses: Vampire/setup-wsl@v5
313 | with:
314 | distribution: Ubuntu-24.04
315 |
316 | - name: Set Memgraph Version
317 | shell: bash -l {0}
318 | run: |
319 | mgversion=$(./tools/get_memgraph_version.sh)
320 | echo "MGVERSION=$mgversion" >> $GITHUB_ENV
321 |
322 | - name: Install and Run Memgraph in WSL
323 | shell: wsl-bash {0}
324 | run: |
325 | mkdir -p $HOME/memgraph/data
326 | sudo apt update
327 | sudo apt install -y curl
328 | curl -L https://download.memgraph.com/memgraph/v${{ env.MGVERSION }}/ubuntu-24.04/memgraph_${{ env.MGVERSION }}-1_amd64.deb -o memgraph.deb
329 | sudo mkdir -p /etc/systemd/system && sudo ln -s /dev/null /etc/systemd/system/memgraph.service # Prevents Memgraph from starting.
330 | sudo apt install -y ./memgraph.deb
331 | openssl req -x509 -newkey rsa:4096 -days 3650 -nodes -keyout key.pem -out cert.pem -subj "/C=GB/ST=London/L=London/O=Testing Corp./CN=PymgclientTest"
332 | nohup /usr/lib/memgraph/memgraph --bolt-port 7687 --bolt-cert-file="cert.pem" --bolt-key-file="key.pem" --data-directory="~/memgraph/data" --storage-properties-on-edges=true --storage-snapshot-interval-sec=0 --storage-wal-enabled=false --storage-snapshot-on-exit=false --telemetry-enabled=false --log-file='' &
333 |
334 | # sleep here instead of using script because it just doesn't work in Windows
335 | sleep 3
336 | # sed $'s/\r$//' ./tools/wait_for_memgraph.sh > ./tools/wait_for_memgraph.unix.sh
337 | # bash ./tools/wait_for_memgraph.unix.sh localhost
338 |
339 |
340 | - name: Run Tests
341 | shell: msys2 {0}
342 | run: |
343 | export PATH="$(cygpath -u "$pythonLocation"):/mingw64/bin:$PATH"
344 | echo $PATH
345 | python -m pytest -v
346 |
347 | env:
348 | pythonLocation: ${{ env.pythonLocation }}
349 | MEMGRAPH_HOST: localhost
350 | MEMGRAPH_STARTED_WITH_SSL:
351 |
352 |
353 | - name: Upload Wheel Artifact
354 | if: ${{ inputs.upload_artifacts && matrix.os == 'windows-2025' }}
355 | uses: actions/upload-artifact@v4
356 | with:
357 | name: pymgclient-win-${{ matrix.python-version }}
358 | path: dist/
359 |
360 |
361 | # NOTE: Cannot run tests on Mac OS runners because:
362 | # - GitHub hosted runners don't have docker
363 | # - self-hosted runner unable to log in to docker due to requiring a password to unlock the keychain
364 | # Also - github hosted macos runners after macos-13 are all arm64
365 | build_macos:
366 | if: ${{ inputs.test_macintosh }}
367 | name: Build and test on MacOS
368 | strategy:
369 | fail-fast: false
370 | matrix:
371 | platform: [macos-14, macos-15]
372 | python_version:
373 | - '3.10'
374 | - '3.11'
375 | - '3.12'
376 | - '3.13'
377 | runs-on: ${{ matrix.platform }}
378 | steps:
379 | - uses: actions/checkout@v2
380 | with:
381 | submodules: true
382 |
383 | - name: Set override version if provided
384 | if: ${{ inputs.version != '' }}
385 | run: |
386 | echo "Building version ${{ inputs.version }}"
387 | echo "PYMGCLIENT_OVERRIDE_VERSION=${{ inputs.version }}" >> $GITHUB_ENV
388 |
389 | - name: Install python and OpenSSL
390 | run: |
391 | brew install python@${{ matrix.python_version }} openssl@1.1
392 | brew link --force --overwrite openssl@1.1
393 | openssl version -a
394 |
395 | - name: Manage OpenSSL 3 on ARM machines
396 | if: ${{ contains(matrix.platform, 'ARM64') }}
397 | run: |
398 | brew install openssl@3
399 | brew link --force --overwrite openssl@3
400 | openssl version -a
401 |
402 | - name: Make used python version default
403 | run: |
404 | brew unlink python@3 && brew link --force python@${{ matrix.python_version }}
405 | python${{ matrix.python_version }} --version
406 |
407 | - name: Create Virtual Env
408 | run: |
409 | python${{ matrix.python_version }} -m venv env
410 |
411 | - name: Install pytest and pyopenssl
412 | run: |
413 | export PIP_BREAK_SYSTEM_PACKAGES=1
414 | source env/bin/activate
415 | python${{ matrix.python_version }} -m pip install pyopenssl pytest setuptools
416 |
417 | - name: Build pymgclient
418 | run: |
419 | source env/bin/activate
420 | python${{ matrix.python_version }} setup.py bdist_wheel
421 |
422 | - name: Install pymgclient
423 | run: |
424 | export PIP_BREAK_SYSTEM_PACKAGES=1
425 | source env/bin/activate
426 | python${{ matrix.python_version }} -m pip install dist/*
427 |
428 | - name: Import mgclient to validate installation
429 | run: |
430 | source env/bin/activate
431 | python${{ matrix.python_version }} -c "import mgclient"
432 |
433 | - name: Save wheel package
434 | if: ${{ inputs.upload_artifacts }}
435 | uses: actions/upload-artifact@v4
436 | with:
437 | name: pymgclient-${{ matrix.platform[0] || matrix.platform }}-${{ matrix.python_version }}
438 | path: dist/
439 |
440 |
--------------------------------------------------------------------------------
/src/types.c:
--------------------------------------------------------------------------------
1 | // Copyright (c) 2016-2020 Memgraph Ltd. [https://memgraph.com]
2 | //
3 | // Licensed under the Apache License, Version 2.0 (the "License");
4 | // you may not use this file except in compliance with the License.
5 | // You may obtain a copy of the License at
6 | //
7 | // http://www.apache.org/licenses/LICENSE-2.0
8 | //
9 | // Unless required by applicable law or agreed to in writing, software
10 | // distributed under the License is distributed on an "AS IS" BASIS,
11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | // See the License for the specific language governing permissions and
13 | // limitations under the License.
14 |
15 | #include "types.h"
16 |
17 | #include
18 |
19 | PyTypeObject NodeType;
20 | PyTypeObject RelationshipType;
21 | PyTypeObject PathType;
22 |
23 | #define CHECK_ATTRIBUTE(obj, name) \
24 | do { \
25 | if (!obj->name) { \
26 | PyErr_SetString(PyExc_AttributeError, "attribute '" #name "' is NULL"); \
27 | return NULL; \
28 | } \
29 | } while (0)
30 |
31 | static void node_dealloc(NodeObject *node) {
32 | Py_CLEAR(node->labels);
33 | Py_CLEAR(node->properties);
34 | Py_TYPE(node)->tp_free(node);
35 | }
36 |
37 | static PyObject *node_repr(NodeObject *node) {
38 | return PyUnicode_FromFormat("<%s(id=%lld, labels=%R, properties=%R) at %p>",
39 | Py_TYPE(node)->tp_name, node->id, node->labels,
40 | node->properties, node);
41 | }
42 |
43 | static PyObject *node_str(NodeObject *node) {
44 | CHECK_ATTRIBUTE(node, labels);
45 | CHECK_ATTRIBUTE(node, properties);
46 |
47 | if (PySet_Size(node->labels)) {
48 | PyObject *colon = PyUnicode_FromString(":");
49 | if (!colon) {
50 | return NULL;
51 | }
52 | PyObject *labels = PyUnicode_Join(colon, node->labels);
53 | Py_DECREF(colon);
54 | if (!labels) {
55 | return NULL;
56 | }
57 | PyObject *result =
58 | PyDict_Size(node->properties)
59 | ? PyUnicode_FromFormat("(:%S %S)", labels, node->properties)
60 | : PyUnicode_FromFormat("(:%S)", labels);
61 | Py_DECREF(labels);
62 | return result;
63 | } else {
64 | if (PyDict_Size(node->properties)) {
65 | return PyUnicode_FromFormat("(%S)", node->properties);
66 | } else {
67 | return PyUnicode_FromString("()");
68 | }
69 | }
70 | }
71 |
72 | // Helper function for implementing richcompare.
73 | static PyObject *node_astuple(NodeObject *node) {
74 | CHECK_ATTRIBUTE(node, labels);
75 | CHECK_ATTRIBUTE(node, properties);
76 |
77 | PyObject *tuple = PyTuple_New(3);
78 | if (!tuple) {
79 | return NULL;
80 | }
81 |
82 | PyObject *id = PyLong_FromLongLong(node->id);
83 | if (!id) {
84 | Py_DECREF(tuple);
85 | return NULL;
86 | }
87 | Py_INCREF(node->labels);
88 | Py_INCREF(node->properties);
89 |
90 | PyTuple_SET_ITEM(tuple, 0, id);
91 | PyTuple_SET_ITEM(tuple, 1, node->labels);
92 | PyTuple_SET_ITEM(tuple, 2, node->properties);
93 | return tuple;
94 | }
95 |
96 | static PyObject *node_richcompare(NodeObject *lhs, PyObject *rhs, int op) {
97 | PyObject *tlhs = NULL;
98 | PyObject *trhs = NULL;
99 | PyObject *ret = NULL;
100 |
101 | if (Py_TYPE(rhs) == &NodeType) {
102 | if (!(tlhs = node_astuple(lhs))) {
103 | goto exit;
104 | }
105 | if (!(trhs = node_astuple((NodeObject *)rhs))) {
106 | goto exit;
107 | }
108 | ret = PyObject_RichCompare(tlhs, trhs, op);
109 | } else {
110 | Py_INCREF(Py_False);
111 | ret = Py_False;
112 | }
113 |
114 | exit:
115 | Py_XDECREF(tlhs);
116 | Py_XDECREF(trhs);
117 | return ret;
118 | }
119 |
120 | int node_init(NodeObject *node, PyObject *args, PyObject *kwargs) {
121 | int64_t id = -1;
122 | PyObject *labels;
123 | PyObject *properties;
124 |
125 | static char *kwlist[] = {"", "", "", NULL};
126 | if (!PyArg_ParseTupleAndKeywords(args, kwargs, "LOO", kwlist, &id, &labels,
127 | &properties)) {
128 | return -1;
129 | }
130 |
131 | if (!PySet_Check(labels)) {
132 | PyErr_SetString(PyExc_TypeError, "__init__ argument 2 must be a set");
133 | return -1;
134 | }
135 | if (!PyDict_Check(properties)) {
136 | PyErr_SetString(PyExc_TypeError, "__init__ argument 3 must be a dict");
137 | return -1;
138 | }
139 |
140 | node->id = id;
141 |
142 | PyObject *tmp_labels = node->labels;
143 | Py_INCREF(labels);
144 | node->labels = labels;
145 | Py_XDECREF(tmp_labels);
146 |
147 | PyObject *tmp_properties = node->properties;
148 | Py_INCREF(properties);
149 | node->properties = properties;
150 | Py_XDECREF(tmp_properties);
151 |
152 | return 0;
153 | }
154 |
155 | PyDoc_STRVAR(NodeType_id_doc,
156 | "Unique node identifier (within the scope of its origin graph).");
157 |
158 | PyDoc_STRVAR(NodeType_labels_doc, "A list of node labels.");
159 |
160 | PyDoc_STRVAR(NodeType_properties_doc, "A dictionary of node properties.");
161 |
162 | static PyMemberDef node_members[] = {
163 | {"id", T_LONGLONG, offsetof(NodeObject, id), READONLY, NodeType_id_doc},
164 | {"labels", T_OBJECT_EX, offsetof(NodeObject, labels), READONLY,
165 | NodeType_labels_doc},
166 | {"properties", T_OBJECT_EX, offsetof(NodeObject, properties), READONLY,
167 | NodeType_properties_doc},
168 | {NULL}};
169 |
170 | PyDoc_STRVAR(NodeType_doc,
171 | "A node in the graph with optional properties and labels.");
172 |
173 | // clang-format off
174 | PyTypeObject NodeType = {
175 | PyVarObject_HEAD_INIT(NULL, 0)
176 | .tp_name = "mgclient.Node",
177 | .tp_basicsize = sizeof(NodeObject),
178 | .tp_itemsize = 0,
179 | .tp_dealloc = (destructor)node_dealloc,
180 | .tp_repr = (reprfunc)node_repr,
181 | .tp_str = (reprfunc)node_str,
182 | .tp_flags = Py_TPFLAGS_DEFAULT,
183 | .tp_doc = NodeType_doc,
184 | .tp_richcompare = (richcmpfunc)node_richcompare,
185 | .tp_members = node_members,
186 | .tp_init = (initproc)node_init,
187 | .tp_new = PyType_GenericNew
188 | };
189 | // clang-format on
190 |
191 | static void relationship_dealloc(RelationshipObject *rel) {
192 | Py_CLEAR(rel->type);
193 | Py_CLEAR(rel->properties);
194 | Py_TYPE(rel)->tp_free(rel);
195 | }
196 |
197 | static PyObject *relationship_repr(RelationshipObject *rel) {
198 | return PyUnicode_FromFormat(
199 | "<%s(start_id=%lld, end_id=%lld, type=%R, properties=%R) at %p>",
200 | Py_TYPE(rel)->tp_name, rel->start_id, rel->end_id, rel->type,
201 | rel->properties, rel);
202 | }
203 |
204 | static PyObject *relationship_str(RelationshipObject *rel) {
205 | CHECK_ATTRIBUTE(rel, type);
206 | CHECK_ATTRIBUTE(rel, properties);
207 |
208 | if (PyDict_Size(rel->properties)) {
209 | return PyUnicode_FromFormat("[:%S %S]", rel->type, rel->properties);
210 | } else {
211 | return PyUnicode_FromFormat("[:%S]", rel->type);
212 | }
213 | }
214 |
215 | // Helper function for implementing richcompare.
216 | static PyObject *relationship_astuple(RelationshipObject *rel) {
217 | CHECK_ATTRIBUTE(rel, type);
218 | CHECK_ATTRIBUTE(rel, properties);
219 |
220 | PyObject *id = NULL;
221 | PyObject *start_id = NULL;
222 | PyObject *end_id = NULL;
223 | PyObject *tuple = NULL;
224 |
225 | if (!(id = PyLong_FromLongLong(rel->id))) {
226 | goto cleanup;
227 | }
228 | if (!(start_id = PyLong_FromLongLong(rel->start_id))) {
229 | goto cleanup;
230 | }
231 | if (!(end_id = PyLong_FromLongLong(rel->end_id))) {
232 | goto cleanup;
233 | }
234 | if (!(tuple = PyTuple_New(5))) {
235 | goto cleanup;
236 | }
237 |
238 | PyTuple_SET_ITEM(tuple, 0, id);
239 | PyTuple_SET_ITEM(tuple, 1, start_id);
240 | PyTuple_SET_ITEM(tuple, 2, end_id);
241 | Py_INCREF(rel->type);
242 | PyTuple_SET_ITEM(tuple, 3, rel->type);
243 | Py_INCREF(rel->properties);
244 | PyTuple_SET_ITEM(tuple, 4, rel->properties);
245 |
246 | return tuple;
247 |
248 | cleanup:
249 | Py_XDECREF(id);
250 | Py_XDECREF(start_id);
251 | Py_XDECREF(end_id);
252 | Py_XDECREF(tuple);
253 | return NULL;
254 | }
255 |
256 | static PyObject *relationship_richcompare(RelationshipObject *lhs,
257 | PyObject *rhs, int op) {
258 | PyObject *tlhs = NULL;
259 | PyObject *trhs = NULL;
260 | PyObject *ret = NULL;
261 |
262 | if (Py_TYPE(rhs) == &RelationshipType) {
263 | if (!(tlhs = relationship_astuple(lhs))) {
264 | goto exit;
265 | }
266 | if (!(trhs = relationship_astuple((RelationshipObject *)rhs))) {
267 | goto exit;
268 | }
269 | ret = PyObject_RichCompare(tlhs, trhs, op);
270 | } else {
271 | Py_INCREF(Py_False);
272 | ret = Py_False;
273 | }
274 |
275 | exit:
276 | Py_XDECREF(tlhs);
277 | Py_XDECREF(trhs);
278 | return ret;
279 | }
280 |
281 | int relationship_init(RelationshipObject *rel, PyObject *args,
282 | PyObject *kwargs) {
283 | int64_t id;
284 | int64_t start_id = -1;
285 | int64_t end_id = -1;
286 | PyObject *type;
287 | PyObject *properties;
288 |
289 | static char *kwlist[] = {"", "", "", "", "", NULL};
290 | if (!PyArg_ParseTupleAndKeywords(args, kwargs, "LLLOO", kwlist, &id,
291 | &start_id, &end_id, &type, &properties)) {
292 | return -1;
293 | }
294 |
295 | if (!PyUnicode_Check(type)) {
296 | PyErr_SetString(PyExc_TypeError, "__init__ argument 4 must be a string");
297 | return -1;
298 | }
299 | if (!PyDict_Check(properties)) {
300 | PyErr_SetString(PyExc_TypeError, "__init__ argument 5 must be a dict");
301 | return -1;
302 | }
303 |
304 | rel->id = id;
305 | rel->start_id = start_id;
306 | rel->end_id = end_id;
307 |
308 | PyObject *tmp_type = rel->type;
309 | Py_INCREF(type);
310 | rel->type = type;
311 | Py_XDECREF(tmp_type);
312 |
313 | PyObject *tmp_properties = rel->properties;
314 | Py_INCREF(properties);
315 | rel->properties = properties;
316 | Py_XDECREF(tmp_properties);
317 |
318 | return 0;
319 | }
320 |
321 | PyDoc_STRVAR(
322 | RelationshipType_id_doc,
323 | "Unique relationship identifier (within the scope of its origin graph).");
324 |
325 | PyDoc_STRVAR(RelationshipType_start_id_doc,
326 | "Identifier of relationship start node (or -1 if it was not "
327 | "supplied by the database).");
328 |
329 | PyDoc_STRVAR(RelationshipType_end_id_doc,
330 | "Identifier of relationship end node (or -1 if it was not "
331 | "supplied by the database).");
332 |
333 | PyDoc_STRVAR(RelationshipType_type_doc, "Relationship type.");
334 |
335 | PyDoc_STRVAR(RelationshipType_properties_doc,
336 | "A dictionary of relationship properties.");
337 |
338 | static PyMemberDef relationship_members[] = {
339 | {"id", T_LONGLONG, offsetof(RelationshipObject, id), READONLY,
340 | RelationshipType_id_doc},
341 | {"start_id", T_LONGLONG, offsetof(RelationshipObject, start_id), READONLY,
342 | RelationshipType_start_id_doc},
343 | {"end_id", T_LONGLONG, offsetof(RelationshipObject, end_id), READONLY,
344 | RelationshipType_end_id_doc},
345 | {"type", T_OBJECT_EX, offsetof(RelationshipObject, type), READONLY,
346 | RelationshipType_type_doc},
347 | {"properties", T_OBJECT_EX, offsetof(RelationshipObject, properties),
348 | READONLY, RelationshipType_properties_doc},
349 | {NULL}};
350 |
351 | PyDoc_STRVAR(
352 | RelationshipType_doc,
353 | "A directed, typed connection between two nodes with optional properties.");
354 |
355 | // clang-format off
356 | PyTypeObject RelationshipType = {
357 | PyVarObject_HEAD_INIT(NULL, 0)
358 | .tp_name = "mgclient.Relationship",
359 | .tp_basicsize = sizeof(RelationshipObject),
360 | .tp_itemsize = 0,
361 | .tp_dealloc = (destructor)relationship_dealloc,
362 | .tp_repr = (reprfunc)relationship_repr,
363 | .tp_str = (reprfunc)relationship_str,
364 | .tp_flags = Py_TPFLAGS_DEFAULT,
365 | .tp_doc = RelationshipType_doc,
366 | .tp_richcompare = (richcmpfunc)relationship_richcompare,
367 | .tp_members = relationship_members,
368 | .tp_init = (initproc)relationship_init,
369 | .tp_new = PyType_GenericNew
370 | };
371 | // clang-format on
372 |
373 | static void path_dealloc(PathObject *path) {
374 | Py_CLEAR(path->nodes);
375 | Py_CLEAR(path->relationships);
376 | Py_TYPE(path)->tp_free(path);
377 | }
378 |
379 | static PyObject *path_repr(PathObject *path) {
380 | return PyUnicode_FromFormat("<%s(nodes=%R, relationships=%R) at %p>",
381 | Py_TYPE(path)->tp_name, path->nodes,
382 | path->relationships, path);
383 | }
384 |
385 | static PyObject *path_str(PathObject *path) {
386 | CHECK_ATTRIBUTE(path, nodes);
387 | CHECK_ATTRIBUTE(path, relationships);
388 |
389 | PyObject *result = NULL;
390 |
391 | Py_ssize_t length = PyList_Size(path->relationships);
392 | PyObject *elements = PyList_New(2 * length + 1);
393 | if (!elements) {
394 | goto end;
395 | }
396 |
397 | for (Py_ssize_t i = 0; i <= length; ++i) {
398 | NodeObject *node = (NodeObject *)PyList_GetItem(path->nodes, i);
399 | if (!node) {
400 | goto end;
401 | }
402 | PyObject *node_s = node_str(node);
403 | if (!node_s) {
404 | goto end;
405 | }
406 | PyList_SET_ITEM(elements, 2 * i, node_s);
407 | if (i < length) {
408 | RelationshipObject *rel =
409 | (RelationshipObject *)PyList_GetItem(path->relationships, i);
410 | PyObject *rel_s;
411 | if (rel->start_id == node->id) {
412 | rel_s = PyUnicode_FromFormat("-%S->", rel);
413 | } else {
414 | rel_s = PyUnicode_FromFormat("<-%S-", rel);
415 | }
416 | if (!rel_s) {
417 | goto end;
418 | }
419 | PyList_SET_ITEM(elements, 2 * i + 1, rel_s);
420 | }
421 | }
422 |
423 | PyObject *sep = PyUnicode_FromString("");
424 | if (!sep) {
425 | goto end;
426 | }
427 | result = PyUnicode_Join(sep, elements);
428 | Py_DECREF(sep);
429 |
430 | end:
431 | Py_XDECREF(elements);
432 | return result;
433 | }
434 |
435 | // Helper function for implementing richcompare.
436 | static PyObject *path_astuple(PathObject *path) {
437 | CHECK_ATTRIBUTE(path, nodes);
438 | CHECK_ATTRIBUTE(path, relationships);
439 |
440 | PyObject *tuple = PyTuple_New(2);
441 | if (!tuple) {
442 | return NULL;
443 | }
444 | Py_INCREF(path->nodes);
445 | Py_INCREF(path->relationships);
446 | PyTuple_SET_ITEM(tuple, 0, path->nodes);
447 | PyTuple_SET_ITEM(tuple, 1, path->relationships);
448 | return tuple;
449 | }
450 |
451 | static PyObject *path_richcompare(PathObject *lhs, PathObject *rhs, int op) {
452 | PyObject *tlhs = NULL;
453 | PyObject *trhs = NULL;
454 | PyObject *ret = NULL;
455 | if (Py_TYPE(rhs) == &PathType) {
456 | if (!(tlhs = path_astuple(lhs))) {
457 | goto exit;
458 | }
459 | if (!(trhs = path_astuple((PathObject *)rhs))) {
460 | goto exit;
461 | }
462 | ret = PyObject_RichCompare(tlhs, trhs, op);
463 |
464 | } else {
465 | Py_INCREF(Py_False);
466 | ret = Py_False;
467 | }
468 |
469 | exit:
470 | Py_XDECREF(tlhs);
471 | Py_XDECREF(trhs);
472 | return ret;
473 | }
474 |
475 | static int check_types_in_list(PyObject *list, PyTypeObject *expected_type,
476 | const char *function_name, int arg_index) {
477 | int ok = 1;
478 | if (PyList_Check(list)) {
479 | PyObject *iter = PyObject_GetIter(list);
480 | if (!iter) {
481 | return -1;
482 | }
483 | PyObject *elem;
484 | while ((elem = PyIter_Next(iter))) {
485 | PyTypeObject *t = Py_TYPE(elem);
486 | Py_DECREF(elem);
487 | if (t != expected_type) {
488 | ok = 0;
489 | break;
490 | }
491 | }
492 | if (PyErr_Occurred()) {
493 | return -1;
494 | }
495 | } else {
496 | ok = 0;
497 | }
498 | if (!ok) {
499 | PyErr_Format(PyExc_TypeError, "%s argument %d must be a list of '%s'",
500 | function_name, arg_index, expected_type->tp_name);
501 | return -1;
502 | }
503 | return 0;
504 | }
505 |
506 | static int path_init(PathObject *path, PyObject *args, PyObject *kwargs) {
507 | PyObject *nodes;
508 | PyObject *relationships;
509 |
510 | static char *kwlist[] = {"", "", NULL};
511 | if (!PyArg_ParseTupleAndKeywords(args, kwargs, "OO", kwlist, &nodes,
512 | &relationships)) {
513 | return -1;
514 | }
515 |
516 | if (check_types_in_list(nodes, &NodeType, "__init__", 1) < 0 ||
517 | check_types_in_list(relationships, &RelationshipType, "__init__", 2) <
518 | 0) {
519 | return -1;
520 | }
521 |
522 | PyObject *tmp_nodes = path->nodes;
523 | Py_INCREF(nodes);
524 | path->nodes = nodes;
525 | Py_XDECREF(tmp_nodes);
526 |
527 | PyObject *tmp_relationships = path->relationships;
528 | Py_INCREF(relationships);
529 | path->relationships = relationships;
530 | Py_XDECREF(tmp_relationships);
531 |
532 | return 0;
533 | }
534 |
535 | // clang-format off
536 | PyDoc_STRVAR(PathType_nodes_doc,
537 | "A list of nodes in the order they appear in the path. It has one element\n\
538 | more than the :attr:`relationships` list.");
539 |
540 | PyDoc_STRVAR(PathType_relationships_doc,
541 | "A list of relationships in the order they appear in the path. It has one\n\
542 | element less than the :attr:`nodes` list.");
543 | // clang-format on
544 |
545 | static PyMemberDef path_members[] = {
546 | {"nodes", T_OBJECT_EX, offsetof(PathObject, nodes), READONLY,
547 | PathType_nodes_doc},
548 | {"relationships", T_OBJECT_EX, offsetof(PathObject, relationships),
549 | READONLY, PathType_relationships_doc},
550 | {NULL}};
551 |
552 | // clang-format off
553 | PyDoc_STRVAR(PathType_doc,
554 | "A sequence of alternating nodes and relationships corresponding to a walk\n\
555 | in the graph.");
556 | // clang-format on
557 |
558 | // clang-format off
559 | PyTypeObject PathType = {
560 | PyVarObject_HEAD_INIT(NULL, 0)
561 | .tp_name = "mgclient.Path",
562 | .tp_basicsize = sizeof(PathObject),
563 | .tp_itemsize = 0,
564 | .tp_dealloc = (destructor)path_dealloc,
565 | .tp_repr = (reprfunc)path_repr,
566 | .tp_str = (reprfunc)path_str,
567 | .tp_flags = Py_TPFLAGS_DEFAULT,
568 | .tp_doc = PathType_doc,
569 | .tp_richcompare = (richcmpfunc)path_richcompare,
570 | .tp_members = path_members,
571 | .tp_init = (initproc)path_init,
572 | .tp_new = PyType_GenericNew
573 | };
574 |
575 | #undef CHECK_ATTRIBUTE
576 | // clang-format on
577 |
--------------------------------------------------------------------------------
/src/cursor.c:
--------------------------------------------------------------------------------
1 | // Copyright (c) 2016-2020 Memgraph Ltd. [https://memgraph.com]
2 | //
3 | // Licensed under the Apache License, Version 2.0 (the "License");
4 | // you may not use this file except in compliance with the License.
5 | // You may obtain a copy of the License at
6 | //
7 | // http://www.apache.org/licenses/LICENSE-2.0
8 | //
9 | // Unless required by applicable law or agreed to in writing, software
10 | // distributed under the License is distributed on an "AS IS" BASIS,
11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | // See the License for the specific language governing permissions and
13 | // limitations under the License.
14 |
15 | #include "cursor.h"
16 |
17 | #include
18 |
19 | #include "column.h"
20 | #include "connection.h"
21 | #include "exceptions.h"
22 |
23 | static void cursor_dealloc(CursorObject *cursor) {
24 | Py_CLEAR(cursor->conn);
25 | Py_CLEAR(cursor->rows);
26 | Py_CLEAR(cursor->description);
27 | Py_TYPE(cursor)->tp_free(cursor);
28 | }
29 |
30 | int cursor_init(CursorObject *cursor, PyObject *args, PyObject *kwargs) {
31 | ConnectionObject *conn = NULL;
32 |
33 | static char *kwlist[] = {"", NULL};
34 | if (!PyArg_ParseTupleAndKeywords(args, kwargs, "O", kwlist, &conn)) {
35 | return -1;
36 | }
37 |
38 | if (Py_TYPE(conn) != &ConnectionType) {
39 | PyErr_Format(PyExc_TypeError, "__init__ argument 1 must be of type '%s'",
40 | ConnectionType.tp_name);
41 | return -1;
42 | }
43 |
44 | Py_INCREF(conn);
45 | cursor->conn = conn;
46 |
47 | cursor->status = CURSOR_STATUS_READY;
48 | cursor->hasresults = 0;
49 | cursor->arraysize = 1;
50 | cursor->rows = NULL;
51 | cursor->description = NULL;
52 | return 0;
53 | }
54 |
55 | PyObject *cursor_new(PyTypeObject *subtype, PyObject *args, PyObject *kwargs) {
56 | // Unused args.
57 | (void)args;
58 | (void)kwargs;
59 |
60 | PyObject *cursor = subtype->tp_alloc(subtype, 0);
61 | if (!cursor) {
62 | return NULL;
63 | }
64 | ((CursorObject *)cursor)->status = CURSOR_STATUS_CLOSED;
65 | return cursor;
66 | }
67 |
68 | // Reset the cursor into state where it can be used for executing a new query,
69 | // but caling fetch* throws an exception.
70 | static void cursor_reset(CursorObject *cursor) {
71 | Py_CLEAR(cursor->rows);
72 | Py_CLEAR(cursor->description);
73 | cursor->hasresults = 0;
74 | cursor->rowcount = -1;
75 | cursor->status = CURSOR_STATUS_READY;
76 | }
77 |
78 | // clang-format off
79 | PyDoc_STRVAR(cursor_close_doc,
80 | "close()\n\
81 | --\n\
82 | \n\
83 | Close the cursor now.\n\
84 | \n\
85 | The cursor will be unusable from this point forward; an :exc:`InterfaceError`\n\
86 | will be raised if any operation is attempted with the cursor.");
87 | // clang-format on
88 |
89 | PyObject *cursor_close(CursorObject *cursor, PyObject *args) {
90 | // Unused args;
91 | (void)args;
92 |
93 | assert(!args);
94 | if (cursor->status == CURSOR_STATUS_EXECUTING) {
95 | assert(cursor->conn->status == CONN_STATUS_EXECUTING);
96 | assert(cursor->conn->lazy);
97 |
98 | // Cannot close cursor while executing a query because query execution might
99 | // raise an error.
100 | PyErr_SetString(InterfaceError,
101 | "cannot close cursor during execution of a query");
102 | return NULL;
103 | }
104 |
105 | Py_CLEAR(cursor->conn);
106 | cursor_reset(cursor);
107 | cursor->status = CURSOR_STATUS_CLOSED;
108 |
109 | Py_RETURN_NONE;
110 | }
111 |
112 | static int cursor_set_description(CursorObject *cursor, PyObject *columns) {
113 | assert(PyList_Check(columns));
114 | assert(cursor->description == NULL);
115 | if (!columns) {
116 | return 0;
117 | }
118 | PyObject *description = NULL;
119 | if (!(description = PyList_New(PyList_Size(columns)))) {
120 | goto failure;
121 | }
122 | for (Py_ssize_t i = 0; i < PyList_Size(columns); ++i) {
123 | PyObject *entry = PyObject_CallFunctionObjArgs(
124 | (PyObject *)&ColumnType, PyList_GetItem(columns, i), NULL);
125 | if (!entry) {
126 | goto failure;
127 | }
128 | PyList_SET_ITEM(description, i, entry);
129 | }
130 |
131 | cursor->description = description;
132 | return 0;
133 |
134 | failure:
135 | if (PyErr_WarnEx(Warning, "failed to obtain result column names", 2) < 0) {
136 | return -1;
137 | }
138 | Py_XDECREF(description);
139 | return 0;
140 | }
141 |
142 | // clang-format off
143 | PyDoc_STRVAR(cursor_execute_doc,
144 | "execute(query, params=None)\n\
145 | --\n\
146 | \n\
147 | Execute a database operation.\n\
148 | \n\
149 | Parameters may be provided as a mapping and will be bound to variables in\n\
150 | the operation. Variables are specified with named (``$name``)\n\
151 | placeholders.\n\
152 | \n\
153 | This method always returns ``None``.\n");
154 | // clang-format on
155 |
156 | PyObject *cursor_execute(CursorObject *cursor, PyObject *args) {
157 | const char *query = NULL;
158 | PyObject *pyparams = NULL;
159 | if (!PyArg_ParseTuple(args, "s|O", &query, &pyparams)) {
160 | return NULL;
161 | }
162 |
163 | if (cursor->status == CURSOR_STATUS_CLOSED) {
164 | PyErr_SetString(InterfaceError, "cursor closed");
165 | return NULL;
166 | }
167 |
168 | if (connection_raise_if_bad_status(cursor->conn) < 0) {
169 | return NULL;
170 | }
171 |
172 | if (cursor->conn->status == CONN_STATUS_EXECUTING) {
173 | assert(cursor->conn->lazy);
174 | PyErr_SetString(InterfaceError,
175 | "cannot call execute during execution of a query");
176 | return NULL;
177 | }
178 |
179 | assert(cursor->status == CURSOR_STATUS_READY);
180 |
181 | cursor_reset(cursor);
182 |
183 | if (!cursor->conn->autocommit && cursor->conn->status == CONN_STATUS_READY) {
184 | if (connection_begin(cursor->conn) < 0) {
185 | goto cleanup;
186 | }
187 | }
188 |
189 | PyObject *columns;
190 | if (connection_run(cursor->conn, query, pyparams, &columns) < 0) {
191 | goto cleanup;
192 | }
193 |
194 | if (cursor_set_description(cursor, columns) < 0) {
195 | Py_XDECREF(columns);
196 | goto cleanup;
197 | }
198 | Py_XDECREF(columns);
199 |
200 | // In lazy mode, results are pulled when fetch is called.
201 | if (cursor->conn->lazy) {
202 | cursor->status = CURSOR_STATUS_EXECUTING;
203 | cursor->hasresults = 1;
204 | cursor->rowcount = -1;
205 | Py_RETURN_NONE;
206 | }
207 |
208 | // Pull all results now.
209 | if (!(cursor->rows = PyList_New(0))) {
210 | goto discard_all;
211 | }
212 |
213 | int status;
214 | status = connection_pull(cursor->conn, 0); // PULL_ALL
215 | if (status != 0) {
216 | goto cleanup;
217 | }
218 |
219 | PyObject *row;
220 | while ((status = connection_fetch(cursor->conn, &row, NULL)) == 1) {
221 | int append_result = PyList_Append(cursor->rows, row);
222 | Py_DECREF(row);
223 | if (append_result < 0) {
224 | goto discard_all;
225 | }
226 | }
227 | if (status < 0) {
228 | goto cleanup;
229 | }
230 |
231 | cursor->hasresults = 1;
232 | cursor->rowindex = 0;
233 | cursor->rowcount = PyList_Size(cursor->rows);
234 | Py_RETURN_NONE;
235 |
236 | discard_all:
237 | connection_discard_all(cursor->conn);
238 |
239 | cleanup:
240 | cursor_reset(cursor);
241 | return NULL;
242 | }
243 |
244 | // clang-format off
245 | PyDoc_STRVAR(cursor_fetchone_doc,
246 | "fetchone()\n\
247 | --\n\
248 | \n\
249 | Fetch the next row of query results, returning a single tuple, or ``None``\n\
250 | when no more data is available.\n\
251 | \n\
252 | An :exc:`InterfaceError` is raised if the previous call to :meth:`.execute()`\n\
253 | did not produce any results or no call was issued yet.");
254 | // clang-format on
255 |
256 | PyObject *cursor_fetchone(CursorObject *cursor, PyObject *args) {
257 | // Unused args.
258 | (void)args;
259 |
260 | assert(!args);
261 |
262 | if (!cursor->hasresults) {
263 | PyErr_SetString(InterfaceError, "no results available");
264 | return NULL;
265 | }
266 |
267 | if (cursor->conn->lazy) {
268 | if (cursor->status == CURSOR_STATUS_READY) {
269 | // All rows are pulled so we have to return None.
270 | Py_RETURN_NONE;
271 | }
272 |
273 | if (cursor->status == CURSOR_STATUS_EXECUTING) {
274 | int pull_status = connection_pull(cursor->conn, 1);
275 | if (pull_status != 0) {
276 | cursor_reset(cursor);
277 | return NULL;
278 | }
279 | }
280 |
281 | PyObject *row = NULL;
282 | // fetchone returns an exact result, this method can't be called twice for
283 | // a single pull call. If called twice for one pull call the second call
284 | // has to return something which is not correct form the cursor interface
285 | // point of view.
286 | //
287 | // The problem is also if the second fetch call ends up with a database
288 | // error, from the user perspective that will look like an error related to
289 | // the first pull.
290 | int has_more_first = 0;
291 | int has_more_second = 0;
292 | int fetch_status_first =
293 | connection_fetch(cursor->conn, &row, &has_more_first);
294 | int fetch_status_second = 0;
295 | if (fetch_status_first == 1) {
296 | fetch_status_second =
297 | connection_fetch(cursor->conn, NULL, &has_more_second);
298 | }
299 | if (fetch_status_first == -1 || fetch_status_second == -1) {
300 | Py_XDECREF(row);
301 | cursor_reset(cursor);
302 | return NULL;
303 | } else if (fetch_status_first == 0) {
304 | Py_XDECREF(row);
305 | if (has_more_first) {
306 | cursor->status = CURSOR_STATUS_EXECUTING;
307 | } else {
308 | cursor->status = CURSOR_STATUS_READY;
309 | }
310 | Py_RETURN_NONE;
311 | } else if (fetch_status_first == 1) {
312 | if (has_more_second) {
313 | cursor->status = CURSOR_STATUS_EXECUTING;
314 | } else {
315 | cursor->status = CURSOR_STATUS_READY;
316 | }
317 | return row;
318 | } else {
319 | // This should never happen, if it happens, some case is not covered.
320 | assert(0);
321 | }
322 | }
323 |
324 | assert(cursor->rowcount >= 0);
325 | if (cursor->rowindex < cursor->rowcount) {
326 | PyObject *row = PyList_GET_ITEM(cursor->rows, cursor->rowindex++);
327 | Py_INCREF(row);
328 | return row;
329 | }
330 |
331 | Py_RETURN_NONE;
332 | }
333 |
334 | // clang-format off
335 | PyDoc_STRVAR(
336 | cursor_fetchmany_doc,
337 | "fetchmany(size=None)\n\
338 | --\n\
339 | \n\
340 | Fetch the next set of rows of query results, returning a list of tuples.\n\
341 | An empty list is returned when no more data is available.\n\
342 | \n\
343 | The number of rows to fetch per call is specified by the parameter. If it\n\
344 | is not given the cursor's :attr:`arraysize` determines the number of rows\n\
345 | to be fetched. Fewer rows may be returned in case there is less rows available\n\
346 | than requested.\n\
347 | \n\
348 | An :exc:`InterfaceError` is raised if the previous call to :meth:`.execute()`\n\
349 | did not produce any results or no call was issued yet.");
350 | // clang-format on
351 |
352 | PyObject *cursor_fetchmany(CursorObject *cursor, PyObject *args,
353 | PyObject *kwargs) {
354 | // TODO(gitbuda): Implement fetchmany by pulling the exact number of records.
355 | static char *kwlist[] = {"size", NULL};
356 | PyObject *pysize = NULL;
357 | if (!PyArg_ParseTupleAndKeywords(args, kwargs, "|O", kwlist, &pysize)) {
358 | return NULL;
359 | }
360 |
361 | if (!cursor->hasresults) {
362 | PyErr_SetString(InterfaceError, "no results available");
363 | return NULL;
364 | }
365 |
366 | long size = cursor->arraysize;
367 |
368 | if (pysize && pysize != Py_None) {
369 | size = PyLong_AsLong(pysize);
370 | if (PyErr_Occurred()) {
371 | return NULL;
372 | }
373 | }
374 |
375 | if (cursor->conn->lazy) {
376 | PyObject *results;
377 | if (!(results = PyList_New(0))) {
378 | return NULL;
379 | }
380 | for (long i = 0; i < size; ++i) {
381 | PyObject *row;
382 | if (!(row = cursor_fetchone(cursor, NULL))) {
383 | Py_DECREF(results);
384 | return NULL;
385 | }
386 | if (row == Py_None) {
387 | break;
388 | }
389 | int append_result = PyList_Append(results, row);
390 | Py_DECREF(row);
391 | if (append_result < 0) {
392 | Py_DECREF(results);
393 | connection_discard_all(cursor->conn);
394 | cursor_reset(cursor);
395 | return NULL;
396 | }
397 | }
398 | return results;
399 | }
400 |
401 | assert(cursor->rowcount >= 0);
402 |
403 | PyObject *rows;
404 | Py_ssize_t new_rowindex = cursor->rowindex + size;
405 | new_rowindex =
406 | new_rowindex < cursor->rowcount ? new_rowindex : cursor->rowcount;
407 | if (!(rows = PyList_GetSlice(cursor->rows, cursor->rowindex, new_rowindex))) {
408 | return NULL;
409 | }
410 | cursor->rowindex = new_rowindex;
411 | return rows;
412 | }
413 |
414 | // clang-format off
415 | PyDoc_STRVAR(cursor_fetchall_doc,
416 | "fetchall()\n\
417 | --\n\
418 | \n\
419 | Fetch all (remaining) rows of query results, returning them as a list of\n\
420 | tuples.\n\
421 | \n\
422 | An :exc:`InterfaceError` is raised if the previous call to :meth:`.execute()`\n\
423 | did not produce any results or no call was issued yet.");
424 | // clang-format on
425 |
426 | PyObject *cursor_fetchall(CursorObject *cursor, PyObject *args) {
427 | // Unused args.
428 | (void)args;
429 |
430 | assert(!args);
431 |
432 | if (!cursor->hasresults) {
433 | PyErr_SetString(InterfaceError, "no results available");
434 | return NULL;
435 | }
436 |
437 | if (cursor->conn->lazy) {
438 | PyObject *results;
439 | if (!(results = PyList_New(0))) {
440 | return NULL;
441 | }
442 |
443 | if (cursor->status == CURSOR_STATUS_READY) {
444 | return results;
445 | }
446 |
447 | if (cursor->status == CURSOR_STATUS_EXECUTING) {
448 | int pull_status = 0;
449 | pull_status = connection_pull(cursor->conn, 0);
450 | if (pull_status != 0) {
451 | Py_DECREF(results);
452 | cursor_reset(cursor);
453 | return NULL;
454 | }
455 | }
456 |
457 | while (1) {
458 | PyObject *row = NULL;
459 | int fetch_status = connection_fetch(cursor->conn, &row, NULL);
460 | if (fetch_status == 0) {
461 | cursor->status = CURSOR_STATUS_READY;
462 | break;
463 | } else if (fetch_status == 1) {
464 | int append_result = PyList_Append(results, row);
465 | Py_DECREF(row);
466 | if (append_result < 0) {
467 | Py_DECREF(results);
468 | connection_discard_all(cursor->conn);
469 | cursor_reset(cursor);
470 | return NULL;
471 | }
472 | } else {
473 | Py_DECREF(results);
474 | cursor_reset(cursor);
475 | return NULL;
476 | }
477 | if (!row) {
478 | Py_DECREF(results);
479 | return NULL;
480 | }
481 | }
482 |
483 | return results;
484 | }
485 |
486 | assert(cursor->rowcount >= 0);
487 |
488 | PyObject *rows;
489 | if (!(rows = PyList_GetSlice(cursor->rows, cursor->rowindex,
490 | cursor->rowcount))) {
491 | return NULL;
492 | }
493 | cursor->rowindex = cursor->rowcount;
494 | return rows;
495 | }
496 |
497 | PyDoc_STRVAR(
498 | cursor_setinputsizes_doc,
499 | "This method does nothing, but it is required by the DB-API 2.0 spec.");
500 |
501 | PyObject *cursor_setinputsizes(CursorObject *cursor, PyObject *args) {
502 | PyObject *sizes;
503 | if (!PyArg_ParseTuple(args, "O", &sizes)) {
504 | return NULL;
505 | }
506 | if (cursor->status == CURSOR_STATUS_CLOSED) {
507 | PyErr_SetString(InterfaceError, "cursor closed");
508 | return NULL;
509 | }
510 | Py_RETURN_NONE;
511 | }
512 |
513 | PyDoc_STRVAR(
514 | cursor_setoutputsizes_doc,
515 | "This method does nothing, but it is required by the DB-API 2.0 spec.");
516 |
517 | PyObject *cursor_setoutputsizes(CursorObject *cursor, PyObject *args) {
518 | long size;
519 | long column;
520 | if (!PyArg_ParseTuple(args, "l|l", &size, &column)) {
521 | return NULL;
522 | }
523 | if (cursor->status == CURSOR_STATUS_CLOSED) {
524 | PyErr_SetString(InterfaceError, "cursor closed");
525 | return NULL;
526 | }
527 | Py_RETURN_NONE;
528 | }
529 |
530 | static PyMethodDef cursor_methods[] = {
531 | {"close", (PyCFunction)cursor_close, METH_NOARGS, cursor_close_doc},
532 | {"execute", (PyCFunction)cursor_execute, METH_VARARGS, cursor_execute_doc},
533 | {"fetchone", (PyCFunction)cursor_fetchone, METH_NOARGS,
534 | cursor_fetchone_doc},
535 | {"fetchmany", (PyCFunction)cursor_fetchmany, METH_VARARGS | METH_KEYWORDS,
536 | cursor_fetchmany_doc},
537 | {"fetchall", (PyCFunction)cursor_fetchall, METH_NOARGS,
538 | cursor_fetchall_doc},
539 | {"setinputsizes", (PyCFunction)cursor_setinputsizes, METH_VARARGS,
540 | cursor_setinputsizes_doc},
541 | {"setoutputsizes", (PyCFunction)cursor_setoutputsizes, METH_VARARGS,
542 | cursor_setoutputsizes_doc},
543 | {NULL}};
544 |
545 | // clang-format off
546 | PyDoc_STRVAR(CursorType_rowcount_doc,
547 | "This read-only attribute specifies the number of rows that the last\n\
548 | :meth:`.execute()` produced.\n\
549 | \n\
550 | The attribute is -1 in case no :meth:`.execute()` has been performed or\n\
551 | the rowcount of the last operation cannot be determined by the interface.");
552 |
553 | PyDoc_STRVAR(CursorType_arraysize_doc,
554 | "This read/write attribute specifies the number of rows to fetch at a time\n\
555 | with :meth:`.fetchmany()`. It defaults to 1 meaning to fetch a single row at\n\
556 | a time.");
557 |
558 | PyDoc_STRVAR(CursorType_description_doc,
559 | "This read-only attribute is a list of :class:`Column` objects.\n\
560 | \n\
561 | Each of those object has attributed describing one result column:\n\
562 | \n\
563 | - :attr:`.name`\n\
564 | - :attr:`.type_code`\n\
565 | - :attr:`.display_size`\n\
566 | - :attr:`.internal_size`\n\
567 | - :attr:`.precision`\n\
568 | - :attr:`.scale`\n\
569 | - :attr:`.null_ok`\n\
570 | \n\
571 | Only the name attribute is set to the name of column returned by the\n\
572 | database. The rest are always set to ``None`` and are only here for\n\
573 | compatibility with DB-API 2.0.\n\
574 | \n\
575 | This attribute will be ``None`` for operations that do not return rows\n\
576 | or if the cursor has not had an operation invoked via the :meth:`.execute()`\n\
577 | method yet.");
578 | // clang-format on
579 |
580 | static PyMemberDef cursor_members[] = {
581 | {"rowcount", T_PYSSIZET, offsetof(CursorObject, rowcount), READONLY,
582 | CursorType_rowcount_doc},
583 | {"arraysize", T_LONG, offsetof(CursorObject, arraysize), 0,
584 | CursorType_arraysize_doc},
585 | {"description", T_OBJECT, offsetof(CursorObject, description), READONLY,
586 | CursorType_description_doc},
587 | {NULL}};
588 |
589 | // clang-format off
590 | PyDoc_STRVAR(cursor_doc,
591 | "Allows execution of database commands.\n\
592 | \n\
593 | Cursors are created by the :meth:`Connection.cursor()` method and they are\n\
594 | bound to the connection for the entire lifetime. Cursors created by the same\n\
595 | connection are not isolated, any changes done to the database by one cursor\n\
596 | are immediately visible by the other cursors.\n\
597 | \n\
598 | Cursor objects are not thread-safe.");
599 | // clang-format on
600 |
601 | // clang-format off
602 | PyTypeObject CursorType = {
603 | PyVarObject_HEAD_INIT(NULL, 0)
604 | .tp_name = "mgclient.Cursor",
605 | .tp_basicsize = sizeof(CursorObject),
606 | .tp_itemsize = 0,
607 | .tp_dealloc = (destructor)cursor_dealloc,
608 | .tp_flags = Py_TPFLAGS_DEFAULT,
609 | .tp_doc = cursor_doc,
610 | .tp_methods = cursor_methods,
611 | .tp_members = cursor_members,
612 | .tp_init = (initproc)cursor_init,
613 | .tp_new = (newfunc)cursor_new
614 | };
615 | // clang-format on
616 |
--------------------------------------------------------------------------------
/test/test_cursor.py:
--------------------------------------------------------------------------------
1 | # Copyright (c) 2016-2020 Memgraph Ltd. [https://memgraph.com]
2 | #
3 | # Licensed under the Apache License, Version 2.0 (the "License");
4 | # you may not use this file except in compliance with the License.
5 | # You may obtain a copy of the License at
6 | #
7 | # http://www.apache.org/licenses/LICENSE-2.0
8 | #
9 | # Unless required by applicable law or agreed to in writing, software
10 | # distributed under the License is distributed on an "AS IS" BASIS,
11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | # See the License for the specific language governing permissions and
13 | # limitations under the License.
14 |
15 | import sys
16 | import mgclient
17 | import pytest
18 |
19 | from common import start_memgraph, Memgraph
20 |
21 |
22 | @pytest.fixture(scope="function")
23 | def memgraph_server():
24 | memgraph = start_memgraph()
25 | yield memgraph.host, memgraph.port, memgraph.sslmode(), memgraph.is_long_running
26 |
27 | memgraph.terminate()
28 |
29 |
30 | def test_cursor_visibility(memgraph_server):
31 | host, port, sslmode, is_long_running = memgraph_server
32 | conn = mgclient.connect(host=host, port=port, sslmode=sslmode)
33 |
34 | cursor1 = conn.cursor()
35 | cursor1.execute("MATCH (n) RETURN count(n)")
36 | original_count = cursor1.fetchall()[0][0]
37 | assert is_long_running or original_count == 0
38 |
39 | cursor1.execute("CREATE (:Node)")
40 |
41 | cursor2 = conn.cursor()
42 | cursor2.execute("MATCH (n) RETURN count(n)")
43 | assert cursor2.fetchall() == [(original_count + 1,)]
44 |
45 |
46 | class TestCursorInRegularConnection:
47 | def test_execute_closed_connection(self, memgraph_server):
48 | host, port, sslmode, _ = memgraph_server
49 | conn = mgclient.connect(host=host, port=port, sslmode=sslmode)
50 |
51 | cursor = conn.cursor()
52 | conn.close()
53 |
54 | with pytest.raises(mgclient.InterfaceError):
55 | cursor.execute("RETURN 100")
56 |
57 | def test_cursor_close(self, memgraph_server):
58 | host, port, sslmode, _ = memgraph_server
59 | conn = mgclient.connect(host=host, port=port, sslmode=sslmode)
60 |
61 | cursor = conn.cursor()
62 | cursor.execute("UNWIND range(1, 10) AS n RETURN n")
63 |
64 | cursor.close()
65 |
66 | # closing again does nothing
67 | cursor.close()
68 |
69 | with pytest.raises(mgclient.InterfaceError):
70 | cursor.fetchone()
71 |
72 | with pytest.raises(mgclient.InterfaceError):
73 | cursor.execute("RETURN 100")
74 |
75 | with pytest.raises(mgclient.InterfaceError):
76 | cursor.fetchmany()
77 |
78 | with pytest.raises(mgclient.InterfaceError):
79 | cursor.fetchall()
80 |
81 | with pytest.raises(mgclient.InterfaceError):
82 | cursor.setinputsizes([])
83 |
84 | with pytest.raises(mgclient.InterfaceError):
85 | cursor.setoutputsizes(100)
86 |
87 | def test_cursor_fetchone(self, memgraph_server):
88 | host, port, sslmode, _ = memgraph_server
89 | conn = mgclient.connect(host=host, port=port, sslmode=sslmode)
90 |
91 | cursor = conn.cursor()
92 |
93 | with pytest.raises(mgclient.InterfaceError):
94 | cursor.fetchone()
95 |
96 | cursor.execute("UNWIND range(1, 10) AS n RETURN n")
97 |
98 | for n in range(1, 11):
99 | assert cursor.fetchone() == (n,)
100 |
101 | assert cursor.fetchone() is None
102 | assert cursor.fetchone() is None
103 |
104 | cursor.execute("RETURN 100")
105 | assert cursor.fetchone() == (100,)
106 | assert cursor.fetchone() is None
107 |
108 | def test_cursor_fetchmany(self, memgraph_server):
109 | host, port, sslmode, _ = memgraph_server
110 | conn = mgclient.connect(host=host, port=port, sslmode=sslmode)
111 |
112 | cursor = conn.cursor()
113 |
114 | with pytest.raises(mgclient.InterfaceError):
115 | cursor.fetchmany()
116 |
117 | cursor.execute("UNWIND range(1, 10) AS n RETURN n")
118 |
119 | with pytest.raises(OverflowError):
120 | cursor.fetchmany(10 ** 100)
121 |
122 | assert cursor.fetchmany() == [(1,)]
123 |
124 | cursor.arraysize = 4
125 |
126 | assert cursor.fetchmany() == [(2,), (3,), (4,), (5,)]
127 | assert cursor.fetchmany() == [(6,), (7,), (8,), (9,)]
128 | assert cursor.fetchmany() == [(10,)]
129 | assert cursor.fetchmany() == []
130 | assert cursor.fetchone() is None
131 |
132 | cursor.execute("RETURN 100")
133 | assert cursor.fetchmany() == [(100,)]
134 | assert cursor.fetchmany() == []
135 | assert cursor.fetchone() is None
136 |
137 | def test_cursor_fetchall(self, memgraph_server):
138 | host, port, sslmode, _ = memgraph_server
139 | conn = mgclient.connect(host=host, port=port, sslmode=sslmode)
140 |
141 | cursor = conn.cursor()
142 |
143 | with pytest.raises(mgclient.InterfaceError):
144 | cursor.fetchall()
145 |
146 | cursor.execute("UNWIND range(1, 10) AS n RETURN n")
147 |
148 | assert cursor.fetchall() == [(n,) for n in range(1, 11)]
149 | assert cursor.fetchall() == []
150 | assert cursor.fetchone() is None
151 |
152 | cursor.execute("RETURN 100")
153 |
154 | assert cursor.fetchall() == [(100,)]
155 | assert cursor.fetchall() == []
156 | assert cursor.fetchone() is None
157 |
158 | def test_cursor_multiple_queries(self, memgraph_server):
159 | host, port, sslmode, _ = memgraph_server
160 | conn = mgclient.connect(host=host, port=port, sslmode=sslmode)
161 |
162 | cursor1 = conn.cursor()
163 | cursor2 = conn.cursor()
164 |
165 | cursor1.execute("UNWIND range(1, 10) AS n RETURN n")
166 | cursor2.execute("UNWIND range(1, 10) AS n RETURN n")
167 |
168 | for n in range(1, 11):
169 | assert cursor1.fetchone() == (n,)
170 | assert cursor2.fetchone() == (n,)
171 |
172 | def test_cursor_syntax_error(self, memgraph_server):
173 | host, port, sslmode, _ = memgraph_server
174 | conn = mgclient.connect(host=host, port=port, sslmode=sslmode)
175 | cursor = conn.cursor()
176 |
177 | cursor.execute("RETURN 100")
178 |
179 | with pytest.raises(mgclient.DatabaseError):
180 | cursor.execute("fjdkalfjdsalfaj")
181 |
182 | with pytest.raises(mgclient.InterfaceError):
183 | cursor.fetchall()
184 |
185 | def test_cursor_runtime_error(self, memgraph_server):
186 | host, port, sslmode, _ = memgraph_server
187 | conn = mgclient.connect(host=host, port=port, sslmode=sslmode)
188 | cursor = conn.cursor()
189 |
190 | cursor.execute("RETURN 100")
191 |
192 | with pytest.raises(mgclient.DatabaseError):
193 | cursor.execute("UNWIND [true, true, false] AS p RETURN assert(p)")
194 | cursor.fetchall()
195 |
196 | cursor.execute("RETURN 200")
197 |
198 | assert cursor.fetchall() == [(200,)]
199 |
200 | def test_cursor_description(self, memgraph_server):
201 | host, port, sslmode, _ = memgraph_server
202 | conn = mgclient.connect(host=host, port=port, sslmode=sslmode)
203 | cursor = conn.cursor()
204 |
205 | cursor.execute("RETURN 5 AS x, 6 AS y")
206 | assert len(cursor.description) == 2
207 | assert cursor.description[0].name == "x"
208 | assert cursor.description[1].name == "y"
209 |
210 | with pytest.raises(mgclient.DatabaseError):
211 | cursor.execute("jdfklfjkdalfja")
212 |
213 | assert cursor.description is None
214 |
215 | def test_cursor_fetchone_without_result(self, memgraph_server):
216 | host, port, sslmode, _ = memgraph_server
217 | conn = mgclient.connect(host=host, port=port, sslmode=sslmode)
218 | cursor = conn.cursor()
219 |
220 | cursor.execute("MATCH (n:NonExistingLabel) RETURN n")
221 | result = cursor.fetchone()
222 | assert result is None
223 |
224 | def test_cursor_fetchmany_without_result(self, memgraph_server):
225 | host, port, sslmode, _ = memgraph_server
226 | conn = mgclient.connect(host=host, port=port, sslmode=sslmode)
227 | cursor = conn.cursor()
228 |
229 | cursor.execute("MATCH (n:NonExistingLabel) RETURN n")
230 | assert cursor.fetchmany() == []
231 |
232 | def test_cursor_result_ref_counts(self, memgraph_server):
233 | host, port, sslmode, _ = memgraph_server
234 | conn = mgclient.connect(host=host, port=port, sslmode=sslmode)
235 | cursor = conn.cursor()
236 |
237 | cursor.execute("UNWIND [1, 2, 3, 4, 5] AS n RETURN n")
238 |
239 | fetchone_result = cursor.fetchone()
240 | # Refs are the following:
241 | # 1. fetchone_result
242 | # 2. temp reference in sys.getrefcount
243 | # 3. cursor->rows
244 | assert sys.getrefcount(fetchone_result) == 3
245 |
246 | fetchmany_result = cursor.fetchmany(2)
247 | # Refs are the following:
248 | # 1. fetchmany_result
249 | # 2. temp reference in sys.getrefcount
250 | assert sys.getrefcount(fetchmany_result) == 2
251 | row1 = fetchmany_result[0]
252 | row2 = fetchmany_result[1]
253 | del fetchmany_result
254 | # Refs are the following:
255 | # 1. row{1,2}
256 | # 2. temp reference in sys.getrefcount
257 | # 3. cursor->rows
258 | assert sys.getrefcount(row1) == 3
259 | assert sys.getrefcount(row2) == 3
260 |
261 | fetchall_result = cursor.fetchall()
262 | # Refs are the following:
263 | # 1. fetchall_result
264 | # 2. temp reference in sys.getrefcount
265 | assert sys.getrefcount(fetchall_result) == 2
266 | row1 = fetchall_result[0]
267 | row2 = fetchall_result[1]
268 | del fetchall_result
269 | # Refs are the following:
270 | # 1. row{1,2}
271 | # 2. temp reference in sys.getrefcount
272 | # 3. cursor->rows
273 | assert sys.getrefcount(row1) == 3
274 | assert sys.getrefcount(row2) == 3
275 |
276 |
277 | class TestCursorInAsyncConnection:
278 | def test_cursor_close(self, memgraph_server):
279 | host, port, sslmode, _ = memgraph_server
280 | conn = mgclient.connect(host=host, port=port, lazy=True, sslmode=sslmode)
281 |
282 | cursor = conn.cursor()
283 | cursor.execute("UNWIND range(1, 10) AS n RETURN n")
284 |
285 | cursor2 = conn.cursor()
286 |
287 | with pytest.raises(mgclient.InterfaceError):
288 | cursor.close()
289 |
290 | cursor2.close()
291 |
292 | # NOTE: This here is a bit strange again because of double fetch /
293 | # server ahead of time pull because of the need for has_more info. As
294 | # soon as the last record is returned, the cursor will become
295 | # closeable.
296 | assert cursor.fetchmany(9) == [(n,) for n in range(1, 10)]
297 | with pytest.raises(mgclient.InterfaceError):
298 | cursor.close()
299 | assert cursor.fetchone() == (10,)
300 | assert cursor.fetchone() is None
301 |
302 | cursor.close()
303 |
304 | # closing again does nothing
305 | cursor.close()
306 |
307 | with pytest.raises(mgclient.InterfaceError):
308 | cursor.fetchone()
309 |
310 | with pytest.raises(mgclient.InterfaceError):
311 | cursor.execute("RETURN 100")
312 |
313 | with pytest.raises(mgclient.InterfaceError):
314 | cursor.fetchmany()
315 |
316 | with pytest.raises(mgclient.InterfaceError):
317 | cursor.fetchall()
318 |
319 | with pytest.raises(mgclient.InterfaceError):
320 | cursor.setinputsizes([])
321 |
322 | with pytest.raises(mgclient.InterfaceError):
323 | cursor.setoutputsizes(100)
324 |
325 | def test_cursor_multiple_queries(self, memgraph_server):
326 | host, port, sslmode, _ = memgraph_server
327 | conn = mgclient.connect(host=host, port=port, lazy=True, sslmode=sslmode)
328 |
329 | cursor1 = conn.cursor()
330 | cursor2 = conn.cursor()
331 |
332 | cursor1.execute("UNWIND range(1, 10) AS n RETURN n")
333 |
334 | with pytest.raises(mgclient.InterfaceError):
335 | cursor2.execute("UNWIND range(1, 10) AS n RETURN n")
336 |
337 | assert cursor1.fetchall() == [(n,) for n in range(1, 11)]
338 |
339 | with pytest.raises(mgclient.InterfaceError):
340 | cursor2.fetchall()
341 |
342 | def test_cursor_fetchone(self, memgraph_server):
343 | host, port, sslmode, _ = memgraph_server
344 | conn = mgclient.connect(host=host, port=port, lazy=True, sslmode=sslmode)
345 |
346 | cursor = conn.cursor()
347 |
348 | with pytest.raises(mgclient.InterfaceError):
349 | cursor.fetchone()
350 |
351 | cursor.execute("UNWIND range(1, 10) AS n RETURN n")
352 |
353 | for n in range(1, 11):
354 | assert cursor.fetchone() == (n,)
355 |
356 | assert cursor.fetchone() is None
357 | assert cursor.fetchone() is None
358 |
359 | cursor.execute("RETURN 100")
360 | assert cursor.fetchone() == (100,)
361 | assert cursor.fetchone() is None
362 |
363 | def test_cursor_fetchmany(self, memgraph_server):
364 | host, port, sslmode, _ = memgraph_server
365 | conn = mgclient.connect(host=host, port=port, lazy=True, sslmode=sslmode)
366 |
367 | cursor = conn.cursor()
368 |
369 | with pytest.raises(mgclient.InterfaceError):
370 | cursor.fetchmany()
371 |
372 | cursor.execute("UNWIND range(1, 10) AS n RETURN n")
373 |
374 | with pytest.raises(OverflowError):
375 | cursor.fetchmany(10 ** 100)
376 |
377 | assert cursor.fetchmany() == [(1,)]
378 |
379 | cursor.arraysize = 4
380 |
381 | assert cursor.fetchmany() == [(2,), (3,), (4,), (5,)]
382 | assert cursor.fetchmany() == [(6,), (7,), (8,), (9,)]
383 | assert cursor.fetchmany() == [(10,)]
384 | assert cursor.fetchmany() == []
385 | assert cursor.fetchone() is None
386 |
387 | cursor.execute("RETURN 100")
388 | assert cursor.fetchmany() == [(100,)]
389 | assert cursor.fetchmany() == []
390 | assert cursor.fetchone() is None
391 |
392 | def test_cursor_fetchall(self, memgraph_server):
393 | host, port, sslmode, _ = memgraph_server
394 | conn = mgclient.connect(host=host, port=port, lazy=True, sslmode=sslmode)
395 |
396 | cursor = conn.cursor()
397 |
398 | with pytest.raises(mgclient.InterfaceError):
399 | cursor.fetchall()
400 |
401 | cursor.execute("UNWIND range(1, 10) AS n RETURN n")
402 |
403 | assert cursor.fetchall() == [(n,) for n in range(1, 11)]
404 | assert cursor.fetchall() == []
405 | assert cursor.fetchone() is None
406 |
407 | cursor.execute("RETURN 100")
408 |
409 | assert cursor.fetchall() == [(100,)]
410 | assert cursor.fetchall() == []
411 | assert cursor.fetchone() is None
412 |
413 | def test_cursor_syntax_error(self, memgraph_server):
414 | host, port, sslmode, _ = memgraph_server
415 | conn = mgclient.connect(host=host, port=port, lazy=True, sslmode=sslmode)
416 | cursor = conn.cursor()
417 |
418 | cursor.execute("RETURN 100")
419 | cursor.fetchall()
420 |
421 | with pytest.raises(mgclient.DatabaseError):
422 | cursor.execute("fjdkalfjdsalfaj")
423 |
424 | with pytest.raises(mgclient.InterfaceError):
425 | cursor.fetchall()
426 |
427 | def test_cursor_runtime_error(self, memgraph_server):
428 | host, port, sslmode, _ = memgraph_server
429 | conn = mgclient.connect(host=host, port=port, lazy=True, sslmode=sslmode)
430 | cursor = conn.cursor()
431 |
432 | cursor.execute("RETURN 100")
433 | assert cursor.fetchall() == [(100,)]
434 |
435 | cursor.execute("UNWIND [true, true, false] AS p RETURN assert(p)")
436 | with pytest.raises(mgclient.DatabaseError):
437 | assert cursor.fetchone() == (True,)
438 | # NOTE: The exception is going to happen here which is unexpected.
439 | # The reason for that is because server pulls one more result ahead
440 | # of time to know are there more results.
441 | assert cursor.fetchone() == (True,) # <- HERE
442 | cursor.fetchone()
443 |
444 | cursor.execute("UNWIND [true, true, false] AS p RETURN assert(p)")
445 |
446 | with pytest.raises(mgclient.DatabaseError):
447 | cursor.fetchmany(5)
448 |
449 | cursor.execute("UNWIND [true, true, false] AS p RETURN assert(p)")
450 |
451 | with pytest.raises(mgclient.DatabaseError):
452 | cursor.fetchall()
453 |
454 | def test_cursor_description(self, memgraph_server):
455 | host, port, sslmode, _ = memgraph_server
456 | conn = mgclient.connect(host=host, port=port, lazy=True, sslmode=sslmode)
457 | cursor = conn.cursor()
458 |
459 | cursor.execute("RETURN 5 AS x, 6 AS y")
460 | assert len(cursor.description) == 2
461 | assert cursor.description[0].name == "x"
462 | assert cursor.description[1].name == "y"
463 |
464 | cursor.fetchone()
465 | assert len(cursor.description) == 2
466 | assert cursor.description[0].name == "x"
467 | assert cursor.description[1].name == "y"
468 |
469 | cursor.fetchone()
470 |
471 | with pytest.raises(mgclient.DatabaseError):
472 | cursor.execute("jdfklfjkdalfja")
473 |
474 | assert cursor.description is None
475 |
476 | def test_cursor_fetchone_without_result(self, memgraph_server):
477 | host, port, sslmode, _ = memgraph_server
478 | conn = mgclient.connect(host=host, port=port, lazy=True, sslmode=sslmode)
479 | cursor = conn.cursor()
480 |
481 | cursor.execute("MATCH (n:NonExistingLabel) RETURN n")
482 | result = cursor.fetchone()
483 | assert result is None
484 |
485 | def test_cursor_fetchmany_without_result(self, memgraph_server):
486 | host, port, sslmode, _ = memgraph_server
487 | conn = mgclient.connect(host=host, port=port, lazy=True, sslmode=sslmode)
488 | cursor = conn.cursor()
489 |
490 | cursor.execute("MATCH (n:NonExistingLabel) RETURN n")
491 | assert cursor.fetchmany() == []
492 |
493 | def test_cursor_result_ref_counts(self, memgraph_server):
494 | host, port, sslmode, _ = memgraph_server
495 | conn = mgclient.connect(host=host, port=port, lazy=True, sslmode=sslmode)
496 | cursor = conn.cursor()
497 |
498 | cursor.execute("UNWIND [1, 2, 3, 4, 5] AS n RETURN n")
499 |
500 | fetchone_result = cursor.fetchone()
501 | # Refs are the following:
502 | # 1. fetchone_result
503 | # 2. temp reference in sys.getrefcount
504 | assert sys.getrefcount(fetchone_result) == 2
505 |
506 | fetchmany_result = cursor.fetchmany(2)
507 | # Refs are the following:
508 | # 1. fetchmany_result
509 | # 2. temp reference in sys.getrefcount
510 | assert sys.getrefcount(fetchmany_result) == 2
511 | row1 = fetchmany_result[0]
512 | row2 = fetchmany_result[1]
513 | del fetchmany_result
514 | # Refs are the following:
515 | # 1. row{1,2}
516 | # 2. temp reference in sys.getrefcount
517 | assert sys.getrefcount(row1) == 2
518 | assert sys.getrefcount(row2) == 2
519 |
520 | fetchall_result = cursor.fetchall()
521 | # Refs are the following:
522 | # 1. fetchall_result
523 | # 2. temp reference in sys.getrefcount
524 | assert sys.getrefcount(fetchall_result) == 2
525 | row1 = fetchall_result[0]
526 | row2 = fetchall_result[1]
527 | del fetchall_result
528 | # Refs are the following:
529 | # 1. row{1,2}
530 | # 2. temp reference in sys.getrefcount
531 | assert sys.getrefcount(row1) == 2
532 | assert sys.getrefcount(row2) == 2
533 |
--------------------------------------------------------------------------------