From 9319e7e8537167854cb220964241c8d1af6f1b4b Mon Sep 17 00:00:00 2001 From: RagnarW Date: Fri, 4 Mar 2016 15:20:12 +0100 Subject: [PATCH 01/12] added result api steps --- test/tck/steps/driver_result_api_steps.py | 133 ++++++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 test/tck/steps/driver_result_api_steps.py diff --git a/test/tck/steps/driver_result_api_steps.py b/test/tck/steps/driver_result_api_steps.py new file mode 100644 index 00000000..d61e5331 --- /dev/null +++ b/test/tck/steps/driver_result_api_steps.py @@ -0,0 +1,133 @@ +from behave import * + +from neo4j.v1 import ResultSummary, STATEMENT_TYPE_READ_ONLY, STATEMENT_TYPE_READ_WRITE, STATEMENT_TYPE_WRITE_ONLY, \ + STATEMENT_TYPE_SCHEMA_WRITE + +from test.tck.resultparser import parse_values + +use_step_matcher("re") + + +@step("the `Result Cursor` is summarized") +def step_impl(context): + context.summaries = [] + for rc in context.rcs: + context.summaries.append(rc.summarize()) + + +@then("the `Result Cursor` is fully consumed") +def step_impl(context): + for rc in context.rcs: + assert rc.at_end() + assert rc.record() is None + + +@then("a `Result Summary` is returned") +def step_impl(context): + for summary in context.summaries: + assert isinstance(summary, ResultSummary) + + +@step("I request a `statement` from the `Result Summary`") +def step_impl(context): + context.statements = [] + for summary in context.summaries: + context.statements.append(summary.statement) + + +@then("requesting the `Statement` as text should give: (?P.+)") +def step_impl(context, expected): + for statement in context.statements: + assert statement == expected + + +@step("requesting the `Statement` parameter should give: (?P.+)") +def step_impl(context, expected): + for summary in context.summaries: + assert summary.parameters == parse_values(expected) + + +@step("requesting `update statistics` from it should give") +def step_impl(context): + for summary in context.summaries: + for row in context.table: + assert getattr(summary.statistics, row[0].replace(" ","_")) == parse_values(row[1]) + + +@step("requesting the `Statement Type` should give (?P.+)") +def step_impl(context, expected): + for summary in context.summaries: + if expected == "read only": + statement_type = STATEMENT_TYPE_READ_ONLY + elif expected == "read write": + statement_type = STATEMENT_TYPE_READ_WRITE + elif expected == "write only": + statement_type = STATEMENT_TYPE_WRITE_ONLY + elif expected == "schema write": + statement_type = STATEMENT_TYPE_SCHEMA_WRITE + else: + raise ValueError("Not recognisable statement type: %s" % expected) + assert summary.statement_type == statement_type + + +@step("the summary has a `plan`") +def step_impl(context): + for summary in context.summaries: + assert summary.plan is not None + + +@step("the summary has a `profile`") +def step_impl(context): + for summary in context.summaries: + assert summary.profile is not None + + +@step("the summary does not have a `plan`") +def step_impl(context): + for summary in context.summaries: + assert summary.plan is None + + +@step("the summary does not have a `profile`") +def step_impl(context): + for summary in context.summaries: + assert summary.profile is None + + +@step("requesting the `(?P.+)` it contains") +def step_impl(context, plan_type): + for summary in context.summaries: + if plan_type == "plan": + plan = summary.plan + elif plan_type == "profile": + plan = summary.profile + else: + raise ValueError("Expected 'plan' or 'profile'. Got: %s" % plan_type) + for row in context.table: + assert getattr(plan, row[0].replace(" ", "_")) == parse_values(row[1]) + + +@step("the `(?P.+)` also contains method calls for") +def step_impl(context, plan_type): + for summary in context.summaries: + if plan_type == "plan": + plan = summary.plan + elif plan_type == "profile": + plan = summary.profile + else: + raise ValueError("Expected 'plan' or 'profile'. Got: %s" % plan_type) + for row in context.table: + assert getattr(plan, row[0].replace(" ", "_")) is not None + + +@step("the summaries collection of `notifications` is empty") +def step_impl(context): + for summary in context.summaries: + assert len(summary.notifications) == 0 + + +@step("the summaries collection of `notifications` is not empty") +def step_impl(context): + for summary in context.summaries: + print(summary.notifications) + assert len(summary.notifications) != 0 From e0c70ccd4f165a79a99269cbbe360781117fbe80 Mon Sep 17 00:00:00 2001 From: RagnarW Date: Fri, 4 Mar 2016 15:20:40 +0100 Subject: [PATCH 02/12] added equality steps according to current definition --- test/tck/steps/driver_equality_steps.py | 53 +++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 test/tck/steps/driver_equality_steps.py diff --git a/test/tck/steps/driver_equality_steps.py b/test/tck/steps/driver_equality_steps.py new file mode 100644 index 00000000..6e57ad63 --- /dev/null +++ b/test/tck/steps/driver_equality_steps.py @@ -0,0 +1,53 @@ +from collections import deque + +from behave import * + +from neo4j.v1 import Path, Relationship +from test.tck.tck_util import send_string + +use_step_matcher("re") + + +@step("`(?P.+)` is single value result of: (?P.+)") +def step_impl(context, key, statement): + records = list(send_string(statement).stream()) + assert len(records) == 1 + assert len(records[0]) == 1 + context.values[key] = records[0][0] + + +@step("`(?P.+)` is a copy of `(?P.+)` path with flipped relationship direction") +def step_impl(context, key1, key2): + path = context.values[key2] + nodes = path.nodes + new_relationships = [] + for r in path.relationships: + start = r.end + end = r.start + tmp_r = Relationship(start, end, r.type, r.properties) + tmp_r.identity = r.identity + new_relationships.append(tmp_r) + entities = [nodes[0]] + for i in range(1,len(nodes)): + entities.append(new_relationships[i-1]) + entities.append(nodes[i]) + + context.values[key1] = Path(*entities) + + +@step("saved values should all equal") +def step_impl(context): + values = context.values.values() + assert len(values) > 1 + first_val = values.pop() + for item in values: + assert item == first_val + + +@step("none of the saved values should be equal") +def step_impl(context): + values = context.values.values() + assert len(values) > 1 + first_val = values.pop() + for item in values: + assert item != first_val From 9e90609fdc960164a4d00023eb7115b51ac19c76 Mon Sep 17 00:00:00 2001 From: RagnarW Date: Fri, 4 Mar 2016 15:21:20 +0100 Subject: [PATCH 03/12] Fixed correct string representation of schema write --- neo4j/v1/session.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/neo4j/v1/session.py b/neo4j/v1/session.py index de5fc0fe..fc3506a2 100644 --- a/neo4j/v1/session.py +++ b/neo4j/v1/session.py @@ -41,7 +41,7 @@ class which can be used to obtain `Driver` instances that are used for STATEMENT_TYPE_READ_ONLY = "r" STATEMENT_TYPE_READ_WRITE = "rw" STATEMENT_TYPE_WRITE_ONLY = "w" -STATEMENT_TYPE_SCHEMA_WRITE = "sw" +STATEMENT_TYPE_SCHEMA_WRITE = "s" class GraphDatabase(object): From 4d5cc907d193e2d4e9fd70362ac6cf90eba0089f Mon Sep 17 00:00:00 2001 From: RagnarW Date: Fri, 4 Mar 2016 15:21:47 +0100 Subject: [PATCH 04/12] Improved output when scenarios fail --- test/tck/environment.py | 29 ++++++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/test/tck/environment.py b/test/tck/environment.py index 222b71eb..37852084 100644 --- a/test/tck/environment.py +++ b/test/tck/environment.py @@ -20,6 +20,8 @@ from test.tck import tck_util +failing_features = {} + def before_all(context): context.config.setup_logging() @@ -27,11 +29,32 @@ def before_all(context): def before_feature(context, feature): # Workaround. Behave has a different way of tagging than cucumber - if "reset_database" in feature.tags: - for scenario in feature.scenarios: - scenario.tags.append("reset_database") + for scenario in feature.scenarios: + scenario.tags += feature.tags def before_scenario(context, scenario): if "reset_database" in scenario.tags: tck_util.send_string("MATCH (n) DETACH DELETE n") + if "equality_test" in scenario.tags: + context.values = {} + + +def after_feature(context, feature): + failed_scenarios = [] + for scenario in feature.scenarios: + if scenario.status != "passed": + failed_scenarios.append(scenario.name) + if len(failed_scenarios) > 0: + failing_features[feature.name] = failed_scenarios + + +def after_all(context): + if len(failing_features) != 0: + print("Following Features failed in TCK:") + for feature, list_of_scenarios in failing_features.items(): + print("Feature: %s" %feature) + for scenario in list_of_scenarios: + print("Failing scenario: %s" % scenario) + raise Exception("TCK FAILED!") + From 1cb7c2c6270d745e6fbcf5f69cbaeecff28b93af Mon Sep 17 00:00:00 2001 From: RagnarW Date: Fri, 4 Mar 2016 15:23:16 +0100 Subject: [PATCH 05/12] updated stepfiles to work with new result cursor --- test/tck/steps/bolt_compability_steps.py | 7 +++--- test/tck/steps/cypher_compability_steps.py | 28 ++++++++++++---------- test/tck/steps/driver_result_api_steps.py | 20 ++++++++++++---- test/tck/tck_util.py | 9 +++---- 4 files changed, 37 insertions(+), 27 deletions(-) diff --git a/test/tck/steps/bolt_compability_steps.py b/test/tck/steps/bolt_compability_steps.py index ea2b47f6..5a609bf6 100644 --- a/test/tck/steps/bolt_compability_steps.py +++ b/test/tck/steps/bolt_compability_steps.py @@ -80,15 +80,14 @@ def step_impl(context, size, type): @when("the driver asks the server to echo this (?P.+) back") def step_impl(context, unused): - context.results = {"as_string": send_string("RETURN " + as_cypher_text(context.expected)), - "as_parameters": send_parameters("RETURN {input}", {'input': context.expected})} + context.results = [ send_string("RETURN " + as_cypher_text(context.expected)).stream(), send_parameters("RETURN {input}", {'input': context.expected}).stream()] @step("the value given in the result should be the same as what was sent") def step_impl(context): assert len(context.results) > 0 - for result in context.results.values(): - result_value = result[0].values()[0] + for result in context.results: + result_value = result.next().values()[0] assert result_value == context.expected diff --git a/test/tck/steps/cypher_compability_steps.py b/test/tck/steps/cypher_compability_steps.py index db21f58e..db1442e8 100644 --- a/test/tck/steps/cypher_compability_steps.py +++ b/test/tck/steps/cypher_compability_steps.py @@ -31,28 +31,30 @@ def step_impl(context, statement): send_string(statement) -@when("running: (?P.+)") +@step("running: (?P.+)") def step_impl(context, statement): - context.results = {"as_string": send_string(statement)} + context.rcs = [send_string(statement)] + context.results = [x.stream() for x in context.rcs] -@then("result") -def step_impl(context): - result = context.results["as_string"] - given = driver_result_to_comparable_result(result) - expected = table_to_comparable_result(context.table) - if not unordered_equal(given, expected): - raise Exception("Does not match given: \n%s expected: \n%s" % (given, expected)) - - -@when('running parametrized: (?P.+)') +@step('running parametrized: (?P.+)') def step_impl(context, statement): assert len(context.table.rows) == 1 keys = context.table.headings values = context.table.rows[0] parameters = {keys[i]: parse_values(values[i]) for i in range(len(keys))} - context.results = {"as_string": send_parameters(statement, parameters)} + context.rcs = [send_parameters(statement, parameters)] + context.results = [x.stream() for x in context.rcs] + + +@then("result") +def step_impl(context): + expected = table_to_comparable_result(context.table) + for result in context.results: + given = driver_result_to_comparable_result(result) + if not unordered_equal(given, expected): + raise Exception("Does not match given: \n%s expected: \n%s" % (given, expected)) def _driver_value_to_comparable(val): diff --git a/test/tck/steps/driver_result_api_steps.py b/test/tck/steps/driver_result_api_steps.py index d61e5331..82952937 100644 --- a/test/tck/steps/driver_result_api_steps.py +++ b/test/tck/steps/driver_result_api_steps.py @@ -120,14 +120,26 @@ def step_impl(context, plan_type): assert getattr(plan, row[0].replace(" ", "_")) is not None -@step("the summaries collection of `notifications` is empty") +@step("the summaries `notifications` is empty list") def step_impl(context): for summary in context.summaries: assert len(summary.notifications) == 0 -@step("the summaries collection of `notifications` is not empty") +@step("the summaries `notifications` has one notification with") def step_impl(context): + for summary in context.summaries: - print(summary.notifications) - assert len(summary.notifications) != 0 + assert len(summary.notifications) == 1 + notification = summary.notifications[0] + for row in context.table: + if row[0] == 'position': + position = getattr(notification, row[0].replace(" ","_")) + expected_position = parse_values(row[1]) + for position_key, value in expected_position.items(): + assert value == getattr(position, position_key.replace(" ", "_")) + else: + assert getattr(notification, row[0].replace(" ","_")) == parse_values(row[1]) + + + diff --git a/test/tck/tck_util.py b/test/tck/tck_util.py index d05913f7..9c7b4e0f 100644 --- a/test/tck/tck_util.py +++ b/test/tck/tck_util.py @@ -23,20 +23,17 @@ from neo4j.v1 import GraphDatabase driver = GraphDatabase.driver("bolt://localhost") +session = driver.session() def send_string(text): - session = driver.session() cursor = session.run(text) - session.close() - return list(cursor.stream()) + return cursor def send_parameters(statement, parameters): - session = driver.session() cursor = session.run(statement, parameters) - session.close() - return list(cursor.stream()) + return cursor def to_unicode(val): From aef9bd4493fc821edf13affc72c49ca5bd32cfa2 Mon Sep 17 00:00:00 2001 From: RagnarW Date: Fri, 4 Mar 2016 15:23:39 +0100 Subject: [PATCH 06/12] Temporarily run in dev files to highlight errors --- runtests.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/runtests.sh b/runtests.sh index 27749cf5..9249ad20 100755 --- a/runtests.sh +++ b/runtests.sh @@ -62,7 +62,7 @@ echo "Running tests with $(python --version)" pip install --upgrade -r ${DRIVER_HOME}/test_requirements.txt echo "" TEST_RUNNER="coverage run -m ${UNITTEST} discover -vfs ${TEST}" -BEHAVE_RUNNER="behave --tags=-db,-in_dev test/tck" +BEHAVE_RUNNER="behave --tags=-db --tags=-tls test/tck" if [ ${RUNNING} -eq 1 ] then ${TEST_RUNNER} From dd7762de765eb77a5eada3c2d1a6317a5663d033 Mon Sep 17 00:00:00 2001 From: RagnarW Date: Fri, 4 Mar 2016 15:57:00 +0100 Subject: [PATCH 07/12] Now works with both 2.7 and 3.5 --- test/tck/steps/bolt_compability_steps.py | 6 ++++-- test/tck/steps/driver_equality_steps.py | 4 ++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/test/tck/steps/bolt_compability_steps.py b/test/tck/steps/bolt_compability_steps.py index 5a609bf6..4edac52d 100644 --- a/test/tck/steps/bolt_compability_steps.py +++ b/test/tck/steps/bolt_compability_steps.py @@ -80,15 +80,17 @@ def step_impl(context, size, type): @when("the driver asks the server to echo this (?P.+) back") def step_impl(context, unused): - context.results = [ send_string("RETURN " + as_cypher_text(context.expected)).stream(), send_parameters("RETURN {input}", {'input': context.expected}).stream()] + context.results = [ send_string("RETURN " + as_cypher_text(context.expected)), send_parameters("RETURN {input}", {'input': context.expected})] @step("the value given in the result should be the same as what was sent") def step_impl(context): assert len(context.results) > 0 for result in context.results: - result_value = result.next().values()[0] + assert result.next() + result_value = result.record().values()[0] assert result_value == context.expected + assert result.at_end() def as_cypher_text(expected): diff --git a/test/tck/steps/driver_equality_steps.py b/test/tck/steps/driver_equality_steps.py index 6e57ad63..388b4192 100644 --- a/test/tck/steps/driver_equality_steps.py +++ b/test/tck/steps/driver_equality_steps.py @@ -37,7 +37,7 @@ def step_impl(context, key1, key2): @step("saved values should all equal") def step_impl(context): - values = context.values.values() + values = list(context.values.values()) assert len(values) > 1 first_val = values.pop() for item in values: @@ -46,7 +46,7 @@ def step_impl(context): @step("none of the saved values should be equal") def step_impl(context): - values = context.values.values() + values = list(context.values.values()) assert len(values) > 1 first_val = values.pop() for item in values: From a403b17e801d91e41e338d67497f5687b3a6a6ba Mon Sep 17 00:00:00 2001 From: RagnarW Date: Fri, 4 Mar 2016 15:57:35 +0100 Subject: [PATCH 08/12] Updated Failing test output. Does not include intentionally skipped tests --- test/tck/environment.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/tck/environment.py b/test/tck/environment.py index 37852084..c6d28898 100644 --- a/test/tck/environment.py +++ b/test/tck/environment.py @@ -43,7 +43,7 @@ def before_scenario(context, scenario): def after_feature(context, feature): failed_scenarios = [] for scenario in feature.scenarios: - if scenario.status != "passed": + if scenario.status == "untested" or scenario.status == "failed" : failed_scenarios.append(scenario.name) if len(failed_scenarios) > 0: failing_features[feature.name] = failed_scenarios @@ -56,5 +56,5 @@ def after_all(context): print("Feature: %s" %feature) for scenario in list_of_scenarios: print("Failing scenario: %s" % scenario) - raise Exception("TCK FAILED!") + raise Exception("\tTCK FAILED!") From 0da257949985fcf1584627401b700532ce9dd6e2 Mon Sep 17 00:00:00 2001 From: RagnarW Date: Wed, 16 Mar 2016 15:55:23 +0100 Subject: [PATCH 09/12] Merge 1.0 --- README.rst | 8 +- docs/source/index.rst | 6 +- example.py | 5 +- examples/test_examples.py | 148 +++++------ neo4j/__main__.py | 32 ++- neo4j/meta.py | 2 +- neo4j/v1/__init__.py | 3 +- neo4j/v1/compat.py | 36 +-- neo4j/v1/connection.py | 127 ++++++++-- neo4j/v1/constants.py | 39 +++ neo4j/v1/session.py | 276 ++++++++++----------- neo4j/v1/{typesystem.py => types.py} | 0 neokit | 2 +- runtests.sh | 51 ++-- setup.py | 2 + test/auth.py | 43 ++++ test/tck/environment.py | 6 + test/tck/steps/bolt_compability_steps.py | 7 +- test/tck/tck_util.py | 23 +- test/test_session.py | 276 ++++++++++++--------- test/{test_typesystem.py => test_types.py} | 2 +- test/util.py | 25 +- 22 files changed, 680 insertions(+), 439 deletions(-) create mode 100644 neo4j/v1/constants.py rename neo4j/v1/{typesystem.py => types.py} (100%) create mode 100644 test/auth.py rename test/{test_typesystem.py => test_types.py} (98%) diff --git a/README.rst b/README.rst index cf9b5c1f..412941e6 100644 --- a/README.rst +++ b/README.rst @@ -28,10 +28,10 @@ Example Usage driver = GraphDatabase.driver("bolt://localhost") session = driver.session() session.run("CREATE (a:Person {name:'Bob'})") - cursor = session.run("MATCH (a:Person) RETURN a.name AS name") - while cursor.next() - print(cursor["name"]) - cursor.close() + result = session.run("MATCH (a:Person) RETURN a.name AS name") + for record in result: + print(record["name"]) + result.close() session.close() diff --git a/docs/source/index.rst b/docs/source/index.rst index 56384a37..bd6b0b70 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -24,15 +24,13 @@ Session API .. autoclass:: neo4j.v1.Record :members: -.. autofunction:: neo4j.v1.record - -.. autoclass:: neo4j.v1.ResultCursor +.. autoclass:: neo4j.v1.StatementResult :members: .. autoclass:: neo4j.v1.ResultSummary :members: -.. autoclass:: neo4j.v1.StatementStatistics +.. autoclass:: neo4j.v1.Counters :members: diff --git a/example.py b/example.py index 321f2f2a..4d328243 100644 --- a/example.py +++ b/example.py @@ -18,9 +18,10 @@ # See the License for the specific language governing permissions and # limitations under the License. -from neo4j.v1.session import GraphDatabase +from neo4j.v1.session import GraphDatabase, basic_auth -driver = GraphDatabase.driver("bolt://localhost") + +driver = GraphDatabase.driver("bolt://localhost", auth=basic_auth("neo4j", "neo4j")) session = driver.session() session.run("MERGE (a:Person {name:'Alice'})") diff --git a/examples/test_examples.py b/examples/test_examples.py index b3b38de6..3213fbab 100644 --- a/examples/test_examples.py +++ b/examples/test_examples.py @@ -19,17 +19,26 @@ # limitations under the License. -from unittest import TestCase +from unittest import skip +from neo4j.v1 import TRUST_ON_FIRST_USE, TRUST_SIGNED_CERTIFICATES +from test.util import ServerTestCase + +# Do not change the contents of this tagged section without good reason* # tag::minimal-example-import[] -from neo4j.v1 import GraphDatabase +from neo4j.v1 import GraphDatabase, basic_auth # end::minimal-example-import[] +# (* "good reason" is defined as knowing what you are doing) + + +auth_token = basic_auth("neo4j", "password") -class FreshDatabaseTestCase(TestCase): +class FreshDatabaseTestCase(ServerTestCase): def setUp(self): - session = GraphDatabase.driver("bolt://localhost").session() + ServerTestCase.setUp(self) + session = GraphDatabase.driver("bolt://localhost", auth=auth_token).session() session.run("MATCH (n) DETACH DELETE n") session.close() @@ -38,14 +47,14 @@ class MinimalWorkingExampleTestCase(FreshDatabaseTestCase): def test_minimal_working_example(self): # tag::minimal-example[] - driver = GraphDatabase.driver("bolt://localhost") + driver = GraphDatabase.driver("bolt://localhost", auth=basic_auth("neo4j", "password")) session = driver.session() - session.run("CREATE (neo:Person {name:'Neo', age:23})") + session.run("CREATE (a:Person {name:'Arthur', title:'King'})", ) - cursor = session.run("MATCH (p:Person) WHERE p.name = 'Neo' RETURN p.age") - while cursor.next(): - print("Neo is %d years old." % cursor["p.age"]) + result = session.run("MATCH (a:Person) WHERE a.name = 'Arthur' RETURN a.name AS name, a.title AS title") + for record in result: + print("%s %s" % (record["title"], record["name"])) session.close() # end::minimal-example[] @@ -55,7 +64,7 @@ class ExamplesTestCase(FreshDatabaseTestCase): def test_construct_driver(self): # tag::construct-driver[] - driver = GraphDatabase.driver("bolt://localhost") + driver = GraphDatabase.driver("bolt://localhost", auth=basic_auth("neo4j", "password")) # end::construct-driver[] return driver @@ -67,124 +76,123 @@ def test_configuration(self): def test_tls_require_encryption(self): # tag::tls-require-encryption[] - # TODO: Unfortunately, this feature is not yet implemented for Python - pass + driver = GraphDatabase.driver("bolt://localhost", auth=basic_auth("neo4j", "password"), encrypted=True) # end::tls-require-encryption[] def test_tls_trust_on_first_use(self): # tag::tls-trust-on-first-use[] - # TODO: Unfortunately, this feature is not yet implemented for Python - pass + driver = GraphDatabase.driver("bolt://localhost", auth=basic_auth("neo4j", "password"), encrypted=True, trust=TRUST_ON_FIRST_USE) # end::tls-trust-on-first-use[] + assert driver + @skip("testing verified certificates not yet supported ") def test_tls_signed(self): # tag::tls-signed[] - # TODO: Unfortunately, this feature is not yet implemented for Python - pass + driver = GraphDatabase.driver("bolt://localhost", auth=basic_auth("neo4j", "password"), encrypted=True, trust=TRUST_SIGNED_CERTIFICATES) # end::tls-signed[] + assert driver def test_statement(self): - driver = GraphDatabase.driver("bolt://localhost") + driver = GraphDatabase.driver("bolt://localhost", auth=auth_token) session = driver.session() # tag::statement[] - session.run("CREATE (person:Person {name: {name}})", {"name": "Neo"}).close() + result = session.run("CREATE (person:Person {name: {name}})", {"name": "Arthur"}) # end::statement[] + result.consume() session.close() def test_statement_without_parameters(self): - driver = GraphDatabase.driver("bolt://localhost") + driver = GraphDatabase.driver("bolt://localhost", auth=auth_token) session = driver.session() # tag::statement-without-parameters[] - session.run("CREATE (person:Person {name: 'Neo'})").close() + result = session.run("CREATE (person:Person {name: 'Arthur'})") # end::statement-without-parameters[] + result.consume() session.close() - def test_result_cursor(self): - driver = GraphDatabase.driver("bolt://localhost") + def test_result_traversal(self): + driver = GraphDatabase.driver("bolt://localhost", auth=auth_token) session = driver.session() - # tag::result-cursor[] - search_term = "hammer" - cursor = session.run("MATCH (tool:Tool) WHERE tool.name CONTAINS {term} " - "RETURN tool.name", {"term": search_term}) - print("List of tools called %r:" % search_term) - while cursor.next(): - print(cursor["tool.name"]) - # end::result-cursor[] + # tag::result-traversal[] + search_term = "sword" + result = session.run("MATCH (weapon:Weapon) WHERE weapon.name CONTAINS {term} " + "RETURN weapon.name", {"term": search_term}) + print("List of weapons called %r:" % search_term) + for record in result: + print(record["weapon.name"]) + # end::result-traversal[] session.close() - def test_cursor_nesting(self): - driver = GraphDatabase.driver("bolt://localhost") + def test_result_retention(self): + driver = GraphDatabase.driver("bolt://localhost", auth=auth_token) + # tag::retain-result[] session = driver.session() - # tag::retain-result-query[] - cursor = session.run("MATCH (person:Person) WHERE person.dept = {dept} " - "RETURN id(person) AS minion", {"dept": "IT"}) - while cursor.next(): - session.run("MATCH (person) WHERE id(person) = {id} " - "MATCH (boss:Person) WHERE boss.name = {boss} " - "CREATE (person)-[:REPORTS_TO]->(boss)", {"id": cursor["minion"], "boss": "Bob"}) - # end::retain-result-query[] + result = session.run("MATCH (knight:Person:Knight) WHERE knight.castle = {castle} " + "RETURN knight.name AS name", {"castle": "Camelot"}) + retained_result = list(result) session.close() + for record in retained_result: + print("%s is a knight of Camelot" % record["name"]) + # end::retain-result[] + assert isinstance(retained_result, list) - def test_result_retention(self): - driver = GraphDatabase.driver("bolt://localhost") + def test_nested_statements(self): + driver = GraphDatabase.driver("bolt://localhost", auth=auth_token) session = driver.session() - # tag::retain-result-process[] - cursor = session.run("MATCH (person:Person) WHERE person.dept = {dept} " - "RETURN id(person) AS minion", {"dept": "IT"}) - minion_records = list(cursor.stream()) - - for record in minion_records: - session.run("MATCH (person) WHERE id(person) = {id} " - "MATCH (boss:Person) WHERE boss.name = {boss} " - "CREATE (person)-[:REPORTS_TO]->(boss)", {"id": record["minion"], "boss": "Bob"}) - # end::retain-result-process[] + # tag::nested-statements[] + result = session.run("MATCH (knight:Person:Knight) WHERE knight.castle = {castle} " + "RETURN id(knight) AS knight_id", {"castle": "Camelot"}) + for record in result: + session.run("MATCH (knight) WHERE id(knight) = {id} " + "MATCH (king:Person) WHERE king.name = {king} " + "CREATE (knight)-[:DEFENDS]->(king)", {"id": record["knight_id"], "king": "Arthur"}) + # end::nested-statements[] session.close() def test_transaction_commit(self): - driver = GraphDatabase.driver("bolt://localhost") + driver = GraphDatabase.driver("bolt://localhost", auth=auth_token) session = driver.session() # tag::transaction-commit[] tx = session.begin_transaction() - tx.run("CREATE (p:Person {name: 'The One'})") + tx.run("CREATE (:Person {name: 'Guinevere'})") tx.commit() # end::transaction-commit[] - cursor = session.run("MATCH (p:Person {name: 'The One'}) RETURN count(p)") - assert cursor.next() - assert cursor["count(p)"] == 1 - assert cursor.at_end() + result = session.run("MATCH (p:Person {name: 'Guinevere'}) RETURN count(p)") + record = next(result) + assert record["count(p)"] == 1 session.close() def test_transaction_rollback(self): - driver = GraphDatabase.driver("bolt://localhost") + driver = GraphDatabase.driver("bolt://localhost", auth=auth_token) session = driver.session() # tag::transaction-rollback[] tx = session.begin_transaction() - tx.run("CREATE (p:Person {name: 'The One'})") + tx.run("CREATE (:Person {name: 'Merlin'})") tx.rollback() # end::transaction-rollback[] - cursor = session.run("MATCH (p:Person {name: 'The One'}) RETURN count(p)") - assert cursor.next() - assert cursor["count(p)"] == 0 - assert cursor.at_end() + result = session.run("MATCH (p:Person {name: 'Merlin'}) RETURN count(p)") + record = next(result) + assert record["count(p)"] == 0 session.close() def test_result_summary_query_profile(self): - driver = GraphDatabase.driver("bolt://localhost") + driver = GraphDatabase.driver("bolt://localhost", auth=auth_token) session = driver.session() # tag::result-summary-query-profile[] - cursor = session.run("PROFILE MATCH (p:Person {name: {name}}) " - "RETURN id(p)", {"name": "The One"}) - summary = cursor.summarize() + result = session.run("PROFILE MATCH (p:Person {name: {name}}) " + "RETURN id(p)", {"name": "Arthur"}) + summary = result.consume() print(summary.statement_type) print(summary.profile) # end::result-summary-query-profile[] session.close() def test_result_summary_notifications(self): - driver = GraphDatabase.driver("bolt://localhost") + driver = GraphDatabase.driver("bolt://localhost", auth=auth_token) session = driver.session() # tag::result-summary-notifications[] - summary = session.run("EXPLAIN MATCH (a), (b) RETURN a,b").summarize() + result = session.run("EXPLAIN MATCH (king), (queen) RETURN king, queen") + summary = result.consume() for notification in summary.notifications: print(notification) # end::result-summary-notifications[] diff --git a/neo4j/__main__.py b/neo4j/__main__.py index 13d5db8c..2d203658 100644 --- a/neo4j/__main__.py +++ b/neo4j/__main__.py @@ -27,19 +27,21 @@ from sys import stdout, stderr from .util import Watcher -from .v1.session import GraphDatabase, CypherError +from .v1.session import GraphDatabase, CypherError, basic_auth def main(): parser = ArgumentParser(description="Execute one or more Cypher statements using Bolt.") parser.add_argument("statement", nargs="+") - parser.add_argument("-u", "--url", default="bolt://localhost", metavar="CONNECTION_URL") + parser.add_argument("-k", "--keys", action="store_true") + parser.add_argument("-P", "--password") parser.add_argument("-p", "--parameter", action="append", metavar="NAME=VALUE") parser.add_argument("-q", "--quiet", action="store_true") - parser.add_argument("-s", "--secure", action="store_true") + parser.add_argument("-U", "--user", default="neo4j") + parser.add_argument("-u", "--url", default="bolt://localhost", metavar="CONNECTION_URL") parser.add_argument("-v", "--verbose", action="count") parser.add_argument("-x", "--times", type=int, default=1) - parser.add_argument("-z", "--summarize", action="store_true") + parser.add_argument("-z", "--summary", action="store_true") args = parser.parse_args() if args.verbose: @@ -57,30 +59,26 @@ def main(): except ValueError: parameters[name] = value - driver = GraphDatabase.driver(args.url, secure=args.secure) + driver = GraphDatabase.driver(args.url, auth=basic_auth(args.user, args.password)) session = driver.session() for _ in range(args.times): for statement in args.statement: try: - cursor = session.run(statement, parameters) + result = session.run(statement, parameters) except CypherError as error: stderr.write("%s: %s\r\n" % (error.code, error.message)) else: if not args.quiet: - has_results = False - for i, record in enumerate(cursor.stream()): - has_results = True - if i == 0: - stdout.write("%s\r\n" % "\t".join(record.keys())) - stdout.write("%s\r\n" % "\t".join(map(repr, record))) - if has_results: - stdout.write("\r\n") - if args.summarize: - summary = cursor.summarize() + if args.keys: + stdout.write("%s\r\n" % "\t".join(result.keys())) + for i, record in enumerate(result): + stdout.write("%s\r\n" % "\t".join(map(repr, record.values()))) + if args.summary: + summary = result.summary stdout.write("Statement : %r\r\n" % summary.statement) stdout.write("Parameters : %r\r\n" % summary.parameters) stdout.write("Statement Type : %r\r\n" % summary.statement_type) - stdout.write("Statistics : %r\r\n" % summary.statistics) + stdout.write("Counters : %r\r\n" % summary.counters) stdout.write("\r\n") session.close() diff --git a/neo4j/meta.py b/neo4j/meta.py index 1c28efe4..f13d779f 100644 --- a/neo4j/meta.py +++ b/neo4j/meta.py @@ -19,4 +19,4 @@ # limitations under the License. -version = "1.0.0b2" +version = "1.0.0b3" diff --git a/neo4j/v1/__init__.py b/neo4j/v1/__init__.py index d51d7b9a..5c275455 100644 --- a/neo4j/v1/__init__.py +++ b/neo4j/v1/__init__.py @@ -18,5 +18,6 @@ # See the License for the specific language governing permissions and # limitations under the License. +from .constants import * from .session import * -from .typesystem import * +from .types import * diff --git a/neo4j/v1/compat.py b/neo4j/v1/compat.py index 24cdbc74..7bae32b6 100644 --- a/neo4j/v1/compat.py +++ b/neo4j/v1/compat.py @@ -32,9 +32,19 @@ try: unicode except NameError: + # Python 3 + integer = int string = str + def ustr(x): + if isinstance(x, bytes): + return x.decode("utf-8") + elif isinstance(x, str): + return x + else: + return str(x) + def hex2(x): if x < 0x10: return "0" + hex(x)[2:].upper() @@ -42,9 +52,19 @@ def hex2(x): return hex(x)[2:].upper() else: + # Python 2 + integer = (int, long) string = (str, unicode) + def ustr(x): + if isinstance(x, str): + return x.decode("utf-8") + elif isinstance(x, unicode): + return x + else: + return unicode(x) + def hex2(x): x = ord(x) if x < 0x10: @@ -90,19 +110,3 @@ def perf_counter(): from urllib.parse import urlparse except ImportError: from urlparse import urlparse - - -try: - from ssl import SSLContext, PROTOCOL_SSLv23, OP_NO_SSLv2, HAS_SNI -except ImportError: - from ssl import wrap_socket, PROTOCOL_SSLv23 - - def secure_socket(s, host): - return wrap_socket(s, ssl_version=PROTOCOL_SSLv23) - -else: - - def secure_socket(s, host): - ssl_context = SSLContext(PROTOCOL_SSLv23) - ssl_context.options |= OP_NO_SSLv2 - return ssl_context.wrap_socket(s, server_hostname=host if HAS_SNI else None) diff --git a/neo4j/v1/connection.py b/neo4j/v1/connection.py index 70dbca1e..37fe2cee 100644 --- a/neo4j/v1/connection.py +++ b/neo4j/v1/connection.py @@ -21,25 +21,24 @@ from __future__ import division +from base64 import b64encode from collections import deque from io import BytesIO import logging -from os import environ +from os import makedirs, open as os_open, write as os_write, close as os_close, O_CREAT, O_APPEND, O_WRONLY +from os.path import dirname, isfile from select import select -from socket import create_connection, SHUT_RDWR +from socket import create_connection, SHUT_RDWR, error as SocketError +from ssl import HAS_SNI, SSLError from struct import pack as struct_pack, unpack as struct_unpack, unpack_from as struct_unpack_from -from ..meta import version -from .compat import hex2, secure_socket +from .constants import DEFAULT_PORT, DEFAULT_USER_AGENT, KNOWN_HOSTS, MAGIC_PREAMBLE, \ + TRUST_DEFAULT, TRUST_ON_FIRST_USE +from .compat import hex2 from .exceptions import ProtocolError from .packstream import Packer, Unpacker -DEFAULT_PORT = 7687 -DEFAULT_USER_AGENT = "neo4j-python/%s" % version - -MAGIC_PREAMBLE = 0x6060B017 - # Signature bytes for each message type INIT = b"\x01" # 0000 0001 // INIT RESET = b"\x0F" # 0000 1111 // RESET @@ -67,7 +66,7 @@ } # Set up logger -log = logging.getLogger("neo4j") +log = logging.getLogger("neo4j.bolt") log_debug = log.debug log_info = log.info log_warning = log.warning @@ -211,17 +210,27 @@ def __init__(self, sock, **config): user_agent = config.get("user_agent", DEFAULT_USER_AGENT) if isinstance(user_agent, bytes): user_agent = user_agent.decode("UTF-8") + self.user_agent = user_agent + + # Determine auth details + try: + self.auth_dict = vars(config["auth"]) + except KeyError: + self.auth_dict = {} + + # Pick up the server certificate, if any + self.der_encoded_server_certificate = config.get("der_encoded_server_certificate") def on_failure(metadata): - raise ProtocolError("Initialisation failed") + raise ProtocolError(metadata.get("message", "Inititalisation failed")) response = Response(self) response.on_failure = on_failure - self.append(INIT, (user_agent,), response=response) + self.append(INIT, (self.user_agent, self.auth_dict), response=response) self.send() while not response.complete: - self.fetch_next() + self.fetch() def __del__(self): self.close() @@ -255,9 +264,9 @@ def on_failure(metadata): self.append(RESET, response=response) self.send() - fetch_next = self.fetch_next + fetch = self.fetch while not response.complete: - fetch_next() + fetch() def send(self): """ Send all queued messages to the server. @@ -268,7 +277,7 @@ def send(self): raise ProtocolError("Cannot write to a defunct connection") self.channel.send() - def fetch_next(self): + def fetch(self): """ Receive exactly one message from the server. """ if self.closed: @@ -313,7 +322,53 @@ def close(self): self.closed = True -def connect(host, port=None, **config): +class CertificateStore(object): + + def match_or_trust(self, host, der_encoded_certificate): + """ Check whether the supplied certificate matches that stored for the + specified host. If it does, return ``True``, if it doesn't, return + ``False``. If no entry for that host is found, add it to the store + and return ``True``. + + :arg host: + :arg der_encoded_certificate: + :return: + """ + raise NotImplementedError() + + +class PersonalCertificateStore(CertificateStore): + + def __init__(self, path=None): + self.path = path or KNOWN_HOSTS + + def match_or_trust(self, host, der_encoded_certificate): + base64_encoded_certificate = b64encode(der_encoded_certificate) + if isfile(self.path): + with open(self.path) as f_in: + for line in f_in: + known_host, _, known_cert = line.strip().partition(":") + known_cert = known_cert.encode("utf-8") + if host == known_host: + return base64_encoded_certificate == known_cert + # First use (no hosts match) + try: + makedirs(dirname(self.path)) + except OSError: + pass + f_out = os_open(self.path, O_CREAT | O_APPEND | O_WRONLY, 0o600) # TODO: Windows + if isinstance(host, bytes): + os_write(f_out, host) + else: + os_write(f_out, host.encode("utf-8")) + os_write(f_out, b":") + os_write(f_out, base64_encoded_certificate) + os_write(f_out, b"\n") + os_close(f_out) + return True + + +def connect(host, port=None, ssl_context=None, **config): """ Connect and perform a handshake and return a valid Connection object, assuming a protocol version can be agreed. """ @@ -321,16 +376,36 @@ def connect(host, port=None, **config): # Establish a connection to the host and port specified port = port or DEFAULT_PORT if __debug__: log_info("~~ [CONNECT] %s %d", host, port) - s = create_connection((host, port)) - - # Secure the connection if so requested try: - secure = environ["NEO4J_SECURE"] - except KeyError: - secure = config.get("secure", False) - if secure: + s = create_connection((host, port)) + except SocketError as error: + if error.errno == 111: + raise ProtocolError("Unable to connect to %s on port %d - is the server running?" % (host, port)) + else: + raise + + # Secure the connection if an SSL context has been provided + if ssl_context: if __debug__: log_info("~~ [SECURE] %s", host) - s = secure_socket(s, host) + try: + s = ssl_context.wrap_socket(s, server_hostname=host if HAS_SNI else None) + except SSLError as cause: + error = ProtocolError("Cannot establish secure connection; %s" % cause.args[1]) + error.__cause__ = cause + raise error + else: + # Check that the server provides a certificate + der_encoded_server_certificate = s.getpeercert(binary_form=True) + if der_encoded_server_certificate is None: + raise ProtocolError("When using a secure socket, the server should always provide a certificate") + trust = config.get("trust", TRUST_DEFAULT) + if trust == TRUST_ON_FIRST_USE: + store = PersonalCertificateStore() + if not store.match_or_trust(host, der_encoded_server_certificate): + raise ProtocolError("Server certificate does not match known certificate for %r; check " + "details in file %r" % (host, KNOWN_HOSTS)) + else: + der_encoded_server_certificate = None # Send details of the protocol versions supported supported_versions = [1, 0, 0, 0] @@ -364,4 +439,4 @@ def connect(host, port=None, **config): s.shutdown(SHUT_RDWR) s.close() else: - return Connection(s, **config) + return Connection(s, der_encoded_server_certificate=der_encoded_server_certificate, **config) diff --git a/neo4j/v1/constants.py b/neo4j/v1/constants.py new file mode 100644 index 00000000..41e5f573 --- /dev/null +++ b/neo4j/v1/constants.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python +# -*- encoding: utf-8 -*- + +# Copyright (c) 2002-2016 "Neo Technology," +# Network Engine for Objects in Lund AB [http://neotechnology.com] +# +# This file is part of Neo4j. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +from os.path import expanduser, join + +from ..meta import version + + +DEFAULT_PORT = 7687 +DEFAULT_USER_AGENT = "neo4j-python/%s" % version + +KNOWN_HOSTS = join(expanduser("~"), ".neo4j", "known_hosts") + +MAGIC_PREAMBLE = 0x6060B017 + +ENCRYPTED_DEFAULT = True + +TRUST_ON_FIRST_USE = 0 +TRUST_SIGNED_CERTIFICATES = 1 + +TRUST_DEFAULT = TRUST_ON_FIRST_USE diff --git a/neo4j/v1/session.py b/neo4j/v1/session.py index fc3506a2..95c78160 100644 --- a/neo4j/v1/session.py +++ b/neo4j/v1/session.py @@ -29,11 +29,13 @@ class which can be used to obtain `Driver` instances that are used for from __future__ import division from collections import deque, namedtuple +from ssl import SSLContext, PROTOCOL_SSLv23, OP_NO_SSLv2, CERT_REQUIRED from .compat import integer, string, urlparse from .connection import connect, Response, RUN, PULL_ALL +from .constants import ENCRYPTED_DEFAULT, TRUST_DEFAULT, TRUST_SIGNED_CERTIFICATES from .exceptions import CypherError -from .typesystem import hydrated +from .types import hydrated DEFAULT_MAX_POOL_SIZE = 50 @@ -44,6 +46,26 @@ class which can be used to obtain `Driver` instances that are used for STATEMENT_TYPE_SCHEMA_WRITE = "s" +def basic_auth(user, password): + """ Generate a basic auth token for a given user and password. + + :param user: user name + :param password: current password + :return: auth token for use with :meth:`GraphDatabase.driver` + """ + return AuthToken("basic", user, password) + + +class AuthToken(object): + """ Container for auth information + """ + + def __init__(self, scheme, principal, credentials): + self.scheme = scheme + self.principal = principal + self.credentials = credentials + + class GraphDatabase(object): """ The :class:`.GraphDatabase` class provides access to all graph database functionality. This is primarily used to construct a driver @@ -77,6 +99,17 @@ def __init__(self, url, **config): self.config = config self.max_pool_size = config.get("max_pool_size", DEFAULT_MAX_POOL_SIZE) self.session_pool = deque() + self.encrypted = encrypted = config.get("encrypted", ENCRYPTED_DEFAULT) + self.trust = trust = config.get("trust", TRUST_DEFAULT) + if encrypted: + ssl_context = SSLContext(PROTOCOL_SSLv23) + ssl_context.options |= OP_NO_SSLv2 + if trust >= TRUST_SIGNED_CERTIFICATES: + ssl_context.verify_mode = CERT_REQUIRED + ssl_context.set_default_verify_paths() + self.ssl_context = ssl_context + else: + self.ssl_context = None def session(self): """ Create a new session based on the graph database details @@ -108,149 +141,107 @@ def recycle(self, session): :return: """ pool = self.session_pool - for s in pool: + for s in list(pool): # freezing the pool into a list for iteration allows pool mutation inside the loop if not s.healthy: pool.remove(s) if session.healthy and len(pool) < self.max_pool_size and session not in pool: pool.appendleft(session) -class ResultCursor(object): +class StatementResult(object): """ A handler for the result of Cypher statement execution. """ - #: The statement that was executed to produce this result. + #: The statement text that was executed to produce this result. statement = None #: Dictionary of parameters passed with the statement. parameters = None - def __init__(self, connection, statement, parameters): - super(ResultCursor, self).__init__() - self.statement = statement - self.parameters = parameters + def __init__(self, connection, run_response, pull_all_response): + super(StatementResult, self).__init__() + + # The Connection instance behind this result. + self.connection = connection + + # The keys for the records in the result stream. These are + # lazily populated on request. self._keys = None - self._connection = connection - self._current = None - self._next = deque() - self._position = -1 + + # Buffer for incoming records to be queued before yielding. If + # the result is used immediately, this buffer will be ignored. + self._buffer = deque() + + # The result summary (populated after the records have been + # fully consumed). self._summary = None + + # Flag to indicate whether the entire stream has been consumed + # from the network (but not necessarily yielded). self._consumed = False - def is_open(self): - """ Return ``True`` if this cursor is still open, ``False`` otherwise. - """ - return bool(self._connection) + def on_header(metadata): + # Called on receipt of the result header. + self._keys = metadata["fields"] - def close(self): - """ Consume the remainder of this result and detach the connection - from this cursor. - """ - if self._connection and not self._connection.closed: - self._consume() - self._connection = None + def on_record(values): + # Called on receipt of each result record. + self._buffer.append(values) - def next(self): - """ Advance to the next record, if available, and return a boolean - to indicate whether or not the cursor has moved. - """ - if self._next: - values = self._next.popleft() - self._current = Record(self.keys(), tuple(map(hydrated, values))) - self._position += 1 - return True - elif self._consumed: - return False - else: - self._connection.fetch_next() - return self.next() + def on_footer(metadata): + # Called on receipt of the result footer. + self._summary = ResultSummary(self.statement, self.parameters, **metadata) + self._consumed = True - def record(self): - """ Return the current record. - """ - return self._current + def on_failure(metadata): + # Called on execution failure. + self._consumed = True + raise CypherError(metadata) - def position(self): - """ Return the current cursor position. - """ - return self._position + run_response.on_success = on_header + run_response.on_failure = on_failure - def at_end(self): - """ Return ``True`` if at the end of the record stream, ``False`` - otherwise. - """ - if self._next: - return False - elif self._consumed: - return True - else: - self._connection.fetch_next() - return self.at_end() + pull_all_response.on_record = on_record + pull_all_response.on_success = on_footer + pull_all_response.on_failure = on_failure - def stream(self): - """ Yield all subsequent records. - """ - while self.next(): - yield self.record() + def __iter__(self): + return self - def __getitem__(self, item): - current = self._current - if current is None: - raise TypeError("No current record") - return current[item] + def __next__(self): + if self._buffer: + values = self._buffer.popleft() + return Record(self.keys(), tuple(map(hydrated, values))) + elif self._consumed: + raise StopIteration() + else: + fetch = self.connection.fetch + while not self._buffer and not self._consumed: + fetch() + return self.__next__() def keys(self): """ Return the keys for the records. """ # Fetch messages until we have the header or a failure while self._keys is None and not self._consumed: - self._connection.fetch_next() + self.connection.fetch() return self._keys - def get(self, item, default=None): - current = self._current - if current is None: - raise TypeError("No current record") - try: - return current[item] - except (IndexError, KeyError): - return default - - def summarize(self): - """ Consume the remainder of this result and produce a summary. - - :rtype: ResultSummary + def consume(self): + """ Consume the remainder of this result and return the + summary. """ - self._consume() + if self.connection and not self.connection.closed: + fetch = self.connection.fetch + while not self._consumed: + fetch() + self.connection = None return self._summary - def _consume(self): - # Consume the remainder of this result, triggering all appropriate callback functions. - fetch_next = self._connection.fetch_next - while not self._consumed: - fetch_next() - - def _on_header(self, metadata): - # Called on receipt of the result header. - self._keys = metadata["fields"] - - def _on_record(self, values): - # Called on receipt of each result record. - self._next.append(values) - - def _on_footer(self, metadata): - # Called on receipt of the result footer. - self._summary = ResultSummary(self.statement, self.parameters, **metadata) - self._consumed = True - - def _on_failure(self, metadata): - # Called on execution failure. - self._consumed = True - raise CypherError(metadata) - class ResultSummary(object): - """ A summary of execution returned with a :class:`.ResultCursor` object. + """ A summary of execution returned with a :class:`.StatementResult` object. """ #: The statement that was executed to produce this result. @@ -262,8 +253,8 @@ class ResultSummary(object): #: The type of statement (``'r'`` = read-only, ``'rw'`` = read/write). statement_type = None - #: A set of statistical information held in a :class:`.StatementStatistics` instance. - statistics = None + #: A set of statistical information held in a :class:`.Counters` instance. + counters = None #: A :class:`.Plan` instance plan = None @@ -281,7 +272,7 @@ def __init__(self, statement, parameters, **metadata): self.statement = statement self.parameters = parameters self.statement_type = metadata.get("type") - self.statistics = StatementStatistics(metadata.get("stats", {})) + self.counters = SummaryCounters(metadata.get("stats", {})) if "plan" in metadata: self.plan = make_plan(metadata["plan"]) if "profile" in metadata: @@ -293,16 +284,13 @@ def __init__(self, statement, parameters, **metadata): if position is not None: position = Position(position["offset"], position["line"], position["column"]) self.notifications.append(Notification(notification["code"], notification["title"], - notification["description"], position)) + notification["description"], notification["severity"], position)) -class StatementStatistics(object): +class SummaryCounters(object): """ Set of statistics from a Cypher statement execution. """ - #: - contains_updates = False - #: nodes_created = 0 @@ -344,6 +332,14 @@ def __init__(self, statistics): def __repr__(self): return repr(vars(self)) + @property + def contains_updates(self): + return self.nodes_created or self.nodes_deleted or \ + self.relationships_created or self.relationships_deleted or \ + self.properties_set or self.labels_added or self.labels_removed or \ + self.indexes_added or self.indexes_removed or \ + self.constraints_added or self.constraints_removed + #: A plan describes how the database will execute your statement. #: @@ -376,9 +372,11 @@ def __repr__(self): #: a short summary of the notification #: description: #: a long description of the notification +#: severity: +#: the severity level of the notification #: position: #: the position in the statement where this notification points to, if relevant. -Notification = namedtuple("Notification", ("code", "title", "description", "position")) +Notification = namedtuple("Notification", ("code", "title", "description", "severity", "position")) #: A position within a statement, consisting of offset, line and column. #: @@ -417,13 +415,16 @@ class Session(object): def __init__(self, driver): self.driver = driver - self.connection = connect(driver.host, driver.port, **driver.config) + self.connection = connect(driver.host, driver.port, driver.ssl_context, **driver.config) self.transaction = None - self.last_cursor = None + self.last_result = None def __del__(self): - if not self.connection.closed: - self.connection.close() + try: + if not self.connection.closed: + self.connection.close() + except AttributeError: + pass def __enter__(self): return self @@ -445,7 +446,7 @@ def run(self, statement, parameters=None): :param statement: Cypher statement to execute :param parameters: dictionary of parameters :return: Cypher result - :rtype: :class:`.ResultCursor` + :rtype: :class:`.StatementResult` """ # Ensure the statement is a Unicode value @@ -462,29 +463,24 @@ def run(self, statement, parameters=None): params[key] = value parameters = params - cursor = ResultCursor(self.connection, statement, parameters) - run_response = Response(self.connection) - run_response.on_success = cursor._on_header - run_response.on_failure = cursor._on_failure - pull_all_response = Response(self.connection) - pull_all_response.on_record = cursor._on_record - pull_all_response.on_success = cursor._on_footer - pull_all_response.on_failure = cursor._on_failure + result = StatementResult(self.connection, run_response, pull_all_response) + result.statement = statement + result.parameters = parameters self.connection.append(RUN, (statement, parameters), response=run_response) self.connection.append(PULL_ALL, response=pull_all_response) self.connection.send() - self.last_cursor = cursor - return cursor + self.last_result = result + return result def close(self): """ Recycle this session through the driver it came from. """ - if self.last_cursor: - self.last_cursor.close() + if self.last_result: + self.last_result.consume() self.driver.recycle(self) def begin_transaction(self): @@ -642,19 +638,3 @@ def __eq__(self, other): def __ne__(self, other): return not self.__eq__(other) - -def record(obj): - """ Obtain an immutable record for the given object - (either by calling obj.__record__() or by copying out the record data) - """ - try: - return obj.__record__() - except AttributeError: - keys = obj.keys() - values = [] - for key in keys: - values.append(obj[key]) - return Record(keys, values) - - - diff --git a/neo4j/v1/typesystem.py b/neo4j/v1/types.py similarity index 100% rename from neo4j/v1/typesystem.py rename to neo4j/v1/types.py diff --git a/neokit b/neokit index 8fc30fd9..3c5a450e 160000 --- a/neokit +++ b/neokit @@ -1 +1 @@ -Subproject commit 8fc30fd9fa5145fdd8b6627f3525a66b0213becc +Subproject commit 3c5a450eb04018fb5f6cb6f6a6e15d1f6b537915 diff --git a/runtests.sh b/runtests.sh index 9249ad20..3cbc45e7 100755 --- a/runtests.sh +++ b/runtests.sh @@ -21,6 +21,11 @@ DRIVER_HOME=$(dirname $0) NEORUN_OPTIONS="" RUNNING=0 +KNOWN_HOSTS="${HOME}/.neo4j/known_hosts" +KNOWN_HOSTS_BACKUP="${KNOWN_HOSTS}.backup" + +FG_BRIGHT_RED='\033[1;31m' +FG_DEFAULT='\033[0m' # Parse options while getopts ":dr" OPTION @@ -57,30 +62,46 @@ then VERSIONS="nightly" fi +function check_exit_status { + EXIT_STATUS=$1 + if [ ${EXIT_STATUS} -ne 0 ] + then + echo "" + echo -e "${FG_BRIGHT_RED}Tests failed with status ${EXIT_STATUS}${FG_DEFAULT}" + exit ${EXIT_STATUS} + fi +} + # Run tests -echo "Running tests with $(python --version)" +echo "Using $(python --version)" pip install --upgrade -r ${DRIVER_HOME}/test_requirements.txt echo "" + TEST_RUNNER="coverage run -m ${UNITTEST} discover -vfs ${TEST}" BEHAVE_RUNNER="behave --tags=-db --tags=-tls test/tck" +EXAMPLES_RUNNER="coverage run -m ${UNITTEST} discover -vfs examples" +BEHAVE_RUNNER="behave --tags=-db --tags=-in_dev test/tck" + if [ ${RUNNING} -eq 1 ] then ${TEST_RUNNER} - EXIT_STATUS=$? + check_exit_status $? else + #echo "Updating password" + #mv ${KNOWN_HOSTS} ${KNOWN_HOSTS_BACKUP} + #neokit/neorun ${NEORUN_OPTIONS} "python -m test.auth password" ${VERSIONS} + #EXIT_STATUS=$? + #mv ${KNOWN_HOSTS_BACKUP} ${KNOWN_HOSTS} + #check_exit_status ${EXIT_STATUS} + export NEO4J_PASSWORD="password" + + echo "Running unit tests" neokit/neorun ${NEORUN_OPTIONS} "${TEST_RUNNER}" ${VERSIONS} - EXIT_STATUS=$? - if [ ${EXIT_STATUS} -ne 0 ] - then - exit ${EXIT_STATUS} - fi + check_exit_status $? + echo "Testing example code" neokit/neorun ${NEORUN_OPTIONS} "${EXAMPLES_RUNNER}" ${VERSIONS} - EXIT_STATUS=$? - if [ ${EXIT_STATUS} -ne 0 ] - then - exit ${EXIT_STATUS} - fi + check_exit_status $? coverage report --show-missing python -c 'from test.tck.configure_feature_files import *; set_up()' @@ -90,9 +111,3 @@ else echo "Feature files removed" fi - -# Exit correctly -if [ ${EXIT_STATUS} -ne 0 ] -then - exit ${EXIT_STATUS} -fi diff --git a/setup.py b/setup.py index 5fa6ab90..ea4a6002 100644 --- a/setup.py +++ b/setup.py @@ -48,5 +48,7 @@ def read(fname): "Topic :: Database", "Topic :: Software Development", "Programming Language :: Python :: 2.7", + "Programming Language :: Python :: 3.3", + "Programming Language :: Python :: 3.4", ], packages=["neo4j", "neo4j.v1"]) diff --git a/test/auth.py b/test/auth.py new file mode 100644 index 00000000..a84a3157 --- /dev/null +++ b/test/auth.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python +# -*- encoding: utf-8 -*- + +# Copyright (c) 2002-2016 "Neo Technology," +# Network Engine for Objects in Lund AB [http://neotechnology.com] +# +# This file is part of Neo4j. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +from sys import argv + +from neo4j.v1 import GraphDatabase, basic_auth +from neo4j.util import Watcher + + +def update_password(user, password, new_password): + """ Test utility for setting the initial password. + + :param user: user name + :param password: current password + :param new_password: new password + """ + + token = basic_auth(user, password) + setattr(token, "new-credentials", new_password) # TODO: hopefully switch hyphen to underscore on server + GraphDatabase.driver("bolt://localhost", auth=token).session().close() + + +if __name__ == "__main__": + Watcher("neo4j.bolt").watch() + update_password("neo4j", "neo4j", argv[1]) diff --git a/test/tck/environment.py b/test/tck/environment.py index c6d28898..5099ce28 100644 --- a/test/tck/environment.py +++ b/test/tck/environment.py @@ -58,3 +58,9 @@ def after_all(context): print("Failing scenario: %s" % scenario) raise Exception("\tTCK FAILED!") + + +def after_scenario(context, scenario): + if scenario.status != "passed": + raise Exception("%s did not pass" %scenario) + diff --git a/test/tck/steps/bolt_compability_steps.py b/test/tck/steps/bolt_compability_steps.py index 4edac52d..5c414ff1 100644 --- a/test/tck/steps/bolt_compability_steps.py +++ b/test/tck/steps/bolt_compability_steps.py @@ -86,8 +86,13 @@ def step_impl(context, unused): @step("the value given in the result should be the same as what was sent") def step_impl(context): assert len(context.results) > 0 + print(context.results[0]) + print(list(context.results[0])) for result in context.results: - assert result.next() + print(result) + for record in result: + print(record) + assert len(list(result)) == 1 result_value = result.record().values()[0] assert result_value == context.expected assert result.at_end() diff --git a/test/tck/tck_util.py b/test/tck/tck_util.py index 9c7b4e0f..a27c466c 100644 --- a/test/tck/tck_util.py +++ b/test/tck/tck_util.py @@ -18,29 +18,28 @@ # See the License for the specific language governing permissions and # limitations under the License. -from neo4j.v1 import compat, Relationship, Node, Path -from neo4j.v1 import GraphDatabase +from neo4j.v1 import GraphDatabase, Relationship, Node, Path, basic_auth +from neo4j.v1.compat import string driver = GraphDatabase.driver("bolt://localhost") session = driver.session() def send_string(text): - cursor = session.run(text) - return cursor + statement_result = session.run(text) + return statement_result def send_parameters(statement, parameters): - cursor = session.run(statement, parameters) - return cursor + statement_result = session.run(statement, parameters) + return statement_result -def to_unicode(val): - try: - return unicode(val) - except NameError: - return str(val) +try: + to_unicode = unicode +except NameError: + to_unicode = str def string_to_type(str): @@ -88,7 +87,7 @@ def __init__(self, entity): elif isinstance(entity, Path): self.content = self.create_path(entity) elif isinstance(entity, int) or isinstance(entity, float) or isinstance(entity, - (str, compat.string)) or entity is None: + (str, string)) or entity is None: self.content['value'] = entity else: raise ValueError("Do not support object type: %s" % entity) diff --git a/test/test_session.py b/test/test_session.py index 0f60d3ae..17103c3c 100644 --- a/test/test_session.py +++ b/test/test_session.py @@ -19,23 +19,31 @@ # limitations under the License. -from unittest import TestCase +from socket import socket +from ssl import SSLSocket from mock import patch -from neo4j.v1.session import GraphDatabase, CypherError, Record, record -from neo4j.v1.typesystem import Node, Relationship, Path +from neo4j.v1.constants import TRUST_ON_FIRST_USE +from neo4j.v1.exceptions import CypherError +from neo4j.v1.session import GraphDatabase, basic_auth, Record +from neo4j.v1.types import Node, Relationship, Path +from test.util import ServerTestCase -class DriverTestCase(TestCase): + +auth_token = basic_auth("neo4j", "password") + + +class DriverTestCase(ServerTestCase): def test_healthy_session_will_be_returned_to_the_pool_on_close(self): - driver = GraphDatabase.driver("bolt://localhost") + driver = GraphDatabase.driver("bolt://localhost", auth=auth_token) assert len(driver.session_pool) == 0 driver.session().close() assert len(driver.session_pool) == 1 def test_unhealthy_session_will_not_be_returned_to_the_pool_on_close(self): - driver = GraphDatabase.driver("bolt://localhost") + driver = GraphDatabase.driver("bolt://localhost", auth=auth_token) assert len(driver.session_pool) == 0 session = driver.session() session.connection.defunct = True @@ -43,7 +51,7 @@ def test_unhealthy_session_will_not_be_returned_to_the_pool_on_close(self): assert len(driver.session_pool) == 0 def session_pool_cannot_exceed_max_size(self): - driver = GraphDatabase.driver("bolt://localhost", max_pool_size=1) + driver = GraphDatabase.driver("bolt://localhost", auth=auth_token, max_pool_size=1) assert len(driver.session_pool) == 0 driver.session().close() assert len(driver.session_pool) == 1 @@ -51,7 +59,7 @@ def session_pool_cannot_exceed_max_size(self): assert len(driver.session_pool) == 1 def test_session_that_dies_in_the_pool_will_not_be_given_out(self): - driver = GraphDatabase.driver("bolt://localhost") + driver = GraphDatabase.driver("bolt://localhost", auth=auth_token) session_1 = driver.session() session_1.close() assert len(driver.session_pool) == 1 @@ -59,15 +67,12 @@ def test_session_that_dies_in_the_pool_will_not_be_given_out(self): session_2 = driver.session() assert session_2 is not session_1 - -class RunTestCase(TestCase): - def test_must_use_valid_url_scheme(self): with self.assertRaises(ValueError): - GraphDatabase.driver("x://xxx") + GraphDatabase.driver("x://xxx", auth=auth_token) def test_sessions_are_reused(self): - driver = GraphDatabase.driver("bolt://localhost") + driver = GraphDatabase.driver("bolt://localhost", auth=auth_token) session_1 = driver.session() session_1.close() session_2 = driver.session() @@ -75,17 +80,54 @@ def test_sessions_are_reused(self): assert session_1 is session_2 def test_sessions_are_not_reused_if_still_in_use(self): - driver = GraphDatabase.driver("bolt://localhost") + driver = GraphDatabase.driver("bolt://localhost", auth=auth_token) session_1 = driver.session() session_2 = driver.session() session_2.close() session_1.close() assert session_1 is not session_2 + +class SecurityTestCase(ServerTestCase): + + def test_default_session_uses_tofu(self): + driver = GraphDatabase.driver("bolt://localhost") + assert driver.trust == TRUST_ON_FIRST_USE + + def test_insecure_session_uses_normal_socket(self): + driver = GraphDatabase.driver("bolt://localhost", auth=auth_token, encrypted=False) + session = driver.session() + connection = session.connection + assert isinstance(connection.channel.socket, socket) + assert connection.der_encoded_server_certificate is None + session.close() + + def test_tofu_session_uses_secure_socket(self): + driver = GraphDatabase.driver("bolt://localhost", auth=auth_token, encrypted=True, trust=TRUST_ON_FIRST_USE) + session = driver.session() + connection = session.connection + assert isinstance(connection.channel.socket, SSLSocket) + assert connection.der_encoded_server_certificate is not None + session.close() + + def test_tofu_session_trusts_certificate_after_first_use(self): + driver = GraphDatabase.driver("bolt://localhost", auth=auth_token, encrypted=True, trust=TRUST_ON_FIRST_USE) + session = driver.session() + connection = session.connection + certificate = connection.der_encoded_server_certificate + session.close() + session = driver.session() + connection = session.connection + assert connection.der_encoded_server_certificate == certificate + session.close() + + +class RunTestCase(ServerTestCase): + def test_can_run_simple_statement(self): - session = GraphDatabase.driver("bolt://localhost").session() - count = 0 - for record in session.run("RETURN 1 AS n").stream(): + session = GraphDatabase.driver("bolt://localhost", auth=auth_token).session() + result = session.run("RETURN 1 AS n") + for record in result: assert record[0] == 1 assert record["n"] == 1 with self.assertRaises(KeyError): @@ -97,14 +139,12 @@ def test_can_run_simple_statement(self): _ = record[object()] assert repr(record) assert len(record) == 1 - count += 1 session.close() - assert count == 1 def test_can_run_simple_statement_with_params(self): - session = GraphDatabase.driver("bolt://localhost").session() + session = GraphDatabase.driver("bolt://localhost", auth=auth_token).session() count = 0 - for record in session.run("RETURN {x} AS n", {"x": {"abc": ["d", "e", "f"]}}).stream(): + for record in session.run("RETURN {x} AS n", {"x": {"abc": ["d", "e", "f"]}}): assert record[0] == {"abc": ["d", "e", "f"]} assert record["n"] == {"abc": ["d", "e", "f"]} assert repr(record) @@ -114,19 +154,19 @@ def test_can_run_simple_statement_with_params(self): assert count == 1 def test_fails_on_bad_syntax(self): - session = GraphDatabase.driver("bolt://localhost").session() + session = GraphDatabase.driver("bolt://localhost", auth=auth_token).session() with self.assertRaises(CypherError): - session.run("X").close() + session.run("X").consume() def test_fails_on_missing_parameter(self): - session = GraphDatabase.driver("bolt://localhost").session() + session = GraphDatabase.driver("bolt://localhost", auth=auth_token).session() with self.assertRaises(CypherError): - session.run("RETURN {x}").close() + session.run("RETURN {x}").consume() def test_can_run_simple_statement_from_bytes_string(self): - session = GraphDatabase.driver("bolt://localhost").session() + session = GraphDatabase.driver("bolt://localhost", auth=auth_token).session() count = 0 - for record in session.run(b"RETURN 1 AS n").stream(): + for record in session.run(b"RETURN 1 AS n"): assert record[0] == 1 assert record["n"] == 1 assert repr(record) @@ -136,24 +176,24 @@ def test_can_run_simple_statement_from_bytes_string(self): assert count == 1 def test_can_run_statement_that_returns_multiple_records(self): - session = GraphDatabase.driver("bolt://localhost").session() + session = GraphDatabase.driver("bolt://localhost", auth=auth_token).session() count = 0 - for record in session.run("unwind(range(1, 10)) AS z RETURN z").stream(): + for record in session.run("unwind(range(1, 10)) AS z RETURN z"): assert 1 <= record[0] <= 10 count += 1 session.close() assert count == 10 def test_can_use_with_to_auto_close_session(self): - with GraphDatabase.driver("bolt://localhost").session() as session: - record_list = list(session.run("RETURN 1").stream()) + with GraphDatabase.driver("bolt://localhost", auth=auth_token).session() as session: + record_list = list(session.run("RETURN 1")) assert len(record_list) == 1 for record in record_list: assert record[0] == 1 def test_can_return_node(self): - with GraphDatabase.driver("bolt://localhost").session() as session: - record_list = list(session.run("MERGE (a:Person {name:'Alice'}) RETURN a").stream()) + with GraphDatabase.driver("bolt://localhost", auth=auth_token).session() as session: + record_list = list(session.run("MERGE (a:Person {name:'Alice'}) RETURN a")) assert len(record_list) == 1 for record in record_list: alice = record[0] @@ -162,9 +202,8 @@ def test_can_return_node(self): assert alice.properties == {"name": "Alice"} def test_can_return_relationship(self): - with GraphDatabase.driver("bolt://localhost").session() as session: - reocrd_list = list(session.run("MERGE ()-[r:KNOWS {since:1999}]->() " - "RETURN r").stream()) + with GraphDatabase.driver("bolt://localhost", auth=auth_token).session() as session: + reocrd_list = list(session.run("MERGE ()-[r:KNOWS {since:1999}]->() RETURN r")) assert len(reocrd_list) == 1 for record in reocrd_list: rel = record[0] @@ -173,9 +212,8 @@ def test_can_return_relationship(self): assert rel.properties == {"since": 1999} def test_can_return_path(self): - with GraphDatabase.driver("bolt://localhost").session() as session: - record_list = list(session.run("MERGE p=({name:'Alice'})-[:KNOWS]->({name:'Bob'}) " - "RETURN p").stream()) + with GraphDatabase.driver("bolt://localhost", auth=auth_token).session() as session: + record_list = list(session.run("MERGE p=({name:'Alice'})-[:KNOWS]->({name:'Bob'}) RETURN p")) assert len(record_list) == 1 for record in record_list: path = record[0] @@ -187,29 +225,47 @@ def test_can_return_path(self): assert len(path.relationships) == 1 def test_can_handle_cypher_error(self): - with GraphDatabase.driver("bolt://localhost").session() as session: + with GraphDatabase.driver("bolt://localhost", auth=auth_token).session() as session: with self.assertRaises(CypherError): - session.run("X").close() + session.run("X").consume() + + def test_keys_are_available_before_and_after_stream(self): + with GraphDatabase.driver("bolt://localhost", auth=auth_token).session() as session: + result = session.run("UNWIND range(1, 10) AS n RETURN n") + assert list(result.keys()) == ["n"] + list(result) + assert list(result.keys()) == ["n"] + + def test_keys_with_an_error(self): + with GraphDatabase.driver("bolt://localhost", auth=auth_token).session() as session: + result = session.run("X") + with self.assertRaises(CypherError): + list(result.keys()) + - def test_can_obtain_summary_info(self): - with GraphDatabase.driver("bolt://localhost").session() as session: - cursor = session.run("CREATE (n) RETURN n") - summary = cursor.summarize() +class SummaryTestCase(ServerTestCase): + + def test_can_obtain_summary_after_consuming_result(self): + with GraphDatabase.driver("bolt://localhost", auth=auth_token).session() as session: + result = session.run("CREATE (n) RETURN n") + summary = result.consume() assert summary.statement == "CREATE (n) RETURN n" assert summary.parameters == {} assert summary.statement_type == "rw" - assert summary.statistics.nodes_created == 1 + assert summary.counters.nodes_created == 1 def test_no_plan_info(self): - with GraphDatabase.driver("bolt://localhost").session() as session: - cursor = session.run("CREATE (n) RETURN n") - assert cursor.summarize().plan is None - assert cursor.summarize().profile is None + with GraphDatabase.driver("bolt://localhost", auth=auth_token).session() as session: + result = session.run("CREATE (n) RETURN n") + summary = result.consume() + assert summary.plan is None + assert summary.profile is None def test_can_obtain_plan_info(self): - with GraphDatabase.driver("bolt://localhost").session() as session: - cursor = session.run("EXPLAIN CREATE (n) RETURN n") - plan = cursor.summarize().plan + with GraphDatabase.driver("bolt://localhost", auth=auth_token).session() as session: + result = session.run("EXPLAIN CREATE (n) RETURN n") + summary = result.consume() + plan = summary.plan assert plan.operator_type == "ProduceResults" assert plan.identifiers == ["n"] assert plan.arguments == {"planner": "COST", "EstimatedRows": 1.0, "version": "CYPHER 3.0", @@ -218,9 +274,10 @@ def test_can_obtain_plan_info(self): assert len(plan.children) == 1 def test_can_obtain_profile_info(self): - with GraphDatabase.driver("bolt://localhost").session() as session: - cursor = session.run("PROFILE CREATE (n) RETURN n") - profile = cursor.summarize().profile + with GraphDatabase.driver("bolt://localhost", auth=auth_token).session() as session: + result = session.run("PROFILE CREATE (n) RETURN n") + summary = result.consume() + profile = summary.profile assert profile.db_hits == 0 assert profile.rows == 1 assert profile.operator_type == "ProduceResults" @@ -231,21 +288,24 @@ def test_can_obtain_profile_info(self): assert len(profile.children) == 1 def test_no_notification_info(self): - with GraphDatabase.driver("bolt://localhost").session() as session: + with GraphDatabase.driver("bolt://localhost", auth=auth_token).session() as session: result = session.run("CREATE (n) RETURN n") - notifications = result.summarize().notifications + summary = result.consume() + notifications = summary.notifications assert notifications == [] def test_can_obtain_notification_info(self): - with GraphDatabase.driver("bolt://localhost").session() as session: + with GraphDatabase.driver("bolt://localhost", auth=auth_token).session() as session: result = session.run("EXPLAIN MATCH (n), (m) RETURN n, m") - notifications = result.summarize().notifications + summary = result.consume() + notifications = summary.notifications assert len(notifications) == 1 notification = notifications[0] - assert notification.code == "Neo.ClientNotification.Statement.CartesianProduct" + assert notification.code == "Neo.ClientNotification.Statement.CartesianProductWarning" assert notification.title == "This query builds a cartesian product between " \ "disconnected patterns." + assert notification.severity == "WARNING" assert notification.description == "If a part of a query contains multiple " \ "disconnected patterns, this will build a " \ "cartesian product between all those parts. This " \ @@ -261,45 +321,32 @@ def test_can_obtain_notification_info(self): assert position.line == 1 assert position.column == 1 - def test_keys_are_available_before_and_after_stream(self): - with GraphDatabase.driver("bolt://localhost").session() as session: - cursor = session.run("UNWIND range(1, 10) AS n RETURN n") - assert list(cursor.keys()) == ["n"] - _ = list(cursor.stream()) - assert list(cursor.keys()) == ["n"] - - def test_keys_with_an_error(self): - with GraphDatabase.driver("bolt://localhost").session() as session: - cursor = session.run("X") - with self.assertRaises(CypherError): - _ = list(cursor.keys()) - -class ResetTestCase(TestCase): +class ResetTestCase(ServerTestCase): def test_automatic_reset_after_failure(self): - with GraphDatabase.driver("bolt://localhost").session() as session: + with GraphDatabase.driver("bolt://localhost", auth=auth_token).session() as session: try: - session.run("X").close() + session.run("X").consume() except CypherError: - cursor = session.run("RETURN 1") - assert cursor.next() - assert cursor[0] == 1 + result = session.run("RETURN 1") + record = next(result) + assert record[0] == 1 else: assert False, "A Cypher error should have occurred" def test_defunct(self): from neo4j.v1.connection import ChunkChannel, ProtocolError - with GraphDatabase.driver("bolt://localhost").session() as session: + with GraphDatabase.driver("bolt://localhost", auth=auth_token).session() as session: assert not session.connection.defunct with patch.object(ChunkChannel, "chunk_reader", side_effect=ProtocolError()): with self.assertRaises(ProtocolError): - session.run("RETURN 1").close() + session.run("RETURN 1").consume() assert session.connection.defunct assert session.connection.closed -class RecordTestCase(TestCase): +class RecordTestCase(ServerTestCase): def test_record_equality(self): record1 = Record(["name", "empire"], ["Nigel", "The British Empire"]) record2 = Record(["name", "empire"], ["Nigel", "The British Empire"]) @@ -345,10 +392,6 @@ def test_record_iter(self): a_record = Record(["name", "empire"], ["Nigel", "The British Empire"]) assert list(a_record.__iter__()) == ["name", "empire"] - def test_record_record(self): - a_record = Record(["name", "empire"], ["Nigel", "The British Empire"]) - assert record(a_record) is a_record - def test_record_copy(self): original = Record(["name", "empire"], ["Nigel", "The British Empire"]) duplicate = original.copy() @@ -373,15 +416,16 @@ def test_record_repr(self): assert repr(a_record) == "" -class TransactionTestCase(TestCase): +class TransactionTestCase(ServerTestCase): + def test_can_commit_transaction(self): - with GraphDatabase.driver("bolt://localhost").session() as session: + with GraphDatabase.driver("bolt://localhost", auth=auth_token).session() as session: tx = session.begin_transaction() # Create a node - cursor = tx.run("CREATE (a) RETURN id(a)") - assert cursor.next() - node_id = cursor[0] + result = tx.run("CREATE (a) RETURN id(a)") + record = next(result) + node_id = record[0] assert isinstance(node_id, int) # Update a property @@ -391,20 +435,20 @@ def test_can_commit_transaction(self): tx.commit() # Check the property value - cursor = session.run("MATCH (a) WHERE id(a) = {n} " + result = session.run("MATCH (a) WHERE id(a) = {n} " "RETURN a.foo", {"n": node_id}) - assert cursor.next() - foo = cursor[0] - assert foo == "bar" + record = next(result) + value = record[0] + assert value == "bar" def test_can_rollback_transaction(self): - with GraphDatabase.driver("bolt://localhost").session() as session: + with GraphDatabase.driver("bolt://localhost", auth=auth_token).session() as session: tx = session.begin_transaction() # Create a node - cursor = tx.run("CREATE (a) RETURN id(a)") - assert cursor.next() - node_id = cursor[0] + result = tx.run("CREATE (a) RETURN id(a)") + record = next(result) + node_id = record[0] assert isinstance(node_id, int) # Update a property @@ -414,17 +458,17 @@ def test_can_rollback_transaction(self): tx.rollback() # Check the property value - cursor = session.run("MATCH (a) WHERE id(a) = {n} " + result = session.run("MATCH (a) WHERE id(a) = {n} " "RETURN a.foo", {"n": node_id}) - assert len(list(cursor.stream())) == 0 + assert len(list(result)) == 0 def test_can_commit_transaction_using_with_block(self): - with GraphDatabase.driver("bolt://localhost").session() as session: + with GraphDatabase.driver("bolt://localhost", auth=auth_token).session() as session: with session.begin_transaction() as tx: # Create a node - cursor = tx.run("CREATE (a) RETURN id(a)") - assert cursor.next() - node_id = cursor[0] + result = tx.run("CREATE (a) RETURN id(a)") + record = next(result) + node_id = record[0] assert isinstance(node_id, int) # Update a property @@ -434,19 +478,19 @@ def test_can_commit_transaction_using_with_block(self): tx.success = True # Check the property value - cursor = session.run("MATCH (a) WHERE id(a) = {n} " + result = session.run("MATCH (a) WHERE id(a) = {n} " "RETURN a.foo", {"n": node_id}) - assert cursor.next() - foo = cursor[0] - assert foo == "bar" + record = next(result) + value = record[0] + assert value == "bar" def test_can_rollback_transaction_using_with_block(self): - with GraphDatabase.driver("bolt://localhost").session() as session: + with GraphDatabase.driver("bolt://localhost", auth=auth_token).session() as session: with session.begin_transaction() as tx: # Create a node - cursor = tx.run("CREATE (a) RETURN id(a)") - assert cursor.next() - node_id = cursor[0] + result = tx.run("CREATE (a) RETURN id(a)") + record = next(result) + node_id = record[0] assert isinstance(node_id, int) # Update a property @@ -454,6 +498,6 @@ def test_can_rollback_transaction_using_with_block(self): "SET a.foo = {foo}", {"n": node_id, "foo": "bar"}) # Check the property value - cursor = session.run("MATCH (a) WHERE id(a) = {n} " + result = session.run("MATCH (a) WHERE id(a) = {n} " "RETURN a.foo", {"n": node_id}) - assert len(list(cursor.stream())) == 0 + assert len(list(result)) == 0 diff --git a/test/test_typesystem.py b/test/test_types.py similarity index 98% rename from test/test_typesystem.py rename to test/test_types.py index a52ca7de..7810ad19 100644 --- a/test/test_typesystem.py +++ b/test/test_types.py @@ -23,7 +23,7 @@ from neo4j.v1.packstream import Structure -from neo4j.v1.typesystem import Node, Relationship, UnboundRelationship, Path, hydrated +from neo4j.v1.types import Node, Relationship, UnboundRelationship, Path, hydrated class NodeTestCase(TestCase): diff --git a/test/util.py b/test/util.py index 793fadb6..6c1a4614 100644 --- a/test/util.py +++ b/test/util.py @@ -20,8 +20,15 @@ import functools +from os import rename +from os.path import isfile +from unittest import TestCase from neo4j.util import Watcher +from neo4j.v1.constants import KNOWN_HOSTS + + +KNOWN_HOSTS_BACKUP = KNOWN_HOSTS + ".backup" def watch(f): @@ -34,8 +41,24 @@ def watch(f): """ @functools.wraps(f) def wrapper(*args, **kwargs): - watcher = Watcher("neo4j") + watcher = Watcher("neo4j.bolt") watcher.watch() f(*args, **kwargs) watcher.stop() return wrapper + + +class ServerTestCase(TestCase): + """ Base class for test cases that use a remote server. + """ + + known_hosts = KNOWN_HOSTS + known_hosts_backup = known_hosts + ".backup" + + def setUp(self): + if isfile(self.known_hosts): + rename(self.known_hosts, self.known_hosts_backup) + + def tearDown(self): + if isfile(self.known_hosts_backup): + rename(self.known_hosts_backup, self.known_hosts) From f88179fb6d75006f7779dac38f6ca922790ba45a Mon Sep 17 00:00:00 2001 From: RagnarW Date: Mon, 21 Mar 2016 11:08:31 +0100 Subject: [PATCH 10/12] Added headers --- test/tck/environment.py | 6 ------ test/tck/steps/bolt_compability_steps.py | 2 +- test/tck/steps/cypher_compability_steps.py | 2 +- test/tck/steps/driver_equality_steps.py | 21 +++++++++++++++++++-- test/tck/steps/driver_result_api_steps.py | 22 +++++++++++++++++++++- 5 files changed, 42 insertions(+), 11 deletions(-) diff --git a/test/tck/environment.py b/test/tck/environment.py index fd24edf4..3bbb7bae 100644 --- a/test/tck/environment.py +++ b/test/tck/environment.py @@ -23,10 +23,6 @@ failing_features = {} -# def before_all(context): -# context.config.setup_logging() - - def before_feature(context, feature): # Workaround. Behave has a different way of tagging than cucumber for scenario in feature.scenarios: @@ -66,6 +62,4 @@ def after_scenario(context, scenario): pass for runner in tck_util.runners: runner.close() - # if scenario.status != "passed": - # raise Exception("%s did not pass" %scenario) diff --git a/test/tck/steps/bolt_compability_steps.py b/test/tck/steps/bolt_compability_steps.py index 12de6195..e56af162 100644 --- a/test/tck/steps/bolt_compability_steps.py +++ b/test/tck/steps/bolt_compability_steps.py @@ -26,7 +26,7 @@ from test.tck import tck_util from test.tck.resultparser import parse_values -from test.tck.tck_util import to_unicode, Type, send_string, send_parameters, string_to_type +from test.tck.tck_util import to_unicode, Type, string_to_type from neo4j.v1 import compat use_step_matcher("re") diff --git a/test/tck/steps/cypher_compability_steps.py b/test/tck/steps/cypher_compability_steps.py index 2da79884..42055160 100644 --- a/test/tck/steps/cypher_compability_steps.py +++ b/test/tck/steps/cypher_compability_steps.py @@ -17,7 +17,7 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. -import time + from behave import * from test.tck import tck_util diff --git a/test/tck/steps/driver_equality_steps.py b/test/tck/steps/driver_equality_steps.py index dc146ad9..98c0b602 100644 --- a/test/tck/steps/driver_equality_steps.py +++ b/test/tck/steps/driver_equality_steps.py @@ -1,8 +1,25 @@ -from collections import deque +#!/usr/bin/env python +# -*- encoding: utf-8 -*- + +# Copyright (c) 2002-2016 "Neo Technology," +# Network Engine for Objects in Lund AB [http://neotechnology.com] +# +# This file is part of Neo4j. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. from behave import * -from neo4j.v1 import Path, Relationship from test.tck.tck_util import send_string use_step_matcher("re") diff --git a/test/tck/steps/driver_result_api_steps.py b/test/tck/steps/driver_result_api_steps.py index f2da1479..315701b5 100644 --- a/test/tck/steps/driver_result_api_steps.py +++ b/test/tck/steps/driver_result_api_steps.py @@ -1,6 +1,26 @@ +#!/usr/bin/env python +# -*- encoding: utf-8 -*- + +# Copyright (c) 2002-2016 "Neo Technology," +# Network Engine for Objects in Lund AB [http://neotechnology.com] +# +# This file is part of Neo4j. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + from behave import * -from neo4j.v1 import ResultSummary, STATEMENT_TYPE_READ_ONLY, STATEMENT_TYPE_READ_WRITE, STATEMENT_TYPE_WRITE_ONLY, \ +from neo4j.v1 import STATEMENT_TYPE_READ_ONLY, STATEMENT_TYPE_READ_WRITE, STATEMENT_TYPE_WRITE_ONLY, \ STATEMENT_TYPE_SCHEMA_WRITE from test.tck.resultparser import parse_values From 828ea6d340743b263a93e48e4d9d5db6b22ebb12 Mon Sep 17 00:00:00 2001 From: RagnarW Date: Mon, 21 Mar 2016 13:17:32 +0100 Subject: [PATCH 11/12] Fixed behave tagging --- runtests.sh | 1 - 1 file changed, 1 deletion(-) diff --git a/runtests.sh b/runtests.sh index 3cbc45e7..2c8a7444 100755 --- a/runtests.sh +++ b/runtests.sh @@ -78,7 +78,6 @@ pip install --upgrade -r ${DRIVER_HOME}/test_requirements.txt echo "" TEST_RUNNER="coverage run -m ${UNITTEST} discover -vfs ${TEST}" -BEHAVE_RUNNER="behave --tags=-db --tags=-tls test/tck" EXAMPLES_RUNNER="coverage run -m ${UNITTEST} discover -vfs examples" BEHAVE_RUNNER="behave --tags=-db --tags=-in_dev test/tck" From 3083a85708e213183c54ae24b3806810fa715d56 Mon Sep 17 00:00:00 2001 From: RagnarW Date: Wed, 30 Mar 2016 12:27:33 +0200 Subject: [PATCH 12/12] Removed commented test --- test/test_session.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/test/test_session.py b/test/test_session.py index c523831a..b4fc20ab 100644 --- a/test/test_session.py +++ b/test/test_session.py @@ -335,15 +335,15 @@ def test_automatic_reset_after_failure(self): else: assert False, "A Cypher error should have occurred" - # def test_defunct(self): - # from neo4j.v1.connection import ChunkChannel, ProtocolError - # with GraphDatabase.driver("bolt://localhost", auth=auth_token).session() as session: - # assert not session.connection.defunct - # with patch.object(ChunkChannel, "chunk_reader", side_effect=ProtocolError()): - # with self.assertRaises(ProtocolError): - # session.run("RETURN 1").consume() - # assert session.connection.defunct - # assert session.connection.closed + def test_defunct(self): + from neo4j.v1.connection import ChunkChannel, ProtocolError + with GraphDatabase.driver("bolt://localhost", auth=auth_token).session() as session: + assert not session.connection.defunct + with patch.object(ChunkChannel, "chunk_reader", side_effect=ProtocolError()): + with self.assertRaises(ProtocolError): + session.run("RETURN 1").consume() + assert session.connection.defunct + assert session.connection.closed class RecordTestCase(ServerTestCase):